161 lines
5.0 KiB
TypeScript
161 lines
5.0 KiB
TypeScript
import { tool } from "@opencode-ai/plugin";
|
|
import { join, posix } from "node:path";
|
|
|
|
import { config } from "../../src/config.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();
|
|
|
|
export default tool({
|
|
description:
|
|
"将已验证、可复用、非敏感的 workflow 或方法模式写入指定的 .opencode/skills 目录,由 opencode 自动识别和加载。",
|
|
args: {
|
|
reason: tool.schema
|
|
.string()
|
|
.describe(
|
|
"The reusable workflow or method pattern to persist for future reuse, written as one concise sentence.",
|
|
),
|
|
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.",
|
|
),
|
|
},
|
|
async execute(args, context) {
|
|
await initializePromise;
|
|
const sessionContext = await toolContextStore.read(context.sessionID);
|
|
if (!sessionContext) {
|
|
throw new Error(`session context not found for ${context.sessionID}`);
|
|
}
|
|
const skillPath = normalizeSkillPath(args.skill_path);
|
|
if (!skillPath) {
|
|
return JSON.stringify({
|
|
ok: true,
|
|
kind: "skill",
|
|
decision: "rejected",
|
|
detail:
|
|
"invalid skill_path; expected a relative path under .opencode/skills",
|
|
});
|
|
}
|
|
const pattern = sanitizePersistentLine(args.reason, 320);
|
|
if (!pattern) {
|
|
return JSON.stringify({
|
|
ok: true,
|
|
kind: "skill",
|
|
decision: "rejected",
|
|
detail: "reason rejected by persistence policy",
|
|
});
|
|
}
|
|
|
|
const result = await appendLearnedSkillPattern(skillPath, pattern);
|
|
return JSON.stringify({
|
|
ok: true,
|
|
kind: "skill",
|
|
decision: result.changed ? "accepted" : "deduped",
|
|
detail: result.changed ? "skill file updated" : "pattern already existed",
|
|
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));
|
|
};
|