Files
TJWaterAgent/.opencode/tools/skill_manager.ts
T

182 lines
5.9 KiB
TypeScript

import { tool } from "@opencode-ai/plugin";
import { join, posix } from "node:path";
import { ResultReferenceStore } from "../../src/results/store.js";
import { ToolSessionContextStore } from "../../src/session/toolContextStore.js";
import {
atomicWriteFile,
ensureDirectory,
readTextFile,
} from "../../src/utils/fileStore.js";
import { sanitizePersistentLine } from "../../src/utils/persistencePolicy.js";
const resultStore = new ResultReferenceStore();
const toolContextStore = new ToolSessionContextStore();
const initializePromise = Promise.all([
resultStore.initialize(),
toolContextStore.initialize(),
]);
const SKILLS_ROOT_DIR = ".opencode/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("Why this workflow or method should be learned 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()
.describe("A reusable workflow pattern written as one concise bullet-like sentence."),
signal_type: tool.schema
.string()
.describe("Signal type, e.g. validated_workflow, successful_complex_convergence, analysis_method, tool_recovery_pattern."),
confidence: tool.schema
.number()
.describe("Confidence between 0 and 1. Only very high-confidence patterns are stored as learned skills."),
result_refs: tool.schema
.array(tool.schema.string())
.optional()
.describe("Optional authorized result_ref list used only for evidence validation before persisting the skill."),
},
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.pattern, 320);
if (!pattern) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "pattern rejected by persistence policy",
});
}
if (args.confidence < 0.85) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail: "only very high-confidence patterns can be stored as skills",
});
}
if (args.result_refs?.length) {
await Promise.all(
args.result_refs.map(async (resultRef) => {
const record = await resultStore.peekAuthorized(resultRef, {
actorKey: sessionContext.actorKey,
projectId: sessionContext.projectId,
});
if (!record) {
throw new Error(`unauthorized or missing result_ref: ${resultRef}`);
}
}),
);
}
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));
await atomicWriteFile(target, next);
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));
};