diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 7736d19..b8b45e1 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -27,5 +27,5 @@ temperature: 0.2 15. 在以下任一情况出现时,主动进行一次轻量复盘:连续多轮对话后、完成复杂多工具任务后、用户明确纠正你后、发现了稳定可复用 workflow 后。复盘的目标是判断是否需要沉淀 memory 或 skill,而不是向用户重复总结。 16. 长期知识严格分流:`memory_manager` 仅保存用户长期偏好与稳定 workspace 事实;`skill_manager` 仅保存可复用方法;一次性案例、会话过程与临时结论应优先保留在 session history,需要时使用 `session_search` 检索,不要误写入 memory 或 skill。 17. 写入 `memory_manager` 时,将内容写成简短陈述事实,不要写成命令句、提醒句或流程步骤。 -18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns` 或 `references/`,只有现有位置明显不合适时才新建新的 skill 目录。 +18. 更新 skill 时,优先补充现有 skill 的 `Learned Patterns`、`references/` 或 `scripts/`;可复用脚本仅允许写到当前 skill 自己的 `scripts/*.py`,不要放到 `data/` 或其他 skill 目录。 19. 当用户问题依赖过去会话中的案例、约束、决策或相似问题时,优先调用 `session_search`,避免让用户重复描述,也避免把历史案例误当成长期 memory。 diff --git a/.opencode/skills/SKILL.md b/.opencode/skills/SKILL.md index 8cebc95..57f6b6f 100644 --- a/.opencode/skills/SKILL.md +++ b/.opencode/skills/SKILL.md @@ -26,6 +26,7 @@ version: 1.2.0 - 如果 workflow 已覆盖主要步骤,则不要先从大量 API skills 开始拼装流程;仅在 workflow 缺失、步骤不全或需要额外原子能力时,才继续下钻。 - 优先更新已有 skill,而不是为一次性问题新增新的 skill 目录。 - learned pattern 应写成可复用的方法或坑点,不应写成某次会话的流水账。 +- 某个 workflow 反复验证过的私有辅助脚本,应放在该 skill 目录下的 `scripts/*.py`,并随 skill 一起维护;不要写入 `data/`。 ## 参考 diff --git a/.opencode/skills/examples.md b/.opencode/skills/examples.md index beb01bf..6cf022d 100644 --- a/.opencode/skills/examples.md +++ b/.opencode/skills/examples.md @@ -173,3 +173,19 @@ opencode agent 调用工具 `skill_manager`: "pattern": "当瓶颈分析依赖大体量属性数据和模拟结果时,先用 dynamic_http_call 获取 preview,再用 fetch_result_ref 回读完整数据后再做合并与排序。" } ``` + +## 示例 9:给单个 workflow skill 写入可复用脚本 + +当某个 workflow 的本地 Python 处理逻辑已经稳定、未来同类任务会重复使用时,可写入该 skill 自己的 `scripts/*.py`: + +```json +{ + "action": "write_script", + "reason": "本轮已验证瓶颈分析中的合并与排序脚本,后续同类 workflow 可直接复用。", + "skill_path": "workflow/bottleneck-analysis", + "file_path": "scripts/merge_and_rank.py", + "content": "import json\n\n\ndef rank_links(rows):\n return sorted(rows, key=lambda row: row['composite_score'], reverse=True)\n" +} +``` + +脚本应只归属当前 `skill_path`,不要写到 `data/` 或其他 skill 目录。 diff --git a/.opencode/skills/runbook.md b/.opencode/skills/runbook.md index 99874c3..486b593 100644 --- a/.opencode/skills/runbook.md +++ b/.opencode/skills/runbook.md @@ -71,12 +71,14 @@ SSE 事件: - 所有学习类工具都必须带 `reason` - `memory_manager` 支持:`add / list / replace / remove` -- `skill_manager` 支持:`list / append_pattern / remove_pattern / write_reference / remove_reference` +- `skill_manager` 支持:`list / append_pattern / remove_pattern / write_reference / remove_reference / write_script / remove_script` - `session_search` 只搜索当前用户 + 当前项目作用域,不接受跨项目检索 - `skill_manager` 的结构化写入优先落到: 1. `## Learned Patterns` 2. `references/*.md` + 3. `scripts/*.py` 不应直接重写 skill frontmatter 或任意正文段落 +- `scripts/*.py` 仅表示当前 `skill_path` 私有的可复用脚本资产;不要把运行时临时脚本写进 `data/` ## 4) 用户上下文注入(后端执行阶段) diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index a2d45d0..da199b7 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -9,7 +9,7 @@ const skillStore = new SkillStore(); export default tool({ description: - "维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference。", + "维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。", args: { action: tool.schema .enum([ @@ -18,6 +18,8 @@ export default tool({ "remove_pattern", "write_reference", "remove_reference", + "write_script", + "remove_script", ]) .describe("Skill maintenance operation."), reason: tool.schema @@ -41,11 +43,11 @@ export default tool({ file_path: tool.schema .string() .optional() - .describe("Reference file path under references/, such as references/bottleneck-notes.md."), + .describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."), content: tool.schema .string() .optional() - .describe("Reference markdown body used by write_reference."), + .describe("Asset content used by write_reference or write_script."), }, async execute(args, context) { await initializePromise; @@ -92,7 +94,15 @@ export default tool({ args.file_path ?? "", args.content ?? "", ) - : await skillStore.removeReference(args.skill_path, args.file_path ?? ""); + : args.action === "remove_reference" + ? await skillStore.removeReference(args.skill_path, args.file_path ?? "") + : args.action === "write_script" + ? await skillStore.writeScript( + args.skill_path, + args.file_path ?? "", + args.content ?? "", + ) + : await skillStore.removeScript(args.skill_path, args.file_path ?? ""); return JSON.stringify({ ok: true, diff --git a/src/skills/store.ts b/src/skills/store.ts index b3d9870..3299d4f 100644 --- a/src/skills/store.ts +++ b/src/skills/store.ts @@ -11,6 +11,7 @@ import { toStableId, } from "../utils/fileStore.js"; import { + sanitizePersistentScript, sanitizePersistentDocument, sanitizePersistentLine, } from "../utils/persistencePolicy.js"; @@ -36,6 +37,7 @@ export class SkillStore { const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath); return { references: await this.listReferenceFiles(normalizedSkillPath), + scripts: await this.listScriptFiles(normalizedSkillPath), skillPath: normalizedSkillPath, target, patterns: extractLearnedPatterns(current), @@ -146,12 +148,62 @@ export class SkillStore { }); } + async writeScript(skillPath: string, filePath: string, content: string) { + const normalizedSkillPath = normalizeSkillPath(skillPath); + const normalizedScriptPath = normalizeScriptPath(filePath); + if (!normalizedSkillPath) { + return { changed: false, detail: "invalid skill_path", target: "" }; + } + if (!normalizedScriptPath) { + return { changed: false, detail: "invalid script file_path", target: "" }; + } + const sanitizedContent = sanitizePersistentScript(content, 20000); + if (!sanitizedContent) { + return { changed: false, detail: "script content rejected by persistence policy", target: "" }; + } + return this.serializeWrite(async () => { + const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath); + await ensureDirectory(dirname(target)); + await atomicWriteFileWithHistory(target, sanitizedContent, { + historyDir: SKILLS_HISTORY_DIR, + rootDir: SKILLS_ROOT_DIR, + }); + return { changed: true, detail: "script written", target }; + }); + } + + async removeScript(skillPath: string, filePath: string) { + const normalizedSkillPath = normalizeSkillPath(skillPath); + const normalizedScriptPath = normalizeScriptPath(filePath); + if (!normalizedSkillPath) { + return { changed: false, detail: "invalid skill_path", target: "" }; + } + if (!normalizedScriptPath) { + return { changed: false, detail: "invalid script file_path", target: "" }; + } + return this.serializeWrite(async () => { + const target = join(SKILLS_ROOT_DIR, normalizedSkillPath, normalizedScriptPath); + const previous = await readTextFile(target); + if (previous === null) { + return { changed: false, detail: "script not found", target }; + } + await removeFileIfExists(target); + return { changed: true, detail: "script removed", target }; + }); + } + private async listReferenceFiles(skillPath: string) { const referenceDir = join(SKILLS_ROOT_DIR, skillPath, "references"); const files = await listFiles(referenceDir); return files.map((file) => file.slice(referenceDir.length + 1)); } + private async listScriptFiles(skillPath: string) { + const scriptDir = join(SKILLS_ROOT_DIR, skillPath, "scripts"); + const files = await listFiles(scriptDir); + return files.map((file) => file.slice(scriptDir.length + 1)); + } + private skillFilePath(skillPath: string) { return join(SKILLS_ROOT_DIR, skillPath, "SKILL.md"); } @@ -201,6 +253,27 @@ const normalizeReferencePath = (rawFilePath: string) => { return [...segments, `${normalizedStem}.md`].join("/"); }; +const normalizeScriptPath = (rawFilePath: string) => { + const normalized = posix.normalize(rawFilePath.trim().replace(/^\/+|\/+$/g, "")); + if (!normalized || normalized.startsWith("..")) { + return null; + } + if (!normalized.startsWith("scripts/")) { + return null; + } + if (!normalized.endsWith(".py")) { + return null; + } + const segments = normalized.split("/"); + const last = segments.pop(); + if (!last) { + return null; + } + const stem = last.replace(/\.py$/i, ""); + const normalizedStem = slugify(stem); + return [...segments, `${normalizedStem}.py`].join("/"); +}; + export const extractLearnedPatterns = (content: string): SkillPatternRecord[] => { const section = extractLearnedPatternsSection(content); if (!section) { diff --git a/src/utils/persistencePolicy.ts b/src/utils/persistencePolicy.ts index 3548fea..b7434dc 100644 --- a/src/utils/persistencePolicy.ts +++ b/src/utils/persistencePolicy.ts @@ -14,12 +14,15 @@ const FORBIDDEN_PERSISTENCE_PATTERNS = [ /eyJ[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9._-]{8,}\.[a-zA-Z0-9._-]{8,}/, ]; +export const containsForbiddenPersistentContent = (content: string) => + FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(content)); + export const sanitizePersistentLine = (content: string, maxLength: number) => { const normalized = content.replace(/\s+/g, " ").trim(); if (!normalized) { return ""; } - if (FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) { + if (containsForbiddenPersistentContent(normalized)) { return ""; } if (normalized.length > maxLength) { @@ -39,7 +42,7 @@ export const sanitizePersistentDocument = (content: string, maxLength: number) = if (!normalized) { return ""; } - if (FORBIDDEN_PERSISTENCE_PATTERNS.some((pattern) => pattern.test(normalized))) { + if (containsForbiddenPersistentContent(normalized)) { return ""; } if (normalized.length > maxLength) { @@ -47,3 +50,17 @@ export const sanitizePersistentDocument = (content: string, maxLength: number) = } return normalized; }; + +export const sanitizePersistentScript = (content: string, maxLength: number) => { + const normalized = content.replace(/\r\n/g, "\n").replace(/\t/g, " ").trim(); + if (!normalized) { + return ""; + } + if (containsForbiddenPersistentContent(normalized)) { + return ""; + } + if (normalized.length > maxLength) { + return ""; + } + return `${normalized}\n`; +};