@zosmaai/pi-llm-wiki 0.10.7 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.de.md +35 -4
  3. package/README.es.md +260 -170
  4. package/README.fr.md +35 -4
  5. package/README.hi.md +35 -4
  6. package/README.ja.md +35 -4
  7. package/README.ko.md +35 -4
  8. package/README.md +38 -3
  9. package/README.pt.md +35 -4
  10. package/README.ru.md +35 -4
  11. package/README.zh.md +260 -170
  12. package/assets/demo.gif +0 -0
  13. package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
  14. package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
  15. package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
  16. package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
  17. package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
  18. package/dist/extensions/llm-wiki/lib/inject.js +65 -0
  19. package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
  20. package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
  21. package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
  22. package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
  24. package/dist/extensions/llm-wiki/lib/observation.js +283 -0
  25. package/dist/extensions/llm-wiki/lib/recall.js +875 -0
  26. package/dist/extensions/llm-wiki/lib/retro.js +158 -0
  27. package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
  28. package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
  29. package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
  30. package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
  31. package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
  33. package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
  34. package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
  35. package/dist/extensions/llm-wiki/lib/utils.js +347 -0
  36. package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
  37. package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
  38. package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
  39. package/dist/mcp/exec.js +121 -0
  40. package/dist/mcp/index.js +229 -0
  41. package/dist/mcp/operations.js +130 -0
  42. package/dist/package.json +1 -0
  43. package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  44. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  45. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  46. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
  47. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
  48. package/extensions/llm-wiki/index.ts +22 -36
  49. package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
  50. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  51. package/extensions/llm-wiki/lib/guardrails.ts +174 -29
  52. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  53. package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
  54. package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
  55. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  56. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  57. package/extensions/llm-wiki/lib/metadata.ts +531 -116
  58. package/extensions/llm-wiki/lib/observation.ts +37 -43
  59. package/extensions/llm-wiki/lib/recall.ts +61 -33
  60. package/extensions/llm-wiki/lib/retro.ts +65 -41
  61. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  62. package/extensions/llm-wiki/lib/source-packet.ts +44 -31
  63. package/extensions/llm-wiki/lib/tools.ts +406 -348
  64. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  65. package/extensions/llm-wiki/lib/utils.ts +121 -130
  66. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  67. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  68. package/mcp/exec.ts +122 -0
  69. package/mcp/index.ts +60 -250
  70. package/mcp/operations.ts +176 -0
  71. package/package.json +8 -2
  72. package/scripts/migrate-llm-wiki.js +801 -0
  73. package/skills/llm-wiki/SKILL.md +8 -6
@@ -0,0 +1,84 @@
1
+ import { existsSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { KnowledgeDiagnostic } from "./knowledge-document.js";
4
+ import { type ProjectionResult, appendEvent, rebuildMetadata } from "./metadata.js";
5
+ import { type VaultPaths, ensureVaultStructure, fmtDate, writeJson } from "./utils.js";
6
+ import { inspectWritableVault, readVaultConfig } from "./vault-format.js";
7
+
8
+ export const WIKI_SCHEMA = [
9
+ "# LLM Wiki Schema",
10
+ "",
11
+ "## Ownership Rules",
12
+ "",
13
+ "| Path | Owner | Rule |",
14
+ "|------|-------|------|",
15
+ "| raw/** | extension | immutable after capture |",
16
+ "| wiki/** | model + user | editable knowledge pages |",
17
+ "| meta/* | extension | auto-generated |",
18
+ "| . | human + explicit request | operating rules |",
19
+ "",
20
+ "## Source Packet Format",
21
+ "",
22
+ "```",
23
+ "raw/sources/SRC-YYYY-MM-DD-NNN/",
24
+ " manifest.json",
25
+ " original/",
26
+ " extracted.md",
27
+ " attachments/",
28
+ "```",
29
+ "",
30
+ "## Page Types",
31
+ "",
32
+ "- **source** — what this specific source says",
33
+ "- **entity** — people, orgs, tools, products",
34
+ "- **concept** — ideas, patterns, frameworks",
35
+ "- **synthesis** — cross-source theses and tensions",
36
+ "- **analysis** — durable filed answers from queries",
37
+ "- **requirement** — atomic requirements with status, priority, and traceability",
38
+ "",
39
+ "## Linking Style",
40
+ "",
41
+ "- New internal links: [label](/folder/page.md)",
42
+ "- Legacy readable links: [[folder/page]]",
43
+ "- Source citation: [source](/sources/SRC-YYYY-MM-DD-NNN.md)",
44
+ "",
45
+ ].join("\n");
46
+
47
+ export interface BootstrapInput {
48
+ topic: string;
49
+ mode: string;
50
+ }
51
+
52
+ export type BootstrapResult =
53
+ | { ok: true; created: boolean; projection: ProjectionResult }
54
+ | { ok: false; created: false; diagnostics: KnowledgeDiagnostic[] };
55
+
56
+ export function bootstrapVault(paths: VaultPaths, input: BootstrapInput): BootstrapResult {
57
+ const configPath = join(paths.dotWiki, "config.json");
58
+ const created = !existsSync(paths.dotWiki);
59
+ let existing: Record<string, unknown> = {};
60
+
61
+ if (!created) {
62
+ const writable = inspectWritableVault(paths);
63
+ if (!writable.ok) return { ok: false, created: false, diagnostics: writable.diagnostics };
64
+ const config = readVaultConfig(paths);
65
+ if (!config.ok) return { ok: false, created: false, diagnostics: [config.diagnostic] };
66
+ existing = config.config;
67
+ }
68
+
69
+ const config: Record<string, unknown> = {
70
+ ...existing,
71
+ name: input.topic,
72
+ mode: input.mode,
73
+ topic: input.topic,
74
+ created: existing.created ?? fmtDate(),
75
+ version: existing.version ?? "1.0",
76
+ ...(created ? { knowledge_format: "okf-0.2" } : {}),
77
+ };
78
+
79
+ ensureVaultStructure(paths);
80
+ writeJson(configPath, config);
81
+ writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), WIKI_SCHEMA, "utf8");
82
+ appendEvent(paths, { kind: "bootstrap", topic: input.topic, mode: input.mode });
83
+ return { ok: true, created, projection: rebuildMetadata(paths) };
84
+ }
@@ -3,10 +3,12 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { request as httpRequest } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
5
  import { join } from "node:path";
6
+ import { parseKnowledgeDocument } from "./knowledge-document.js";
6
7
  import type { Registry } from "./metadata.js";
7
8
  import type { LaunchCtx, Runtime } from "./runtime.js";
8
9
  import type { TaskConfig } from "./task-config.js";
9
- import { type VaultPaths, parseFrontmatter, readJson, writeJson } from "./utils.js";
10
+ import { type VaultPaths, readJson, writeJson } from "./utils.js";
11
+ import { assertWritableVault } from "./vault-format.js";
10
12
 
11
13
  /**
12
14
  * Background semantic embeddings, computed at write time (issue #66, epic #63).
@@ -166,6 +168,7 @@ export function readEmbeddingStore(paths: VaultPaths): EmbeddingStore {
166
168
  }
167
169
 
168
170
  export function writeEmbeddingStore(paths: VaultPaths, store: EmbeddingStore): void {
171
+ assertWritableVault(paths);
169
172
  writeJson(embeddingStorePath(paths), store);
170
173
  }
171
174
 
@@ -189,8 +192,9 @@ function readPageText(paths: VaultPaths, id: string): PageText | undefined {
189
192
  const pagePath = join(paths.wiki, `${id}.md`);
190
193
  if (!existsSync(pagePath)) return undefined;
191
194
  const raw = readFileSync(pagePath, "utf-8");
192
- const { frontmatter, body } = parseFrontmatter(raw);
193
- const text = buildEmbeddingText(id, frontmatter, body);
195
+ const result = parseKnowledgeDocument(raw, `${id}.md`);
196
+ if (!result.ok) return undefined;
197
+ const text = buildEmbeddingText(id, result.document.frontmatter, result.document.body);
194
198
  return { id, text, hash: contentHash(text) };
195
199
  }
196
200
 
@@ -205,6 +209,7 @@ export async function embedPages(
205
209
  embedder: Embedder,
206
210
  opts: { force?: boolean } = {},
207
211
  ): Promise<EmbedStats> {
212
+ assertWritableVault(paths);
208
213
  const store = readEmbeddingStore(paths);
209
214
  const targets: PageText[] = [];
210
215
  let skipped = 0;
@@ -251,6 +256,7 @@ export async function reindexEmbeddings(
251
256
  embedder: Embedder,
252
257
  opts: { force?: boolean } = {},
253
258
  ): Promise<ReindexStats> {
259
+ assertWritableVault(paths);
254
260
  const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
255
261
  version: "1.0",
256
262
  last_updated: "",
@@ -1,10 +1,11 @@
1
- import { resolve, sep } from "node:path";
2
1
  import { isToolCallEventType } from "@mariozechner/pi-coding-agent";
3
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
3
  import { scheduleReindex } from "./indexing.js";
5
4
  import { rebuildMetadataLight } from "./metadata.js";
6
5
  import type { Runtime } from "./runtime.js";
7
- import { isProtectedPath, resolveVaultPaths } from "./utils.js";
6
+ import { isPathWithin, isProtectedPath, resolveVaultPaths } from "./utils.js";
7
+ import type { VaultPaths } from "./utils.js";
8
+ import { inspectVaultFormat, isGeneratedOkfPath } from "./vault-format.js";
8
9
 
9
10
  /**
10
11
  * Guardrails and auto-rebuild hooks for the LLM Wiki extension.
@@ -12,64 +13,208 @@ import { isProtectedPath, resolveVaultPaths } from "./utils.js";
12
13
 
13
14
  let pendingRebuild = false;
14
15
 
15
- const PATCH_HEADER = /^\[([^#\r\n]+)#[0-9A-F]{4}\]$/gm;
16
+ const APPLY_PATCH_PATH_NOISE =
17
+ /^\*{0,3}\s*(?:(?:update|add|delete|move)[^A-Za-z0-9]*(?:file|to)?[^A-Za-z0-9]*:)?\s*\*{0,3}\s*/i;
18
+ const PATCH_INPUT_KEYS: Record<string, true> = { input: true, _input: true, patch: true };
19
+ const DESTINATION_KEYS: Record<string, true> = {
20
+ rename: true,
21
+ move: true,
22
+ dest: true,
23
+ destination: true,
24
+ newPath: true,
25
+ };
16
26
 
17
- function collectMutationPaths(input: unknown, seen: WeakSet<object>): string[] {
27
+ interface MutationScan {
28
+ paths: string[];
29
+ complete: boolean;
30
+ }
31
+
32
+ function normalizeMutationPath(target: string): string | undefined {
33
+ const trimmed = target.trim();
34
+ if (!trimmed) return undefined;
35
+ const first = trimmed[0];
36
+ const last = trimmed[trimmed.length - 1];
37
+ const quoted = first === '"' || first === "'";
38
+ if (quoted !== (last === '"' || last === "'") || (quoted && first !== last)) {
39
+ return undefined;
40
+ }
41
+ const unquoted = quoted ? trimmed.slice(1, -1) : trimmed;
42
+ return unquoted.replace(APPLY_PATCH_PATH_NOISE, "") || undefined;
43
+ }
44
+
45
+ function parsePatchHeader(line: string): string | undefined {
46
+ const trimmed = line.replace(/\r$/, "").trimEnd();
47
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined;
48
+
49
+ const body = trimmed.slice(1, -1).trim();
50
+ const tag = /#[0-9A-Fa-f]{4}\s*$/.exec(body);
51
+ const rawTarget = tag ? body.slice(0, tag.index) : body.replace(/\s+$/, "");
52
+ if (!rawTarget || rawTarget.includes("#")) return undefined;
53
+
54
+ return normalizeMutationPath(rawTarget);
55
+ }
56
+
57
+ function parseMoveDestination(line: string): string | undefined {
58
+ const rawDestination = line.trim().slice(2).trim();
59
+ if (!rawDestination) return undefined;
60
+ const quote = rawDestination[0];
61
+ if (quote !== '"' && quote !== "'") return normalizeMutationPath(rawDestination);
62
+
63
+ let cursor = 1;
64
+ while (cursor < rawDestination.length) {
65
+ if (rawDestination[cursor] === "\\" && cursor + 1 < rawDestination.length) {
66
+ cursor += 2;
67
+ continue;
68
+ }
69
+ if (rawDestination[cursor] === quote) {
70
+ return cursor === rawDestination.length - 1
71
+ ? normalizeMutationPath(rawDestination)
72
+ : undefined;
73
+ }
74
+ cursor++;
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ function scanPatchString(input: string): MutationScan {
80
+ const paths: string[] = [];
81
+ let sawHeader = false;
82
+ let sectionHasMove = false;
83
+ let complete = true;
84
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
85
+
86
+ for (const line of stripped.split("\n")) {
87
+ const trimmed = line.replace(/\r$/, "").trim();
88
+ if (trimmed.startsWith("[")) {
89
+ sawHeader = true;
90
+ sectionHasMove = false;
91
+ const path = parsePatchHeader(line);
92
+ if (path) paths.push(path);
93
+ else complete = false;
94
+ continue;
95
+ }
96
+ if (!/^MV(?:\s|$)/.test(trimmed)) continue;
97
+ const destination = parseMoveDestination(trimmed);
98
+ if (!sawHeader || sectionHasMove || !destination) complete = false;
99
+ else {
100
+ paths.push(destination);
101
+ sectionHasMove = true;
102
+ }
103
+ }
104
+
105
+ return { paths, complete: sawHeader && complete };
106
+ }
107
+
108
+ function mergeMutationScans(target: MutationScan, source: MutationScan): void {
109
+ target.paths.push(...source.paths);
110
+ target.complete &&= source.complete;
111
+ }
112
+
113
+ function addMutationPath(scan: MutationScan, target: string): void {
114
+ const path = normalizeMutationPath(target);
115
+ if (path) scan.paths.push(path);
116
+ else scan.complete = false;
117
+ }
118
+
119
+ function collectMutationPaths(
120
+ input: unknown,
121
+ seen: WeakSet<object>,
122
+ stringsArePatches = false,
123
+ ): MutationScan {
18
124
  if (typeof input === "string") {
19
- return Array.from(input.matchAll(PATCH_HEADER), ([, target]) => target);
125
+ return stringsArePatches ? scanPatchString(input) : { paths: [], complete: true };
126
+ }
127
+ if (!input || typeof input !== "object" || seen.has(input)) {
128
+ return { paths: [], complete: true };
20
129
  }
21
- if (!input || typeof input !== "object" || seen.has(input)) return [];
22
130
 
23
131
  seen.add(input);
24
- if (Array.isArray(input)) return input.flatMap((value) => collectMutationPaths(value, seen));
132
+ const scan: MutationScan = { paths: [], complete: true };
133
+ if (Array.isArray(input)) {
134
+ for (const value of input) {
135
+ mergeMutationScans(scan, collectMutationPaths(value, seen, stringsArePatches));
136
+ }
137
+ return scan;
138
+ }
25
139
 
26
- const { path, ...nested } = input as Record<string, unknown>;
27
- if (typeof path === "string" && path.length > 0) return [path];
140
+ const record = input as Record<string, unknown>;
141
+ if (typeof record.path === "string" && record.path.length > 0) {
142
+ addMutationPath(scan, record.path);
143
+ }
144
+ const eventPaths = Array.isArray(record.paths) ? record.paths : [record.paths];
145
+ for (const path of eventPaths) {
146
+ if (typeof path === "string" && path.length > 0) addMutationPath(scan, path);
147
+ }
148
+
149
+ for (const [key, value] of Object.entries(record)) {
150
+ if (key === "path" || key === "paths") continue;
151
+ if (DESTINATION_KEYS[key] === true) {
152
+ if (typeof value === "string" && value.length > 0) addMutationPath(scan, value);
153
+ else scan.complete = false;
154
+ continue;
155
+ }
156
+ const childStringsArePatches = stringsArePatches || PATCH_INPUT_KEYS[key] === true;
157
+ if (typeof value !== "string" && (!value || typeof value !== "object")) continue;
158
+ mergeMutationScans(scan, collectMutationPaths(value, seen, childStringsArePatches));
159
+ }
160
+ return scan;
161
+ }
28
162
 
29
- return Object.values(nested).flatMap((value) => collectMutationPaths(value, seen));
163
+ function inspectMutationPaths(input: unknown): MutationScan {
164
+ const stringsArePatches = typeof input === "string" || Array.isArray(input);
165
+ const scan = collectMutationPaths(input, new WeakSet(), stringsArePatches);
166
+ return { paths: [...new Set(scan.paths)], complete: scan.complete };
30
167
  }
31
168
 
32
169
  /** Return every file path targeted by a write or patch-shaped edit input. */
33
170
  export function extractMutationPaths(input: unknown): string[] {
34
- return [...new Set(collectMutationPaths(input, new WeakSet()))];
171
+ return inspectMutationPaths(input).paths;
35
172
  }
36
173
 
37
174
  /** True when a write or patch-shaped edit targets a page in the wiki directory. */
38
175
  export function hasWikiMutation(input: unknown, wikiPath: string): boolean {
39
- const resolvedWikiPath = resolve(wikiPath);
40
- return extractMutationPaths(input).some((path) => {
41
- const resolvedPath = resolve(path);
42
- return (
43
- resolvedPath === resolvedWikiPath || resolvedPath.startsWith(`${resolvedWikiPath}${sep}`)
44
- );
45
- });
176
+ return extractMutationPaths(input).some((path) => isPathWithin(wikiPath, path));
177
+ }
178
+
179
+ export function mutationBlockReason(path: string, paths: VaultPaths): string | undefined {
180
+ const protectedPath = isProtectedPath(path, paths);
181
+ if (protectedPath.protected) return protectedPath.reason;
182
+
183
+ if (isPathWithin(paths.dotWiki, path)) {
184
+ const state = inspectVaultFormat(paths);
185
+ if (state.blocking) {
186
+ return `Wiki vault configuration is invalid: ${state.diagnostics[0].message}`;
187
+ }
188
+ }
189
+
190
+ if (isGeneratedOkfPath(path, paths)) {
191
+ return "Generated OKF indexes and log are read-only. Use wiki_rebuild_meta or the page-producing tool that owns the source mutation.";
192
+ }
193
+ return undefined;
46
194
  }
47
195
 
48
196
  /** Install guardrails on the extension API. */
49
197
  export function installGuardrails(pi: ExtensionAPI, runtime?: Runtime): void {
50
- // Block direct edits to raw/ and meta/
198
+ // Block direct edits to raw/ and meta/, plus OKF generated projections
51
199
  pi.on("tool_call", async (event) => {
52
200
  if (isToolCallEventType("write", event)) {
53
201
  const path = event.input.path as string;
54
202
  const paths = resolveVaultPaths(process.cwd());
55
- const check = isProtectedPath(path, paths);
56
- if (check.protected) {
57
- return { block: true, reason: check.reason };
58
- }
203
+ const reason = mutationBlockReason(path, paths);
204
+ if (reason) return { block: true, reason };
59
205
  }
60
206
 
61
207
  if (isToolCallEventType("edit", event)) {
62
- const targetPaths = extractMutationPaths(event.input);
63
- if (targetPaths.length === 0) {
208
+ const mutation = inspectMutationPaths(event.input);
209
+ const targetPaths = mutation.paths;
210
+ if (!mutation.complete || targetPaths.length === 0) {
64
211
  return { block: true, reason: "Cannot determine the files targeted by this edit." };
65
212
  }
66
213
 
67
214
  const paths = resolveVaultPaths(process.cwd());
68
215
  for (const path of targetPaths) {
69
- const check = isProtectedPath(path, paths);
70
- if (check.protected) {
71
- return { block: true, reason: check.reason };
72
- }
216
+ const reason = mutationBlockReason(path, paths);
217
+ if (reason) return { block: true, reason };
73
218
  }
74
219
  }
75
220
  });
@@ -64,7 +64,8 @@ export function scheduleReindex(
64
64
  // that land during embedding are not lost.
65
65
  while (dirty.has(root)) {
66
66
  dirty.delete(root);
67
- rebuildMetadataLight(paths);
67
+ const projection = rebuildMetadataLight(paths);
68
+ if (!projection.ok) continue;
68
69
 
69
70
  // Refresh embeddings only after metadata is consistent. Stale-aware and
70
71
  // a no-op unless an embedder is configured.
@@ -4,9 +4,19 @@ import type { AgentTool } from "@mariozechner/pi-agent-core";
4
4
  import type { Api, Model } from "@mariozechner/pi-ai";
5
5
  import { Type } from "typebox";
6
6
  import type { Static } from "typebox";
7
+ import {
8
+ type KnowledgeDiagnostic,
9
+ type KnowledgeDocument,
10
+ createKnowledgeDocument,
11
+ patchKnowledgeDocument,
12
+ readKnowledgeDocumentFile,
13
+ serializeKnowledgeDocument,
14
+ writeKnowledgeDocumentFile,
15
+ } from "./knowledge-document.js";
7
16
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
8
17
  import { runSubAgent } from "./subagent.js";
9
18
  import { type VaultPaths, fmtDate, slugify } from "./utils.js";
19
+ import { VaultWriteError, assertWritableVault } from "./vault-format.js";
10
20
 
11
21
  /**
12
22
  * Background ingest synthesis (issue #65, part of epic #63).
@@ -76,39 +86,51 @@ export interface CommitResult {
76
86
  contradictions: number;
77
87
  }
78
88
 
89
+ export type CommitSynthesisOutcome =
90
+ | ({ ok: true } & CommitResult)
91
+ | { ok: false; sourceId: string; diagnostics: KnowledgeDiagnostic[] };
92
+
79
93
  // ── deterministic persistence (no LLM) ────────────────────
80
94
 
81
- function buildEntityPage(
82
- title: string,
83
- description: string,
84
- date: string,
85
- sourceId: string,
86
- ): string {
95
+ function buildEntityPageBody(title: string, description: string, sourceId: string): string {
87
96
  const desc = description.trim() || "One-line description.";
88
- return `---\ntype: entity\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${desc}\n\n## Overview\n\n[Key facts]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
97
+ return `# ${title}
98
+
99
+ ${desc}
100
+
101
+ ## Overview
102
+
103
+ [Key facts]
104
+
105
+ ## Links
106
+
107
+ - [${sourceId}](/sources/${sourceId}.md)`;
89
108
  }
90
109
 
91
- function buildConceptPage(
92
- title: string,
93
- definition: string,
94
- date: string,
95
- sourceId: string,
96
- ): string {
110
+ function buildConceptPageBody(title: string, definition: string, sourceId: string): string {
97
111
  const def = definition.trim() || "One-line definition.";
98
- return `---\ntype: concept\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${def}\n\n## Definition\n\n[Clear explanation]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
112
+ return `# ${title}
113
+
114
+ ${def}
115
+
116
+ ## Definition
117
+
118
+ [Clear explanation]
119
+
120
+ ## Links
121
+
122
+ - [${sourceId}](/sources/${sourceId}.md)`;
99
123
  }
100
124
 
101
- /** Rebuild the source page from synthesis data, marking it ingested. */
102
- export function buildIngestedSourcePage(
125
+ /** Rebuild the source page body from synthesis data, marking it ingested. */
126
+ export function buildIngestedSourcePageBody(
103
127
  manifest: Record<string, unknown>,
104
128
  data: SynthesisData,
105
- date: string,
129
+ _date: string,
106
130
  ): string {
107
131
  const id = String(manifest.id);
108
132
  const title = String(manifest.title || id);
109
133
  const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
110
- const format = String(manifest.format || "unknown");
111
- const captured = String(manifest.captured || date);
112
134
 
113
135
  const takeaways =
114
136
  data.key_takeaways.length > 0
@@ -116,11 +138,11 @@ export function buildIngestedSourcePage(
116
138
  : "- [None recorded]";
117
139
  const entities =
118
140
  data.entities.length > 0
119
- ? data.entities.map((e) => `- [[entities/${slugify(e.title)}]]`).join("\n")
141
+ ? data.entities.map((e) => `- [${e.title}](/entities/${slugify(e.title)}.md)`).join("\n")
120
142
  : "- [None]";
121
143
  const concepts =
122
144
  data.concepts.length > 0
123
- ? data.concepts.map((c) => `- [[concepts/${slugify(c.title)}]]`).join("\n")
145
+ ? data.concepts.map((c) => `- [${c.title}](/concepts/${slugify(c.title)}.md)`).join("\n")
124
146
  : "- [None]";
125
147
  const quotes =
126
148
  data.quotes && data.quotes.length > 0
@@ -133,7 +155,61 @@ export function buildIngestedSourcePage(
133
155
  ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
134
156
  : "";
135
157
 
136
- return `---\ntype: source\nformat: ${format}\nsource_id: ${id}\nraw_path: raw/sources/${id}/extracted.md\ncaptured: ${captured}\nstatus: ingested\nupdated: ${date}\n---\n\n# ${title}${url}\n\n## Summary\n\n${data.summary.trim()}\n\n## Key Takeaways\n\n${takeaways}\n\n## Entities Mentioned\n\n${entities}\n\n## Concepts Mentioned\n\n${concepts}\n\n## Notable Quotes\n\n${quotes}\n${contradictions}\n## Source Packet\n\n- **ID:** \`[[sources/${id}]]\`\n- **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)\n- **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)\n`;
158
+ return `# ${title}${url}
159
+
160
+ ## Summary
161
+
162
+ ${data.summary.trim()}
163
+
164
+ ## Key Takeaways
165
+
166
+ ${takeaways}
167
+
168
+ ## Entities Mentioned
169
+
170
+ ${entities}
171
+
172
+ ## Concepts Mentioned
173
+
174
+ ${concepts}
175
+
176
+ ## Notable Quotes
177
+
178
+ ${quotes}
179
+ ${contradictions}## Source Packet
180
+
181
+ - **ID:** \`sources/${id}\`
182
+ - **Extracted:** \`raw/sources/${id}/extracted.md\`
183
+ - **Manifest:** \`raw/sources/${id}/manifest.json\`
184
+ `;
185
+ }
186
+
187
+ /** Rebuild the source page from synthesis data, marking it ingested. */
188
+ export function buildIngestedSourcePage(
189
+ manifest: Record<string, unknown>,
190
+ data: SynthesisData,
191
+ date: string,
192
+ ): string {
193
+ const id = String(manifest.id);
194
+ const title = String(manifest.title || id);
195
+ const format = String(manifest.format || "unknown");
196
+ const captured = String(manifest.captured || date);
197
+ const body = buildIngestedSourcePageBody(manifest, data, date);
198
+ const doc = createKnowledgeDocument(
199
+ `sources/${id}.md`,
200
+ {
201
+ type: "source",
202
+ title,
203
+ format,
204
+ source_id: id,
205
+ raw_path: `raw/sources/${id}/extracted.md`,
206
+ captured,
207
+ status: "ingested",
208
+ updated: date,
209
+ },
210
+ body,
211
+ );
212
+ return serializeKnowledgeDocument(doc);
137
213
  }
138
214
 
139
215
  /**
@@ -147,7 +223,7 @@ export function commitSynthesis(
147
223
  manifest: Record<string, unknown>,
148
224
  data: SynthesisData,
149
225
  date: string = fmtDate(),
150
- ): CommitResult {
226
+ ): CommitSynthesisOutcome {
151
227
  const result: CommitResult = {
152
228
  sourceId,
153
229
  sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
@@ -158,9 +234,42 @@ export function commitSynthesis(
158
234
  contradictions: data.contradictions?.length ?? 0,
159
235
  };
160
236
 
161
- // Source page (always rewritten from skeleton → ingested).
237
+ try {
238
+ assertWritableVault(paths);
239
+ } catch (error: unknown) {
240
+ if (error instanceof VaultWriteError) {
241
+ return { ok: false, sourceId, diagnostics: error.diagnostics };
242
+ }
243
+ throw error;
244
+ }
245
+
246
+ // Patch existing documents so unknown fields, legacy sources, and titles survive.
247
+ let sourceDocument: KnowledgeDocument;
248
+ if (existsSync(result.sourcePage)) {
249
+ const parsed = readKnowledgeDocumentFile(result.sourcePage, `sources/${sourceId}.md`);
250
+ if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
251
+ sourceDocument = patchKnowledgeDocument(parsed.document, {
252
+ fields: { status: "ingested", updated: date },
253
+ body: buildIngestedSourcePageBody(manifest, data, date),
254
+ });
255
+ } else {
256
+ sourceDocument = createKnowledgeDocument(
257
+ `sources/${sourceId}.md`,
258
+ {
259
+ type: "source",
260
+ title: String(manifest.title || sourceId),
261
+ format: String(manifest.format || "unknown"),
262
+ source_id: sourceId,
263
+ raw_path: `raw/sources/${sourceId}/extracted.md`,
264
+ captured: String(manifest.captured || date),
265
+ status: "ingested",
266
+ updated: date,
267
+ },
268
+ buildIngestedSourcePageBody(manifest, data, date),
269
+ );
270
+ }
162
271
  mkdirSync(join(paths.wiki, "sources"), { recursive: true });
163
- writeFileSync(result.sourcePage, buildIngestedSourcePage(manifest, data, date), "utf-8");
272
+ writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
164
273
 
165
274
  // Entity pages — create if absent, link if present.
166
275
  mkdirSync(join(paths.wiki, "entities"), { recursive: true });
@@ -171,7 +280,19 @@ export function commitSynthesis(
171
280
  if (existsSync(pagePath)) {
172
281
  result.entitiesLinked.push(slug);
173
282
  } else {
174
- writeFileSync(pagePath, buildEntityPage(e.title, e.description, date, sourceId), "utf-8");
283
+ const entityDoc = createKnowledgeDocument(
284
+ `entities/${slug}.md`,
285
+ {
286
+ type: "entity",
287
+ title: e.title,
288
+ description: e.description.trim() || "One-line description.",
289
+ created: date,
290
+ updated: date,
291
+ },
292
+ buildEntityPageBody(e.title, e.description, sourceId),
293
+ [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
294
+ );
295
+ writeKnowledgeDocumentFile(pagePath, entityDoc);
175
296
  result.entitiesCreated.push(slug);
176
297
  }
177
298
  }
@@ -185,7 +306,19 @@ export function commitSynthesis(
185
306
  if (existsSync(pagePath)) {
186
307
  result.conceptsLinked.push(slug);
187
308
  } else {
188
- writeFileSync(pagePath, buildConceptPage(c.title, c.definition, date, sourceId), "utf-8");
309
+ const conceptDoc = createKnowledgeDocument(
310
+ `concepts/${slug}.md`,
311
+ {
312
+ type: "concept",
313
+ title: c.title,
314
+ description: c.definition.trim() || "One-line definition.",
315
+ created: date,
316
+ updated: date,
317
+ },
318
+ buildConceptPageBody(c.title, c.definition, sourceId),
319
+ [{ id: sourceId, resource: `/sources/${sourceId}.md` }],
320
+ );
321
+ writeKnowledgeDocumentFile(pagePath, conceptDoc);
189
322
  result.conceptsCreated.push(slug);
190
323
  }
191
324
  }
@@ -199,7 +332,7 @@ export function commitSynthesis(
199
332
  background: true,
200
333
  });
201
334
 
202
- return result;
335
+ return { ok: true, ...result };
203
336
  }
204
337
 
205
338
  // ── sub-agent synthesis (LLM) ─────────────────────────────
@@ -253,7 +386,15 @@ export async function runIngestSynthesis(
253
386
  "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
254
387
  parameters: CommitSynthesisSchema,
255
388
  execute: async (_id, params) => {
256
- committed = commitSynthesis(paths, sourceId, manifest, params);
389
+ const outcome = commitSynthesis(paths, sourceId, manifest, params);
390
+ if (!outcome.ok) {
391
+ return {
392
+ content: [{ type: "text", text: `Failed: ${outcome.diagnostics[0].message}` }],
393
+ details: { sourceId },
394
+ isError: true,
395
+ };
396
+ }
397
+ committed = outcome;
257
398
  const ack = `Committed: source page + ${committed.entitiesCreated.length} new entit${
258
399
  committed.entitiesCreated.length === 1 ? "y" : "ies"
259
400
  }, ${committed.conceptsCreated.length} new concept${