Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ All notable changes to this project are documented in this file.
- Direct `@modelcontextprotocol/sdk` (root and `packages/efficiency-agent`) moved from `^1.30.0` to `^1.32.1`. The advisory range was `<1.31.0` (OAuth client could send credentials to an authorization server chosen by the MCP server).
- `overrides.proxy-addr` is `^2.0.8`. express still declares `^2.0.7`, which resolved to the vulnerable `2.0.7` (IPv4-mapped IPv6 trust spoofing).

### Fixed — skill markdown graph clients stay open (Windows sqlite EBUSY)

- `importSkillsFromMarkdownRuntime`, `exportSkillsToMarkdownRuntime`, and `extractDialogueKnowledgeRuntime` now `close()` the graph client in `finally`. Default `auto` transport opens better-sqlite3; leaving that handle open makes a later `rmSync` of `graphflow-graph.sqlite` fail with `EBUSY` on Windows.

## [Unreleased]

### Fixed — 第 0 步性能与健壮性(迁移 Rust 前的架构债清偿,复审登记项全闭环)
Expand Down
279 changes: 151 additions & 128 deletions src/surfaces/cli/runtime/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ function resolveRuntimeConfig(
);
}

async function closeGraphClient(client: GraphClient): Promise<void> {
// sqlite holds a Windows file lock until close(). Import/export/extract
// used to return with the handle open, so test cleanup (and any later
// unlink of graphflow-graph.sqlite) failed with EBUSY on win32.
try {
await client.close?.();
} catch {
/* ignore close races */
}
}

export interface SkillMarkdownExportResult {
outputDir: string;
fileCount: number;
Expand All @@ -52,59 +63,63 @@ export async function exportSkillsToMarkdownRuntime(
): Promise<SkillMarkdownExportResult> {
const config = resolveRuntimeConfig(configPath, options?.rootDir);
const client = createGraphClient(config);
const snapshot = client.readSnapshot?.() ?? { nodes: [], edges: [] };
const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd();
const outputDir = options?.outputDir
? (isAbsolute(options.outputDir)
? options.outputDir
: join(workspaceRoot, options.outputDir))
: join(workspaceRoot, ".graphflow", "skills", "markdown");
mkdirSync(outputDir, { recursive: true });
try {
const snapshot = client.readSnapshot?.() ?? { nodes: [], edges: [] };
const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd();
const outputDir = options?.outputDir
? (isAbsolute(options.outputDir)
? options.outputDir
: join(workspaceRoot, options.outputDir))
: join(workspaceRoot, ".graphflow", "skills", "markdown");
mkdirSync(outputDir, { recursive: true });

let bytes = 0;
let fileCount = 0;
let skippedComposites = 0;
let referenceFileCount = 0;
const invalid: Array<{ file: string; violations: string[] }> = [];
const usedDirs = new Set<string>();
for (const node of snapshot.nodes) {
if (node.type !== "Skill") continue;
const state = parseSkillState(node.content);
if (!state) {
if (node.content.includes('"kind":"composite"')) skippedComposites += 1;
continue;
}
// agentskills.io layout: one directory per skill, SKILL.md inside, and
// the directory name MUST equal the spec name. Oversized guidance moves
// to references/ (progressive disclosure) so the body stays a pointer.
let dirName = skillDirectoryFor(state);
const base = toSpecName(state.name);
let suffix = 2;
while (usedDirs.has(dirName.toLowerCase())) {
dirName = `${base}-${suffix}`;
suffix += 1;
}
usedDirs.add(dirName.toLowerCase());
const bundle = skillToSkillMarkdownBundle(state);
// skills-ref gate: validate the whole bundle — dangling pointers to
// references/ files and orphan reference files both count as invalid.
const violations = validateSkillBundle(bundle);
const relPath = `${dirName}/SKILL.md`;
if (violations.length > 0) invalid.push({ file: relPath, violations });
const skillDir = join(outputDir, dirName);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), bundle.markdown, "utf8");
bytes += Buffer.byteLength(bundle.markdown);
fileCount += 1;
for (const reference of bundle.references) {
const refPath = join(skillDir, ...reference.path.split("/"));
mkdirSync(dirname(refPath), { recursive: true });
writeFileSync(refPath, reference.content, "utf8");
bytes += Buffer.byteLength(reference.content);
referenceFileCount += 1;
let bytes = 0;
let fileCount = 0;
let skippedComposites = 0;
let referenceFileCount = 0;
const invalid: Array<{ file: string; violations: string[] }> = [];
const usedDirs = new Set<string>();
for (const node of snapshot.nodes) {
if (node.type !== "Skill") continue;
const state = parseSkillState(node.content);
if (!state) {
if (node.content.includes('"kind":"composite"')) skippedComposites += 1;
continue;
}
// agentskills.io layout: one directory per skill, SKILL.md inside, and
// the directory name MUST equal the spec name. Oversized guidance moves
// to references/ (progressive disclosure) so the body stays a pointer.
let dirName = skillDirectoryFor(state);
const base = toSpecName(state.name);
let suffix = 2;
while (usedDirs.has(dirName.toLowerCase())) {
dirName = `${base}-${suffix}`;
suffix += 1;
}
usedDirs.add(dirName.toLowerCase());
const bundle = skillToSkillMarkdownBundle(state);
// skills-ref gate: validate the whole bundle — dangling pointers to
// references/ files and orphan reference files both count as invalid.
const violations = validateSkillBundle(bundle);
const relPath = `${dirName}/SKILL.md`;
if (violations.length > 0) invalid.push({ file: relPath, violations });
const skillDir = join(outputDir, dirName);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), bundle.markdown, "utf8");
bytes += Buffer.byteLength(bundle.markdown);
fileCount += 1;
for (const reference of bundle.references) {
const refPath = join(skillDir, ...reference.path.split("/"));
mkdirSync(dirname(refPath), { recursive: true });
writeFileSync(refPath, reference.content, "utf8");
bytes += Buffer.byteLength(reference.content);
referenceFileCount += 1;
}
}
return { outputDir, fileCount, bytes, skippedComposites, referenceFileCount, invalid };
} finally {
await closeGraphClient(client);
}
return { outputDir, fileCount, bytes, skippedComposites, referenceFileCount, invalid };
}

export interface SkillMarkdownImportResult {
Expand Down Expand Up @@ -174,58 +189,62 @@ export async function importSkillsFromMarkdownRuntime(

const files = collectMarkdownFiles(inputPath);
const client = createGraphClient(config);
let imported = 0;
let updated = 0;
let skipped = 0;
const invalid: Array<{ file: string; violations: string[] }> = [];
for (const file of files) {
const raw = readFileSync(file, "utf8");
// Spec gate: reject files that violate agentskills.io shape before parsing.
// Missing description is advisory (importable); bad name/shape is rejected.
const violations = validateSkillMarkdown(raw).filter(
(v) => !v.startsWith("description is required")
);
if (violations.length > 0) {
invalid.push({ file, violations });
skipped += 1;
continue;
}
const state = parseSkillMarkdown(raw);
if (!state) {
skipped += 1;
continue;
}
// agentskills.io: the parent directory name must equal the skill name.
// Only enforced for spec-layout files; flat legacy exports (name.md at
// the scan root) stay importable.
if (isSpecLayoutFile(file)) {
const parentDir = basename(dirname(file));
const specName = toSpecName(state.name);
if (parentDir.toLowerCase() !== specName.toLowerCase()) {
invalid.push({
file,
violations: [
`directory name "${parentDir}" must equal the skill name "${specName}" (agentskills.io)`,
],
});
try {
let imported = 0;
let updated = 0;
let skipped = 0;
const invalid: Array<{ file: string; violations: string[] }> = [];
for (const file of files) {
const raw = readFileSync(file, "utf8");
// Spec gate: reject files that violate agentskills.io shape before parsing.
// Missing description is advisory (importable); bad name/shape is rejected.
const violations = validateSkillMarkdown(raw).filter(
(v) => !v.startsWith("description is required")
);
if (violations.length > 0) {
invalid.push({ file, violations });
skipped += 1;
continue;
}
const state = parseSkillMarkdown(raw);
if (!state) {
skipped += 1;
continue;
}
// agentskills.io: the parent directory name must equal the skill name.
// Only enforced for spec-layout files; flat legacy exports (name.md at
// the scan root) stay importable.
if (isSpecLayoutFile(file)) {
const parentDir = basename(dirname(file));
const specName = toSpecName(state.name);
if (parentDir.toLowerCase() !== specName.toLowerCase()) {
invalid.push({
file,
violations: [
`directory name "${parentDir}" must equal the skill name "${specName}" (agentskills.io)`,
],
});
skipped += 1;
continue;
}
}
const previousUpdatedAt = await existingSkillUpdatedAt(client, state.id);
if (
previousUpdatedAt !== undefined &&
!options?.force &&
previousUpdatedAt >= state.updatedAt
) {
skipped += 1;
continue;
}
await client.upsertNodes([{ id: state.id, type: "Skill", content: serializeAtomic(state) }]);
if (previousUpdatedAt === undefined) imported += 1;
else updated += 1;
}
const previousUpdatedAt = await existingSkillUpdatedAt(client, state.id);
if (
previousUpdatedAt !== undefined &&
!options?.force &&
previousUpdatedAt >= state.updatedAt
) {
skipped += 1;
continue;
}
await client.upsertNodes([{ id: state.id, type: "Skill", content: serializeAtomic(state) }]);
if (previousUpdatedAt === undefined) imported += 1;
else updated += 1;
return { inputPath, imported, updated, skipped, total: files.length, invalid };
} finally {
await closeGraphClient(client);
}
return { inputPath, imported, updated, skipped, total: files.length, invalid };
}

export interface DialogueKnowledgeExtractionResult {
Expand Down Expand Up @@ -254,39 +273,43 @@ export async function extractDialogueKnowledgeRuntime(
? options.sessionId
: dialogueSessionIdFor(options.sessionId, workspaceRoot);
const client = createGraphClient(config);
const turns = await listDialogueTurns(client, {
...(sessionId ? { sessionId } : {}),
...(options?.limit !== undefined ? { limit: options.limit } : {}),
});
const records: KnowledgeTurnRecord[] = turns.map((turn) => ({
turnId: turn.id,
query: turn.userQuery,
reply: turn.assistantReply,
}));
const fragment = extractEngineeringKnowledgeGraphFragment({ turns: records });
try {
const turns = await listDialogueTurns(client, {
...(sessionId ? { sessionId } : {}),
...(options?.limit !== undefined ? { limit: options.limit } : {}),
});
const records: KnowledgeTurnRecord[] = turns.map((turn) => ({
turnId: turn.id,
query: turn.userQuery,
reply: turn.assistantReply,
}));
const fragment = extractEngineeringKnowledgeGraphFragment({ turns: records });

// The extractor records source turn IDs in metadata. Emit one provenance
// edge per actual dialogue-turn node so Concept/Requirement remain auditable.
const edges: GraphEdge[] = [];
for (const node of fragment.nodes) {
const metadata = node.metadata as {
sourceTurnIds?: string[];
};
for (const sourceId of metadata.sourceTurnIds ?? []) {
edges.push({ from: node.id, to: sourceId, relation: "derived_from" });
// The extractor records source turn IDs in metadata. Emit one provenance
// edge per actual dialogue-turn node so Concept/Requirement remain auditable.
const edges: GraphEdge[] = [];
for (const node of fragment.nodes) {
const metadata = node.metadata as {
sourceTurnIds?: string[];
};
for (const sourceId of metadata.sourceTurnIds ?? []) {
edges.push({ from: node.id, to: sourceId, relation: "derived_from" });
}
}
}

const apply = options?.apply ?? true;
if (apply) {
if (fragment.nodes.length > 0) await client.upsertNodes(fragment.nodes);
if (edges.length > 0) await client.upsertEdges(edges);
const apply = options?.apply ?? true;
if (apply) {
if (fragment.nodes.length > 0) await client.upsertNodes(fragment.nodes);
if (edges.length > 0) await client.upsertEdges(edges);
}
return {
scannedTurns: turns.length,
requirements: fragment.nodes.filter((node) => node.type === "Requirement").length,
concepts: fragment.nodes.filter((node) => node.type === "Concept").length,
edges: edges.length,
applied: apply,
};
} finally {
await closeGraphClient(client);
}
return {
scannedTurns: turns.length,
requirements: fragment.nodes.filter((node) => node.type === "Requirement").length,
concepts: fragment.nodes.filter((node) => node.type === "Concept").length,
edges: edges.length,
applied: apply,
};
}
Loading
Loading