import { dirname, isAbsolute, join, posix, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { config } from "../config.js"; import { atomicWriteFileWithHistory, ensureDirectory, listFiles, readTextFile, removeFileIfExists, slugify, toStableId, } from "../utils/fileStore.js"; import { sanitizePersistentScript, sanitizePersistentDocument, sanitizePersistentLine, } from "../utils/persistencePolicy.js"; const LEARNED_PATTERNS_MARKER = "## Learned Patterns"; const PROJECT_ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const resolveProjectPath = (path: string) => isAbsolute(path) ? path : resolve(PROJECT_ROOT_DIR, path); const DEFAULT_SKILLS_ROOT_DIR = resolveProjectPath(config.OPENCODE_SKILLS_ROOT_DIR); const DEFAULT_SKILLS_BACKUP_DIR = resolveProjectPath( join(config.PERSISTENCE_BACKUP_DIR, "skills"), ); export type SkillPatternRecord = { id: string; content: string; }; export class SkillStore { private writeQueue: Promise = Promise.resolve(); constructor( private readonly rootDir = DEFAULT_SKILLS_ROOT_DIR, private readonly backupDir = DEFAULT_SKILLS_BACKUP_DIR, ) {} async list(skillPath: string) { const normalizedSkillPath = normalizeSkillPath(skillPath); if (!normalizedSkillPath) { return null; } const target = this.skillFilePath(normalizedSkillPath); const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath); return { references: await this.listReferenceFiles(normalizedSkillPath), scripts: await this.listScriptFiles(normalizedSkillPath), skillPath: normalizedSkillPath, target, patterns: extractLearnedPatterns(current), }; } async appendPattern(skillPath: string, pattern: string) { const normalizedSkillPath = normalizeSkillPath(skillPath); if (!normalizedSkillPath) { return { changed: false, detail: "invalid skill_path", target: "" }; } const sanitizedPattern = sanitizePersistentLine(pattern, 320); if (!sanitizedPattern) { return { changed: false, detail: "pattern rejected by persistence policy", target: "" }; } return this.serializeWrite(async () => { const target = this.skillFilePath(normalizedSkillPath); const current = (await readTextFile(target)) ?? defaultLearnedSkill(normalizedSkillPath); const existingPatterns = extractLearnedPatterns(current); if (existingPatterns.some((entry) => entry.content === sanitizedPattern)) { return { changed: false, detail: "pattern already existed", target }; } const record: SkillPatternRecord = { content: sanitizedPattern, id: toStableId(normalizedSkillPath, sanitizedPattern.toLowerCase()), }; const next = current.includes(LEARNED_PATTERNS_MARKER) ? current.replace( LEARNED_PATTERNS_MARKER, `${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}`, ) : `${current.trimEnd()}\n\n${LEARNED_PATTERNS_MARKER}\n- [${record.id}] ${record.content}\n`; await ensureDirectory(join(this.rootDir, normalizedSkillPath)); await atomicWriteFileWithHistory(target, next, { backupDir: this.backupDir, rootDir: this.rootDir, }); return { changed: true, detail: "skill file updated", target }; }); } async removePattern(skillPath: string, targetId: string) { const normalizedSkillPath = normalizeSkillPath(skillPath); if (!normalizedSkillPath) { return { changed: false, detail: "invalid skill_path", target: "" }; } return this.serializeWrite(async () => { const target = this.skillFilePath(normalizedSkillPath); const current = await readTextFile(target); if (!current) { return { changed: false, detail: "skill file not found", target }; } const patterns = extractLearnedPatterns(current); const remaining = patterns.filter((entry) => entry.id !== targetId.trim()); if (remaining.length === patterns.length) { return { changed: false, detail: "pattern not found", target }; } const next = rewriteLearnedPatterns(current, remaining); await atomicWriteFileWithHistory(target, next, { backupDir: this.backupDir, rootDir: this.rootDir, }); return { changed: true, detail: "pattern removed", target }; }); } async writeReference(skillPath: string, filePath: string, content: string) { const normalizedSkillPath = normalizeSkillPath(skillPath); const normalizedReferencePath = normalizeReferencePath(filePath); if (!normalizedSkillPath) { return { changed: false, detail: "invalid skill_path", target: "" }; } if (!normalizedReferencePath) { return { changed: false, detail: "invalid reference file_path", target: "" }; } const sanitizedContent = sanitizePersistentDocument(content, 5000); if (!sanitizedContent) { return { changed: false, detail: "reference content rejected by persistence policy", target: "" }; } return this.serializeWrite(async () => { const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath); await ensureDirectory(dirname(target)); await atomicWriteFileWithHistory(target, `${sanitizedContent}\n`, { backupDir: this.backupDir, rootDir: this.rootDir, }); return { changed: true, detail: "reference written", target }; }); } async removeReference(skillPath: string, filePath: string) { const normalizedSkillPath = normalizeSkillPath(skillPath); const normalizedReferencePath = normalizeReferencePath(filePath); if (!normalizedSkillPath) { return { changed: false, detail: "invalid skill_path", target: "" }; } if (!normalizedReferencePath) { return { changed: false, detail: "invalid reference file_path", target: "" }; } return this.serializeWrite(async () => { const target = join(this.rootDir, normalizedSkillPath, normalizedReferencePath); const previous = await readTextFile(target); if (previous === null) { return { changed: false, detail: "reference not found", target }; } await removeFileIfExists(target); return { changed: true, detail: "reference removed", target }; }); } 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(this.rootDir, normalizedSkillPath, normalizedScriptPath); await ensureDirectory(dirname(target)); await atomicWriteFileWithHistory(target, sanitizedContent, { backupDir: this.backupDir, rootDir: this.rootDir, }); 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(this.rootDir, 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(this.rootDir, skillPath, "references"); const files = await listFiles(referenceDir); return files.map((file) => file.slice(referenceDir.length + 1)); } private async listScriptFiles(skillPath: string) { const scriptDir = join(this.rootDir, skillPath, "scripts"); const files = await listFiles(scriptDir); return files.map((file) => file.slice(scriptDir.length + 1)); } private skillFilePath(skillPath: string) { return join(this.rootDir, skillPath, "SKILL.md"); } private async serializeWrite(task: () => Promise) { const run = this.writeQueue.catch(() => undefined).then(task); this.writeQueue = run.then( () => undefined, () => undefined, ); return run; } } export 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 normalizeReferencePath = (rawFilePath: string) => { const normalized = posix.normalize(rawFilePath.trim().replace(/^\/+|\/+$/g, "")); if (!normalized || normalized.startsWith("..")) { return null; } if (!normalized.startsWith("references/")) { return null; } if (!normalized.endsWith(".md")) { return null; } const segments = normalized.split("/"); const last = segments.pop(); if (!last) { return null; } const stem = last.replace(/\.md$/i, ""); const normalizedStem = slugify(stem); 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) { return []; } return section .split("\n") .map((line) => line.trim()) .filter((line) => line.startsWith("- ")) .map((line) => line.slice(2).trim()) .map((line) => { const idMatch = line.match(/^\[([a-z0-9]{8,})\]\s+(.*)$/i); if (idMatch) { return { content: idMatch[2], id: idMatch[1], }; } return { content: line, id: toStableId("skill-pattern", line.toLowerCase()), }; }) .filter((entry) => entry.content); }; const rewriteLearnedPatterns = (content: string, patterns: SkillPatternRecord[]) => { const renderedSection = patterns.length > 0 ? `${LEARNED_PATTERNS_MARKER}\n${patterns.map((entry) => `- [${entry.id}] ${entry.content}`).join("\n")}` : `${LEARNED_PATTERNS_MARKER}\n`; if (!content.includes(LEARNED_PATTERNS_MARKER)) { return `${content.trimEnd()}\n\n${renderedSection}\n`; } const markerIndex = content.indexOf(LEARNED_PATTERNS_MARKER); const afterMarkerIndex = markerIndex + LEARNED_PATTERNS_MARKER.length; const tail = content.slice(afterMarkerIndex); const nextHeadingMatch = tail.match(/\n##\s+/); const sectionEndOffset = nextHeadingMatch?.index ?? tail.length; const head = content.slice(0, markerIndex).trimEnd(); const suffix = tail.slice(sectionEndOffset).trimStart(); return suffix ? `${head}\n\n${renderedSection}\n\n${suffix}` : `${head}\n\n${renderedSection}\n`; }; const extractLearnedPatternsSection = (content: string) => { const markerIndex = content.indexOf(LEARNED_PATTERNS_MARKER); if (markerIndex === -1) { return ""; } const tail = content.slice(markerIndex + LEARNED_PATTERNS_MARKER.length); const nextHeadingMatch = tail.match(/\n##\s+/); return tail.slice(0, nextHeadingMatch?.index ?? tail.length); }; const defaultLearnedSkill = (skillPath: string) => `--- name: tjwater-action-${skillPath .split("/") .filter(Boolean) .join("-") .replace(/[^a-z0-9._-]+/gi, "-") .replace(/^-+|-+$/g, "") .slice(0, 120) || "generated-skill"} description: 由 skill_manager 在线追加的高置信度可复用 workflow。 version: 1.0.0 --- # learned skill ## 简介 记录由 \`skill_manager\` 在线追加的高置信度 workflow 模式。 ## Learned Patterns `;