@zosmaai/pi-llm-wiki 0.10.9 → 0.11.1

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 (82) hide show
  1. package/CHANGELOG.md +6 -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 +41 -12
  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 +74 -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 +410 -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 +505 -0
  23. package/dist/extensions/llm-wiki/lib/model-command.js +85 -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 +187 -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 +201 -0
  32. package/dist/extensions/llm-wiki/lib/tools.js +1199 -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 +353 -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/api.md +5 -2
  44. package/docs/architecture.md +5 -2
  45. package/docs/configuration.md +25 -0
  46. package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
  47. package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
  48. package/docs/superpowers/plans/2026-08-06-authoritative-event-history-phase-1-foundation-hardening.md +937 -0
  49. package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
  50. package/docs/superpowers/plans/2026-08-07-synthesis-language.md +98 -0
  51. package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +593 -0
  52. package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +542 -0
  53. package/docs/superpowers/specs/2026-08-07-synthesis-language-design.md +94 -0
  54. package/extensions/llm-wiki/index.ts +22 -36
  55. package/extensions/llm-wiki/lib/bootstrap.ts +87 -0
  56. package/extensions/llm-wiki/lib/embeddings.ts +9 -3
  57. package/extensions/llm-wiki/lib/guardrails.ts +26 -18
  58. package/extensions/llm-wiki/lib/indexing.ts +2 -1
  59. package/extensions/llm-wiki/lib/ingest-worker.ts +304 -28
  60. package/extensions/llm-wiki/lib/knowledge-document.ts +663 -0
  61. package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
  62. package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
  63. package/extensions/llm-wiki/lib/metadata.ts +550 -128
  64. package/extensions/llm-wiki/lib/model-command.ts +0 -1
  65. package/extensions/llm-wiki/lib/observation.ts +37 -43
  66. package/extensions/llm-wiki/lib/recall.ts +61 -33
  67. package/extensions/llm-wiki/lib/retro.ts +65 -41
  68. package/extensions/llm-wiki/lib/runtime.ts +0 -3
  69. package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
  70. package/extensions/llm-wiki/lib/source-packet.ts +45 -32
  71. package/extensions/llm-wiki/lib/task-config.ts +36 -0
  72. package/extensions/llm-wiki/lib/tools.ts +413 -342
  73. package/extensions/llm-wiki/lib/trajectory.ts +15 -1
  74. package/extensions/llm-wiki/lib/utils.ts +127 -131
  75. package/extensions/llm-wiki/lib/vault-format.ts +363 -0
  76. package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
  77. package/mcp/exec.ts +122 -0
  78. package/mcp/index.ts +60 -250
  79. package/mcp/operations.ts +176 -0
  80. package/package.json +8 -2
  81. package/scripts/migrate-llm-wiki.js +801 -0
  82. package/skills/llm-wiki/SKILL.md +12 -8
@@ -1,36 +1,41 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { randomUUID } from "node:crypto";
3
2
  import {
4
- type VaultPaths,
5
- extractWikilinks,
6
- findWikiPages,
7
- fmtDate,
8
- parseFrontmatter,
9
- readJson,
10
- writeJson,
11
- } from "./utils.js";
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import type { KnowledgeDiagnostic, KnowledgeDocument } from "./knowledge-document.js";
14
+ import { buildResolvedBacklinks } from "./knowledge-links.js";
15
+ import { type VaultPaths, isPathWithin, readJson } from "./utils.js";
16
+ import {
17
+ assertWritableVault,
18
+ compareCodePoint,
19
+ discoverKnowledgeDocuments,
20
+ inspectVaultFormat,
21
+ } from "./vault-format.js";
12
22
 
13
23
  /**
14
24
  * Metadata generation for the LLM Wiki.
15
25
  *
16
- * Rebuilds registry.json, backlinks.json, index.md, log.md, and lint-report.md
17
- * deterministically from the current state of raw/ and wiki/.
26
+ * Rebuilds registry.json, backlinks.json, index.md, log.md deterministically
27
+ * from the current state of wiki/ and raw/. In OKF mode, also generates
28
+ * wiki/index.md and wiki/log.md projections.
29
+ *
30
+ * Fail-closed: computes all outputs in memory before writing any file.
31
+ * Blocking diagnostics prevent any writes.
18
32
  */
19
33
 
20
34
  export interface RegistryEntry {
21
- type:
22
- | "source"
23
- | "entity"
24
- | "concept"
25
- | "synthesis"
26
- | "analysis"
27
- | "requirement"
28
- | "trajectory"
29
- | "skill"
30
- | "case";
35
+ type: string;
31
36
  title: string;
32
- created: string;
33
- updated: string;
37
+ created?: string;
38
+ updated?: string;
34
39
  [key: string]: unknown;
35
40
  }
36
41
 
@@ -50,86 +55,149 @@ export interface WikiEvent {
50
55
  [key: string]: unknown;
51
56
  }
52
57
 
53
- /** Rebuild the complete metadata layer. */
54
- export function rebuildMetadata(paths: VaultPaths): void {
55
- mkdirSync(paths.meta, { recursive: true });
58
+ export interface ProjectionResult {
59
+ ok: boolean;
60
+ diagnostics: KnowledgeDiagnostic[];
61
+ registry?: Registry;
62
+ backlinks?: Backlinks;
63
+ }
56
64
 
57
- const registry = buildRegistry(paths);
58
- const backlinks = buildBacklinks(paths, registry);
65
+ /** Rebuild the complete metadata layer with fail-closed semantics. */
66
+ export function rebuildMetadata(paths: VaultPaths): ProjectionResult {
67
+ // Step 1: Validate vault format and mode
68
+ const vaultState = inspectVaultFormat(paths);
69
+ if (vaultState.blocking) {
70
+ return { ok: false, diagnostics: vaultState.diagnostics };
71
+ }
59
72
 
60
- writeJson(join(paths.meta, "registry.json"), registry);
61
- writeJson(join(paths.meta, "backlinks.json"), backlinks);
62
- writeFileSync(join(paths.meta, "index.md"), buildIndexMarkdown(registry), "utf-8");
73
+ // Step 2: Discover all knowledge documents
74
+ const discovery = discoverKnowledgeDocuments(paths);
75
+ if (discovery.blocking) {
76
+ return { ok: false, diagnostics: discovery.diagnostics };
77
+ }
63
78
 
64
- const log = buildLogMarkdown(paths);
65
- writeFileSync(join(paths.meta, "log.md"), log, "utf-8");
66
- }
79
+ const documents = discovery.documents;
80
+ const allDiagnostics: KnowledgeDiagnostic[] = [
81
+ ...vaultState.diagnostics,
82
+ ...discovery.diagnostics,
83
+ ];
67
84
 
68
- /** Build registry from wiki/ and raw/ state. */
69
- export function buildRegistry(paths: VaultPaths): Registry {
70
- const pages: Record<string, RegistryEntry> = {};
85
+ // Step 3: Build registry from documents + raw fallbacks
86
+ const registry = buildRegistry(paths, documents);
71
87
 
72
- // Scan wiki pages
73
- for (const page of findWikiPages(paths.wiki)) {
74
- const { frontmatter } = parseFrontmatter(page.content);
75
- const type = String(frontmatter.type || "page") as RegistryEntry["type"];
76
- const title = String(frontmatter.title || page.relative.split("/").pop() || "Untitled");
88
+ // Step 4: Build backlinks from discovered documents
89
+ const knownIds = new Set(documents.map((d) => d.id));
90
+ const backlinks = buildBacklinks(documents, knownIds, allDiagnostics);
77
91
 
78
- pages[page.relative] = {
79
- type,
80
- title,
81
- created: String(frontmatter.created || fmtDate()),
82
- updated: String(frontmatter.updated || frontmatter.created || fmtDate()),
83
- ...frontmatter,
84
- };
85
- }
92
+ const eventSource = readEventSource(join(paths.meta, "events.jsonl"));
93
+ const eventLogResult = eventSource.available ? buildOkfLog(eventSource.content) : undefined;
94
+ if (eventLogResult) allDiagnostics.push(...eventLogResult.diagnostics);
95
+ else if (!eventSource.available) allDiagnostics.push(eventSource.diagnostic);
86
96
 
87
- // Scan raw source packets
88
- if (existsSync(paths.rawSources)) {
89
- for (const entry of readdirSync(paths.rawSources)) {
90
- const manifestPath = join(paths.rawSources, entry, "manifest.json");
91
- if (!existsSync(manifestPath)) continue;
92
-
93
- const manifest = readJson<Record<string, unknown>>(manifestPath, {});
94
- const id = String(manifest.id || entry);
95
- const sourcePage = `sources/${id}`;
96
-
97
- if (!pages[sourcePage]) {
98
- pages[sourcePage] = {
99
- type: "source",
100
- title: String(manifest.title || id),
101
- created: String(manifest.captured || fmtDate()),
102
- updated: String(manifest.captured || fmtDate()),
103
- ...manifest,
104
- };
105
- }
97
+ // Step 5: Build meta/index.md
98
+ const metaIndex = buildIndexMarkdown(registry);
99
+
100
+ // Step 6: Build meta/log.md (existing rich format)
101
+ const metaLog = eventSource.available ? buildLogMarkdown(eventSource.content) : undefined;
102
+
103
+ // Step 7: Build OKF projections if in okf-0.2 mode
104
+ const okfIndexes: Map<string, string> | null =
105
+ vaultState.knowledgeFormat === "okf-0.2"
106
+ ? buildDirectoryIndexes(documents, readJson(join(paths.dotWiki, "config.json"), {}))
107
+ : null;
108
+
109
+ const okfLog: string | undefined =
110
+ vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult?.markdown : undefined;
111
+
112
+ // Step 8: Atomic write all projections
113
+ mkdirSync(paths.meta, { recursive: true });
114
+
115
+ const registryJson = `${JSON.stringify(registry, null, 2)}\n`;
116
+ const backlinksJson = `${JSON.stringify(backlinks, null, 2)}\n`;
117
+
118
+ atomicWriteFile(join(paths.meta, "registry.json"), registryJson);
119
+ atomicWriteFile(join(paths.meta, "backlinks.json"), backlinksJson);
120
+ atomicWriteFile(join(paths.meta, "index.md"), metaIndex);
121
+ if (metaLog !== undefined) atomicWriteFile(join(paths.meta, "log.md"), metaLog);
122
+
123
+ // Step 9: Write OKF projections if applicable
124
+ if (okfIndexes && okfIndexes.size > 0) {
125
+ mkdirSync(paths.wiki, { recursive: true });
126
+ for (const [indexPath, content] of okfIndexes) {
127
+ atomicWriteFile(join(paths.wiki, indexPath), content);
106
128
  }
129
+ // Prune obsolete generated indexes
130
+ pruneObsoleteIndexes(paths, okfIndexes);
107
131
  }
108
132
 
109
- // Scan raw trajectory packets (agent working-memory). These are catalogued
110
- // under the `trajectories/` namespace so distillation and recall can find
111
- // them even before a canonical case/skill page has been written.
112
- if (existsSync(paths.rawTrajectories)) {
113
- for (const entry of readdirSync(paths.rawTrajectories)) {
114
- const manifestPath = join(paths.rawTrajectories, entry, "manifest.json");
115
- if (!existsSync(manifestPath)) continue;
133
+ if (okfLog !== undefined) {
134
+ mkdirSync(paths.wiki, { recursive: true });
135
+ atomicWriteFile(join(paths.wiki, "log.md"), okfLog);
136
+ }
116
137
 
117
- const manifest = readJson<Record<string, unknown>>(manifestPath, {});
118
- const id = String(manifest.id || entry);
119
- const trajectoryPage = `trajectories/${id}`;
138
+ return {
139
+ ok: true,
140
+ diagnostics: allDiagnostics,
141
+ registry,
142
+ backlinks,
143
+ };
144
+ }
120
145
 
121
- if (!pages[trajectoryPage]) {
122
- pages[trajectoryPage] = {
123
- type: "trajectory",
124
- title: String(manifest.title || id),
125
- created: String(manifest.captured || fmtDate()),
126
- updated: String(manifest.captured || fmtDate()),
127
- ...manifest,
128
- };
146
+ /** Lightweight rebuild is the same as full rebuild. */
147
+ export const rebuildMetadataLight = rebuildMetadata;
148
+
149
+ /** Build registry from discovered documents and raw fallbacks. */
150
+ function buildRegistry(paths: VaultPaths, documents: KnowledgeDocument[]): Registry {
151
+ const pages: Record<string, RegistryEntry> = {};
152
+
153
+ // Add discovered documents
154
+ for (const doc of documents) {
155
+ const title = getSemanticTitle(doc);
156
+ const entry: RegistryEntry = {
157
+ type: doc.frontmatter.type,
158
+ title,
159
+ };
160
+ // Copy known frontmatter fields (excluding type which is already set)
161
+ for (const key of [
162
+ "description",
163
+ "tags",
164
+ "category",
165
+ "domain",
166
+ "aliases",
167
+ "recall_triggers",
168
+ "status",
169
+ "stale_after",
170
+ "resource",
171
+ "generated",
172
+ "verified",
173
+ "summary",
174
+ "raw_path",
175
+ "source_id",
176
+ ] as const) {
177
+ if (doc.frontmatter[key] !== undefined) {
178
+ entry[key] = doc.frontmatter[key];
179
+ }
180
+ }
181
+ // Copy extension fields
182
+ for (const [key, value] of Object.entries(doc.extensions)) {
183
+ if (!(key in entry)) {
184
+ entry[key] = value;
129
185
  }
130
186
  }
187
+ // Only include created/updated if they are non-empty strings
188
+ if (typeof doc.frontmatter.created === "string" && doc.frontmatter.created.trim()) {
189
+ entry.created = doc.frontmatter.created;
190
+ }
191
+ if (typeof doc.frontmatter.updated === "string" && doc.frontmatter.updated.trim()) {
192
+ entry.updated = doc.frontmatter.updated;
193
+ }
194
+ pages[doc.id] = entry;
131
195
  }
132
196
 
197
+ // Raw source/trajectory fallback entries (registry-only compatibility)
198
+ addRawFallbacks(paths, pages, "sources", "source");
199
+ addRawFallbacks(paths, pages, "trajectories", "trajectory");
200
+
133
201
  return {
134
202
  version: "1.0",
135
203
  last_updated: new Date().toISOString(),
@@ -137,30 +205,77 @@ export function buildRegistry(paths: VaultPaths): Registry {
137
205
  };
138
206
  }
139
207
 
140
- /** Build backlinks map from all wiki pages. */
141
- export function buildBacklinks(paths: VaultPaths, registry: Registry): Backlinks {
208
+ function addRawFallbacks(
209
+ paths: VaultPaths,
210
+ pages: Record<string, RegistryEntry>,
211
+ dirName: "sources" | "trajectories",
212
+ type: string,
213
+ ): void {
214
+ const rawDir = dirName === "sources" ? paths.rawSources : paths.rawTrajectories;
215
+ if (!existsSync(rawDir)) return;
216
+
217
+ for (const entry of readdirSync(rawDir)) {
218
+ const manifestPath = join(rawDir, entry, "manifest.json");
219
+ if (!existsSync(manifestPath)) continue;
220
+
221
+ const manifest = readJson<Record<string, unknown>>(manifestPath, {});
222
+ const id = String(manifest.id || entry);
223
+ const pageKey = `${dirName}/${id}`;
224
+
225
+ // Don't overwrite a parsed document
226
+ if (pages[pageKey]) continue;
227
+
228
+ const captured = String(manifest.captured || "");
229
+ pages[pageKey] = {
230
+ type,
231
+ title: String(manifest.title || id),
232
+ ...(captured ? { created: captured, updated: captured } : {}),
233
+ ...manifest,
234
+ };
235
+ }
236
+ }
237
+
238
+ function getSemanticTitle(doc: KnowledgeDocument): string {
239
+ if (typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()) {
240
+ return doc.frontmatter.title.trim();
241
+ }
242
+ return doc.id.split("/").pop() || "Untitled";
243
+ }
244
+
245
+ /** Build backlinks from discovered documents using shared link resolution. */
246
+ function buildBacklinks(
247
+ documents: KnowledgeDocument[],
248
+ knownIds: Set<string>,
249
+ diagnostics: KnowledgeDiagnostic[],
250
+ ): Backlinks {
142
251
  const inbound: Backlinks = {};
143
252
 
144
- // Initialize all pages with empty arrays
145
- for (const id of Object.keys(registry.pages)) {
146
- inbound[id] = [];
253
+ // Initialize parsed concept IDs with empty arrays
254
+ for (const doc of documents) {
255
+ inbound[doc.id] = [];
147
256
  }
148
257
 
149
- // Count inbound links
150
- for (const page of findWikiPages(paths.wiki)) {
151
- const links = extractWikilinks(page.content);
152
- for (const link of links) {
153
- if (inbound[link] && !inbound[link].includes(page.relative)) {
154
- inbound[link].push(page.relative);
258
+ // Resolve links for each document
259
+ for (const doc of documents) {
260
+ const result = buildResolvedBacklinks(doc.id, doc.body, knownIds);
261
+ diagnostics.push(...result.diagnostics);
262
+ for (const target of result.targets) {
263
+ if (inbound[target] && !inbound[target].includes(doc.id)) {
264
+ inbound[target].push(doc.id);
155
265
  }
156
266
  }
157
267
  }
158
268
 
269
+ // Sort targets for determinism
270
+ for (const [id, targets] of Object.entries(inbound)) {
271
+ inbound[id] = [...targets].sort(compareCodePoint);
272
+ }
273
+
159
274
  return inbound;
160
275
  }
161
276
 
162
- /** Build index markdown from registry. */
163
- export function buildIndexMarkdown(registry: Registry): string {
277
+ /** Build meta/index.md in existing rich format. */
278
+ function buildIndexMarkdown(registry: Registry): string {
164
279
  const byType: Record<string, Array<{ id: string; entry: RegistryEntry }>> = {};
165
280
 
166
281
  for (const [id, entry] of Object.entries(registry.pages)) {
@@ -178,7 +293,7 @@ export function buildIndexMarkdown(registry: Registry): string {
178
293
  const label = `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
179
294
  sections.push(`## ${label}\n`);
180
295
  for (const { id, entry } of items.sort((a, b) => a.id.localeCompare(b.id))) {
181
- sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created})*`);
296
+ sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created || "unknown"})*`);
182
297
  }
183
298
  sections.push("");
184
299
  }
@@ -189,20 +304,19 @@ export function buildIndexMarkdown(registry: Registry): string {
189
304
  return `${sections.join("\n")}\n`;
190
305
  }
191
306
 
192
- /** Build log markdown from events.jsonl. */
193
- export function buildLogMarkdown(paths: VaultPaths): string {
194
- const eventsPath = join(paths.meta, "events.jsonl");
307
+ function buildLogMarkdown(eventsJsonl: string): string {
195
308
  const events: WikiEvent[] = [];
196
-
197
- if (existsSync(eventsPath)) {
198
- const raw = readFileSync(eventsPath, "utf-8").trim();
199
- for (const line of raw.split("\n")) {
200
- if (!line.trim()) continue;
201
- try {
202
- events.push(JSON.parse(line) as WikiEvent);
203
- } catch {
204
- // skip malformed
309
+ const raw = eventsJsonl.trim();
310
+
311
+ for (const line of raw.split("\n")) {
312
+ if (!line.trim()) continue;
313
+ try {
314
+ const candidate: unknown = JSON.parse(line);
315
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
316
+ events.push(candidate as WikiEvent);
205
317
  }
318
+ } catch {
319
+ // Keep backward-compatible rich-log behavior: malformed lines are omitted.
206
320
  }
207
321
  }
208
322
 
@@ -222,29 +336,337 @@ export function buildLogMarkdown(paths: VaultPaths): string {
222
336
  lines.push("");
223
337
  }
224
338
 
225
- if (events.length === 0) {
226
- lines.push("_No events recorded yet._\n");
227
- }
228
-
339
+ if (events.length === 0) lines.push("_No events recorded yet._\n");
229
340
  return `${lines.join("\n")}\n`;
230
341
  }
231
342
 
232
343
  /** Append an event to events.jsonl. */
233
344
  export function appendEvent(paths: VaultPaths, event: Omit<WikiEvent, "timestamp">): void {
345
+ assertWritableVault(paths);
346
+ const { timestamp: _ignored, kind: rawKind, ...details } = event as WikiEvent;
347
+ const kind = typeof rawKind === "string" ? rawKind.trim() : "";
348
+ if (!kind) throw new Error("Event kind must be a non-empty string");
234
349
  mkdirSync(paths.meta, { recursive: true });
235
350
  const eventsPath = join(paths.meta, "events.jsonl");
236
- const line = JSON.stringify({ timestamp: new Date().toISOString(), ...event });
351
+ const line = JSON.stringify({ ...details, timestamp: new Date().toISOString(), kind });
237
352
  writeFileSync(eventsPath, `${line}\n`, { flag: "a", encoding: "utf-8" });
238
353
  }
239
354
 
240
- /** Quick lightweight metadata rebuild (backlinks + index + log only). */
241
- export function rebuildMetadataLight(paths: VaultPaths): void {
242
- const registry = buildRegistry(paths);
243
- const backlinks = buildBacklinks(paths, registry);
244
- writeJson(join(paths.meta, "registry.json"), registry);
245
- writeJson(join(paths.meta, "backlinks.json"), backlinks);
246
- writeFileSync(join(paths.meta, "index.md"), buildIndexMarkdown(registry), "utf-8");
355
+ /** Atomic write: temp file + rename. */
356
+ function atomicWriteFile(path: string, content: string): void {
357
+ mkdirSync(dirname(path), { recursive: true });
358
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
359
+ writeFileSync(temporary, content, "utf8");
360
+ renameSync(temporary, path);
361
+ }
362
+
363
+ type EventSourceRead =
364
+ | { available: true; content: string }
365
+ | { available: false; diagnostic: KnowledgeDiagnostic };
366
+
367
+ function readEventSource(filePath: string, diagnosticPath = "meta/events.jsonl"): EventSourceRead {
368
+ try {
369
+ return { available: true, content: readFileSync(filePath, "utf8") };
370
+ } catch (error) {
371
+ const code = (error as NodeJS.ErrnoException).code;
372
+ // ENOENT = missing; ENOTDIR/EISDIR = not a regular file (FreeBSD dir hazard); else unreadable
373
+ const diagnosticCode = code === "ENOENT" ? "event_source_missing" : "event_source_unreadable";
374
+ const message =
375
+ diagnosticCode === "event_source_missing"
376
+ ? "Authoritative event source is missing; existing log projections were preserved"
377
+ : "Authoritative event source is unreadable; existing log projections were preserved";
378
+ return {
379
+ available: false,
380
+ diagnostic: okfDiag("warning", diagnosticCode, diagnosticPath, message),
381
+ };
382
+ }
383
+ }
384
+
385
+ /** Prune obsolete generated indexes in OKF mode. */
386
+ function pruneObsoleteIndexes(paths: VaultPaths, currentIndexes: Map<string, string>): void {
387
+ const currentPaths = new Set(currentIndexes.keys());
247
388
 
248
- const log = buildLogMarkdown(paths);
249
- writeFileSync(join(paths.meta, "log.md"), log, "utf-8");
389
+ function walkDir(dir: string, relative: string): string[] {
390
+ const results: string[] = [];
391
+ if (!existsSync(dir)) return results;
392
+ for (const entry of readdirSync(dir)) {
393
+ const fullPath = join(dir, entry);
394
+ let stat: ReturnType<typeof lstatSync>;
395
+ try {
396
+ stat = lstatSync(fullPath);
397
+ } catch {
398
+ continue;
399
+ }
400
+ if (stat.isSymbolicLink()) continue;
401
+ const relPath = relative ? `${relative}/${entry}` : entry;
402
+ if (entry.toLowerCase() === "index.md") {
403
+ results.push(relPath);
404
+ } else if (stat.isDirectory()) {
405
+ results.push(...walkDir(fullPath, relPath));
406
+ }
407
+ }
408
+ return results;
409
+ }
410
+
411
+ const allIndexes = walkDir(paths.wiki, "");
412
+ for (const indexPath of allIndexes) {
413
+ // Never prune root index.md
414
+ if (indexPath === "index.md") continue;
415
+ if (!currentPaths.has(indexPath)) {
416
+ const fullPath = join(paths.wiki, indexPath);
417
+ try {
418
+ if (lstatSync(fullPath).isSymbolicLink() || !isPathWithin(paths.wiki, fullPath)) continue;
419
+ rmSync(fullPath);
420
+ // Try to remove parent dir if empty
421
+ const parentDir = dirname(fullPath);
422
+ if (
423
+ !lstatSync(parentDir).isSymbolicLink() &&
424
+ isPathWithin(paths.wiki, parentDir) &&
425
+ existsSync(parentDir) &&
426
+ readdirSync(parentDir).length === 0
427
+ ) {
428
+ rmSync(parentDir);
429
+ }
430
+ } catch {
431
+ // Ignore errors during pruning
432
+ }
433
+ }
434
+ }
435
+ }
436
+
437
+ // ===== OKF Projection Renderers =====
438
+
439
+ function escapeLabel(value: string): string {
440
+ return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
441
+ }
442
+
443
+ function encodeRelativePath(value: string): string {
444
+ return value.split("/").map(encodeURIComponent).join("/");
445
+ }
446
+
447
+ function compactDescription(value: unknown): string | undefined {
448
+ if (typeof value !== "string") return undefined;
449
+ const compact = value.replace(/\s+/g, " ").trim();
450
+ return compact || undefined;
451
+ }
452
+
453
+ export function buildDirectoryIndexes(
454
+ documents: KnowledgeDocument[],
455
+ config: { name?: unknown },
456
+ ): Map<string, string> {
457
+ const indexes = new Map<string, string>();
458
+
459
+ // Build directory tree from concept documents only
460
+ const directories = new Map<string, { dirs: Set<string>; concepts: KnowledgeDocument[] }>();
461
+
462
+ for (const doc of documents) {
463
+ const parts = doc.id.split("/");
464
+
465
+ // Walk the path, tracking parent-child directory relationships
466
+ for (let i = 0; i < parts.length; i++) {
467
+ const parentPath = i === 0 ? "" : parts.slice(0, i).join("/");
468
+ if (!directories.has(parentPath)) {
469
+ directories.set(parentPath, { dirs: new Set(), concepts: [] });
470
+ }
471
+
472
+ if (i === parts.length - 1) {
473
+ // Last part is the concept file
474
+ directories.get(parentPath)!.concepts.push(doc);
475
+ } else {
476
+ // This is a subdirectory
477
+ directories.get(parentPath)!.dirs.add(parts[i]);
478
+ }
479
+ }
480
+ }
481
+
482
+ // Always emit root index
483
+ const vaultName =
484
+ typeof config.name === "string" && config.name.trim() ? config.name.trim() : "Wiki";
485
+ if (!directories.has("")) {
486
+ directories.set("", { dirs: new Set(), concepts: [] });
487
+ }
488
+
489
+ // Render each index
490
+ for (const [dirPath, { dirs, concepts }] of directories) {
491
+ const indexPath = dirPath ? `${dirPath}/index.md` : "index.md";
492
+ const lines: string[] = [];
493
+
494
+ if (dirPath === "") {
495
+ lines.push("---");
496
+ lines.push('okf_version: "0.2"');
497
+ lines.push("---");
498
+ lines.push("");
499
+ lines.push(`# ${escapeLabel(vaultName)}`);
500
+ } else {
501
+ const dirName = dirPath.split("/").pop()!;
502
+ lines.push(`# ${escapeLabel(dirName)}`);
503
+ }
504
+
505
+ // List directories first
506
+ if (dirs.size > 0) {
507
+ lines.push("");
508
+ lines.push("## Directories");
509
+ lines.push("");
510
+ for (const subDir of [...dirs].sort(compareCodePoint)) {
511
+ const encoded = `${encodeRelativePath(subDir)}/`;
512
+ lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
513
+ }
514
+ }
515
+
516
+ // List concepts
517
+ if (concepts.length > 0) {
518
+ lines.push("");
519
+ lines.push("## Concepts");
520
+ lines.push("");
521
+ const sorted = [...concepts].sort((a, b) => {
522
+ const aRel = dirPath ? a.id.slice(dirPath.length + 1) : a.id;
523
+ const bRel = dirPath ? b.id.slice(dirPath.length + 1) : b.id;
524
+ return compareCodePoint(aRel, bRel);
525
+ });
526
+ for (const doc of sorted) {
527
+ const relId = dirPath ? doc.id.slice(dirPath.length + 1) : doc.id;
528
+ const title =
529
+ typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()
530
+ ? doc.frontmatter.title.trim()
531
+ : relId.split("/").pop()!;
532
+ const desc = compactDescription(doc.frontmatter.description);
533
+ const encoded = encodeRelativePath(`${relId}.md`);
534
+ const descPart = desc ? ` — ${desc}` : "";
535
+ lines.push(`- [${escapeLabel(title)}](${encoded})${descPart}`);
536
+ }
537
+ }
538
+
539
+ indexes.set(indexPath, `${lines.join("\n")}\n`);
540
+ }
541
+
542
+ return indexes;
543
+ }
544
+
545
+ export interface OkfLogResult {
546
+ markdown: string;
547
+ diagnostics: KnowledgeDiagnostic[];
548
+ }
549
+
550
+ function canonicalJsonValue(value: unknown): unknown {
551
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
552
+ if (value && typeof value === "object") {
553
+ return Object.fromEntries(
554
+ Object.entries(value as Record<string, unknown>)
555
+ .sort(([a], [b]) => compareCodePoint(a, b))
556
+ .map(([key, child]) => [key, canonicalJsonValue(child)]),
557
+ );
558
+ }
559
+ return value;
560
+ }
561
+
562
+ function okfDiag(
563
+ severity: "warning" | "error",
564
+ code: KnowledgeDiagnostic["code"],
565
+ path: string,
566
+ message: string,
567
+ ): KnowledgeDiagnostic {
568
+ return { severity, code, path, message };
569
+ }
570
+
571
+ export function buildOkfLog(eventsJsonl: string, path = "meta/events.jsonl"): OkfLogResult {
572
+ const diagnostics: KnowledgeDiagnostic[] = [];
573
+ const events: Array<{
574
+ seq: number;
575
+ timestamp: string;
576
+ epoch: number;
577
+ date: string;
578
+ kind: string;
579
+ details: string;
580
+ }> = [];
581
+
582
+ const lines = eventsJsonl.split("\n");
583
+ for (let i = 0; i < lines.length; i++) {
584
+ const line = lines[i];
585
+ if (!line.trim()) continue;
586
+
587
+ let parsed: Record<string, unknown>;
588
+ try {
589
+ const candidate: unknown = JSON.parse(line);
590
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
591
+ throw new Error("event must be a JSON object");
592
+ }
593
+ parsed = candidate as Record<string, unknown>;
594
+ } catch {
595
+ diagnostics.push(
596
+ okfDiag("warning", "event_invalid_json", path, `Invalid JSON at line ${i + 1}`),
597
+ );
598
+ continue;
599
+ }
600
+
601
+ const rawTs = parsed.timestamp;
602
+ if (typeof rawTs !== "string" || Number.isNaN(Date.parse(rawTs))) {
603
+ diagnostics.push(
604
+ okfDiag("warning", "event_invalid_timestamp", path, `Invalid timestamp at line ${i + 1}`),
605
+ );
606
+ continue;
607
+ }
608
+
609
+ const rawKind = parsed.kind;
610
+ if (typeof rawKind !== "string" || !rawKind.trim()) {
611
+ diagnostics.push(
612
+ okfDiag("warning", "event_missing_kind", path, `Missing kind at line ${i + 1}`),
613
+ );
614
+ continue;
615
+ }
616
+
617
+ const ts = new Date(rawTs);
618
+ const date = ts.toISOString().split("T")[0];
619
+ const kind = rawKind.trim();
620
+
621
+ const detailEntries: [string, unknown][] = Object.entries(parsed).filter(
622
+ ([k]) => k !== "timestamp" && k !== "kind",
623
+ );
624
+ const details =
625
+ detailEntries.length > 0
626
+ ? JSON.stringify(canonicalJsonValue(Object.fromEntries(detailEntries)))
627
+ : "";
628
+
629
+ events.push({ seq: i, timestamp: rawTs, epoch: ts.getTime(), date, kind, details });
630
+ }
631
+
632
+ // Group by date
633
+ const byDate = new Map<string, typeof events>();
634
+ for (const ev of events) {
635
+ if (!byDate.has(ev.date)) byDate.set(ev.date, []);
636
+ byDate.get(ev.date)!.push(ev);
637
+ }
638
+
639
+ const sortedDates = [...byDate.keys()].sort((a, b) => b.localeCompare(a));
640
+
641
+ const outLines: string[] = ["# Wiki Update Log"];
642
+
643
+ for (const date of sortedDates) {
644
+ const dayEvents = byDate.get(date)!;
645
+ dayEvents.sort((a, b) => b.epoch - a.epoch || b.seq - a.seq);
646
+
647
+ outLines.push("");
648
+ outLines.push(`## ${date}`);
649
+ outLines.push("");
650
+
651
+ for (const ev of dayEvents) {
652
+ const escapedKind = ev.kind
653
+ .replace(/\\/g, "\\\\")
654
+ .replace(/\*/g, "\\*")
655
+ .replace(/_/g, "\\_")
656
+ .replace(/\[/g, "\\[")
657
+ .replace(/\]/g, "\\]")
658
+ .replace(/\s+/g, " ");
659
+
660
+ if (ev.details) {
661
+ outLines.push(`- **${escapedKind}**: ${ev.details}`);
662
+ } else {
663
+ outLines.push(`- **${escapedKind}**`);
664
+ }
665
+ }
666
+ }
667
+
668
+ return {
669
+ markdown: `${outLines.join("\n")}\n`,
670
+ diagnostics,
671
+ };
250
672
  }