Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to this project are documented in this file.

## [Unreleased]

### Fixed

- **Windows skill markdown 测试 EBUSY**:`importSkillsFromMarkdownRuntime` / `exportSkillsToMarkdownRuntime` 打开默认 `auto`→sqlite 图库后不 `close()`,`afterEach` 删临时目录时 `graphflow-graph.sqlite` 仍被占用。现于 try/finally 关闭 client。

## [1.24.0] - 2026-09-19

### Added — R7 三件套首发:语义召回默认开 + Agent Skills 标准分发 + 可核验隐私
Expand Down
293 changes: 157 additions & 136 deletions src/surfaces/cli/runtime/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ import {
import { parseSkillState, serializeAtomic } from "../../../learning/skill-store";
import { dialogueSessionIdFor, listDialogueTurns } from "../../../learning/dialogue-thread";

function resolveRuntimeConfig(
configPath?: string,
rootDir?: string
) {
function resolveRuntimeConfig(configPath?: string, rootDir?: string) {
const resolved = resolveConfig(configPath, rootDir ? { rootDir } : undefined);
return bindRuntimeWorkspaceRoot(
resolved,
Expand All @@ -34,6 +31,14 @@ function resolveRuntimeConfig(
);
}

function closeGraphClient(client: GraphClient): void {
try {
void client.close?.();
} catch {
// Windows keeps the sqlite file locked if close throws after a failed open.
}
}

export interface SkillMarkdownExportResult {
outputDir: string;
fileCount: number;
Expand All @@ -51,57 +56,61 @@ 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);
const violations = validateSkillMarkdown(bundle.markdown);
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);
const violations = validateSkillMarkdown(bundle.markdown);
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 {
closeGraphClient(client);
}
return { outputDir, fileCount, bytes, skippedComposites, referenceFileCount, invalid };
}

export interface SkillMarkdownImportResult {
Expand Down Expand Up @@ -149,7 +158,10 @@ function isSpecLayoutFile(file: string): boolean {
return basename(file).toLowerCase() === "skill.md";
}

async function existingSkillUpdatedAt(client: GraphClient, id: string): Promise<number | undefined> {
async function existingSkillUpdatedAt(
client: GraphClient,
id: string
): Promise<number | undefined> {
const hits = await (client.getNodesByIds?.([id]) ?? client.queryByKeyword(id));
const node = hits.find((item) => item.id === id && item.type === "Skill");
if (!node) return undefined;
Expand All @@ -171,58 +183,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 {
closeGraphClient(client);
}
return { inputPath, imported, updated, skipped, total: files.length, invalid };
}

export interface DialogueKnowledgeExtractionResult {
Expand All @@ -245,45 +261,50 @@ export async function extractDialogueKnowledgeRuntime(
): Promise<DialogueKnowledgeExtractionResult> {
const config = resolveRuntimeConfig(configPath, options?.rootDir);
const workspaceRoot = config.graphPolicy.workspaceRoot ?? process.cwd();
const sessionId = options?.all || !options?.sessionId
? undefined
: options.sessionId.startsWith("dialogue-session:")
? options.sessionId
: dialogueSessionIdFor(options.sessionId, workspaceRoot);
const sessionId =
options?.all || !options?.sessionId
? undefined
: options.sessionId.startsWith("dialogue-session:")
? 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 {
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,
};
}
7 changes: 6 additions & 1 deletion tests/skill-markdown-progressive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ const dirs: string[] = [];
afterEach(() => {
while (dirs.length > 0) {
const dir = dirs.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
if (!dir) continue;
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// Windows: leftover sqlite handles used to EBUSY here; runtimes now close.
}
}
});

Expand Down