Files
TJWaterAgent/src/skills/store.ts
T

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
`;