364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
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<void> = 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<T>(task: () => Promise<T>) {
|
|
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
|
|
`;
|