LLM-driven 设计,添加学习审计和会话历史存储至目录的功能

This commit is contained in:
2026-05-15 11:50:20 +08:00
parent 2ba4f35a2d
commit 4ec6cbed16
15 changed files with 1557 additions and 133 deletions
+60 -115
View File
@@ -1,37 +1,51 @@
import { tool } from "@opencode-ai/plugin";
import { join, posix } from "node:path";
import { config } from "../../src/config.js";
import { SkillStore } from "../../src/skills/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
import {
atomicWriteFileWithHistory,
ensureDirectory,
readTextFile,
} from "../../src/utils/fileStore.js";
import { sanitizePersistentLine } from "../../src/utils/persistencePolicy.js";
const toolContextStore = new ToolSessionContextStore();
const initializePromise = toolContextStore.initialize();
const SKILLS_ROOT_DIR = ".opencode/skills";
// learned skill 与正式技能树同路径组织,但历史版本单独落到 data/history/skills 下。
const SKILLS_HISTORY_DIR = join(config.PERSISTENCE_HISTORY_DIR, "skills");
const LEARNED_PATTERNS_MARKER = "## Learned Patterns";
let writeQueue: Promise<void> = Promise.resolve();
const skillStore = new SkillStore();
export default tool({
description:
"已验证、可复用、非敏感的 workflow 或方法模式写入指定的 .opencode/skills 目录,由 opencode 自动识别和加载。",
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference。",
args: {
action: tool.schema
.enum([
"list",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
])
.describe("Skill maintenance operation."),
reason: tool.schema
.string()
.describe(
"The reusable workflow or method pattern to persist for future reuse, written as one concise sentence.",
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema
.string()
.describe(
"Target skill directory path relative to .opencode/skills, for example analytics/simulation-analysis/leakage or platform/governance-observability/meta.",
),
pattern: tool.schema
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema
.string()
.optional()
.describe("Stable learned pattern id used by remove_pattern."),
file_path: tool.schema
.string()
.optional()
.describe("Reference file path under references/, such as references/bottleneck-notes.md."),
content: tool.schema
.string()
.optional()
.describe("Reference markdown body used by write_reference."),
},
async execute(args, context) {
await initializePromise;
@@ -39,122 +53,53 @@ export default tool({
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const skillPath = normalizeSkillPath(args.skill_path);
if (!skillPath) {
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
detail: "skill writes are disabled for this session",
});
}
const pattern = sanitizePersistentLine(args.reason, 320);
if (!pattern) {
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
});
}
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "reason rejected by persistence policy",
decision: "accepted",
detail: "skill listed",
...result,
});
}
const result = await appendLearnedSkillPattern(skillPath, pattern);
const result =
args.action === "append_pattern"
? await skillStore.appendPattern(args.skill_path, args.pattern ?? "")
: args.action === "remove_pattern"
? await skillStore.removePattern(args.skill_path, args.target_id ?? "")
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeReference(args.skill_path, args.file_path ?? "");
return JSON.stringify({
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "deduped",
detail: result.changed ? "skill file updated" : "pattern already existed",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
});
},
});
const appendLearnedSkillPattern = async (
skillPath: string,
pattern: string,
) => {
return serializeWrite(async () => {
const target = join(SKILLS_ROOT_DIR, skillPath, "SKILL.md");
const current =
(await readTextFile(target)) ?? defaultLearnedSkill(skillPath);
const existingPatterns = extractLearnedPatterns(current);
if (existingPatterns.includes(pattern)) {
return { changed: false, target };
}
const next = current.includes(LEARNED_PATTERNS_MARKER)
? current.replace(
LEARNED_PATTERNS_MARKER,
`${LEARNED_PATTERNS_MARKER}\n- ${pattern}`,
)
: `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- ${pattern}\n`;
await ensureDirectory(join(SKILLS_ROOT_DIR, skillPath));
// 追加 learned pattern 前先备份旧版 SKILL.md,避免共享技能被异常写坏。
await atomicWriteFileWithHistory(target, next, {
historyDir: SKILLS_HISTORY_DIR,
rootDir: SKILLS_ROOT_DIR,
});
return { changed: true, target };
});
};
const serializeWrite = async <T>(task: () => Promise<T>) => {
const run = writeQueue.catch(() => undefined).then(task);
writeQueue = run.then(
() => undefined,
() => undefined,
);
return run;
};
const defaultLearnedSkill = (skillPath: string) => `---
name: tjwater-action-${toSkillName(skillPath)}
description: 由 skill_manager 在线追加的高置信度可复用 workflow。
version: 1.0.0
---
# learned skill
## 简介
记录由 \`skill_manager\` 在线追加的高置信度 workflow 模式。
## Learned Patterns
`;
const normalizeSkillPath = (rawSkillPath: string) => {
const normalized = posix.normalize(
rawSkillPath.trim().replace(/^\/+|\/+$/g, ""),
);
if (!normalized || normalized === "." || normalized.startsWith("..")) {
return null;
}
if (normalized === "SKILL.md" || normalized.endsWith("/SKILL.md")) {
return null;
}
if (!/^[a-z0-9._/-]+$/i.test(normalized)) {
return null;
}
return normalized;
};
const toSkillName = (skillPath: string) =>
skillPath
.split("/")
.filter(Boolean)
.join("-")
.replace(/[^a-z0-9._-]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || "generated-skill";
const extractLearnedPatterns = (content: string) => {
if (!content.includes(LEARNED_PATTERNS_MARKER)) {
return [];
}
return (content.split(LEARNED_PATTERNS_MARKER)[1] ?? "")
.split("\n")
.filter((line) => line.trim().startsWith("- "))
.map((line) => line.trim().slice(2));
};