@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
@@ -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,147 @@ 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 eventLogResult = buildOkfLog(readText(join(paths.meta, "events.jsonl")));
93
+ allDiagnostics.push(...eventLogResult.diagnostics);
86
94
 
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
- }
95
+ // Step 5: Build meta/index.md
96
+ const metaIndex = buildIndexMarkdown(registry);
97
+
98
+ // Step 6: Build meta/log.md (existing rich format)
99
+ const metaLog = buildLogMarkdown(paths);
100
+
101
+ // Step 7: Build OKF projections if in okf-0.2 mode
102
+ const okfIndexes: Map<string, string> | null =
103
+ vaultState.knowledgeFormat === "okf-0.2"
104
+ ? buildDirectoryIndexes(documents, readJson(join(paths.dotWiki, "config.json"), {}))
105
+ : null;
106
+
107
+ const okfLog: string | null =
108
+ vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult.markdown : null;
109
+
110
+ // Step 8: Atomic write all projections
111
+ mkdirSync(paths.meta, { recursive: true });
112
+
113
+ const registryJson = `${JSON.stringify(registry, null, 2)}\n`;
114
+ const backlinksJson = `${JSON.stringify(backlinks, null, 2)}\n`;
115
+
116
+ atomicWriteFile(join(paths.meta, "registry.json"), registryJson);
117
+ atomicWriteFile(join(paths.meta, "backlinks.json"), backlinksJson);
118
+ atomicWriteFile(join(paths.meta, "index.md"), metaIndex);
119
+ atomicWriteFile(join(paths.meta, "log.md"), metaLog);
120
+
121
+ // Step 9: Write OKF projections if applicable
122
+ if (okfIndexes && okfIndexes.size > 0) {
123
+ mkdirSync(paths.wiki, { recursive: true });
124
+ for (const [indexPath, content] of okfIndexes) {
125
+ atomicWriteFile(join(paths.wiki, indexPath), content);
106
126
  }
127
+ // Prune obsolete generated indexes
128
+ pruneObsoleteIndexes(paths, okfIndexes);
107
129
  }
108
130
 
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;
131
+ if (okfLog !== null) {
132
+ mkdirSync(paths.wiki, { recursive: true });
133
+ atomicWriteFile(join(paths.wiki, "log.md"), okfLog);
134
+ }
135
+
136
+ return {
137
+ ok: true,
138
+ diagnostics: allDiagnostics,
139
+ registry,
140
+ backlinks,
141
+ };
142
+ }
143
+
144
+ /** Lightweight rebuild is the same as full rebuild. */
145
+ export const rebuildMetadataLight = rebuildMetadata;
116
146
 
117
- const manifest = readJson<Record<string, unknown>>(manifestPath, {});
118
- const id = String(manifest.id || entry);
119
- const trajectoryPage = `trajectories/${id}`;
147
+ /** Build registry from discovered documents and raw fallbacks. */
148
+ function buildRegistry(paths: VaultPaths, documents: KnowledgeDocument[]): Registry {
149
+ const pages: Record<string, RegistryEntry> = {};
120
150
 
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
- };
151
+ // Add discovered documents
152
+ for (const doc of documents) {
153
+ const title = getSemanticTitle(doc);
154
+ const entry: RegistryEntry = {
155
+ type: doc.frontmatter.type,
156
+ title,
157
+ };
158
+ // Copy known frontmatter fields (excluding type which is already set)
159
+ for (const key of [
160
+ "description",
161
+ "tags",
162
+ "category",
163
+ "domain",
164
+ "aliases",
165
+ "recall_triggers",
166
+ "status",
167
+ "stale_after",
168
+ "resource",
169
+ "generated",
170
+ "verified",
171
+ "summary",
172
+ "raw_path",
173
+ "source_id",
174
+ ] as const) {
175
+ if (doc.frontmatter[key] !== undefined) {
176
+ entry[key] = doc.frontmatter[key];
177
+ }
178
+ }
179
+ // Copy extension fields
180
+ for (const [key, value] of Object.entries(doc.extensions)) {
181
+ if (!(key in entry)) {
182
+ entry[key] = value;
129
183
  }
130
184
  }
185
+ // Only include created/updated if they are non-empty strings
186
+ if (typeof doc.frontmatter.created === "string" && doc.frontmatter.created.trim()) {
187
+ entry.created = doc.frontmatter.created;
188
+ }
189
+ if (typeof doc.frontmatter.updated === "string" && doc.frontmatter.updated.trim()) {
190
+ entry.updated = doc.frontmatter.updated;
191
+ }
192
+ pages[doc.id] = entry;
131
193
  }
132
194
 
195
+ // Raw source/trajectory fallback entries (registry-only compatibility)
196
+ addRawFallbacks(paths, pages, "sources", "source");
197
+ addRawFallbacks(paths, pages, "trajectories", "trajectory");
198
+
133
199
  return {
134
200
  version: "1.0",
135
201
  last_updated: new Date().toISOString(),
@@ -137,30 +203,77 @@ export function buildRegistry(paths: VaultPaths): Registry {
137
203
  };
138
204
  }
139
205
 
140
- /** Build backlinks map from all wiki pages. */
141
- export function buildBacklinks(paths: VaultPaths, registry: Registry): Backlinks {
206
+ function addRawFallbacks(
207
+ paths: VaultPaths,
208
+ pages: Record<string, RegistryEntry>,
209
+ dirName: "sources" | "trajectories",
210
+ type: string,
211
+ ): void {
212
+ const rawDir = dirName === "sources" ? paths.rawSources : paths.rawTrajectories;
213
+ if (!existsSync(rawDir)) return;
214
+
215
+ for (const entry of readdirSync(rawDir)) {
216
+ const manifestPath = join(rawDir, entry, "manifest.json");
217
+ if (!existsSync(manifestPath)) continue;
218
+
219
+ const manifest = readJson<Record<string, unknown>>(manifestPath, {});
220
+ const id = String(manifest.id || entry);
221
+ const pageKey = `${dirName}/${id}`;
222
+
223
+ // Don't overwrite a parsed document
224
+ if (pages[pageKey]) continue;
225
+
226
+ const captured = String(manifest.captured || "");
227
+ pages[pageKey] = {
228
+ type,
229
+ title: String(manifest.title || id),
230
+ ...(captured ? { created: captured, updated: captured } : {}),
231
+ ...manifest,
232
+ };
233
+ }
234
+ }
235
+
236
+ function getSemanticTitle(doc: KnowledgeDocument): string {
237
+ if (typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()) {
238
+ return doc.frontmatter.title.trim();
239
+ }
240
+ return doc.id.split("/").pop() || "Untitled";
241
+ }
242
+
243
+ /** Build backlinks from discovered documents using shared link resolution. */
244
+ function buildBacklinks(
245
+ documents: KnowledgeDocument[],
246
+ knownIds: Set<string>,
247
+ diagnostics: KnowledgeDiagnostic[],
248
+ ): Backlinks {
142
249
  const inbound: Backlinks = {};
143
250
 
144
- // Initialize all pages with empty arrays
145
- for (const id of Object.keys(registry.pages)) {
146
- inbound[id] = [];
251
+ // Initialize parsed concept IDs with empty arrays
252
+ for (const doc of documents) {
253
+ inbound[doc.id] = [];
147
254
  }
148
255
 
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);
256
+ // Resolve links for each document
257
+ for (const doc of documents) {
258
+ const result = buildResolvedBacklinks(doc.id, doc.body, knownIds);
259
+ diagnostics.push(...result.diagnostics);
260
+ for (const target of result.targets) {
261
+ if (inbound[target] && !inbound[target].includes(doc.id)) {
262
+ inbound[target].push(doc.id);
155
263
  }
156
264
  }
157
265
  }
158
266
 
267
+ // Sort targets for determinism
268
+ for (const [id, targets] of Object.entries(inbound)) {
269
+ inbound[id] = [...targets].sort(compareCodePoint);
270
+ }
271
+
159
272
  return inbound;
160
273
  }
161
274
 
162
- /** Build index markdown from registry. */
163
- export function buildIndexMarkdown(registry: Registry): string {
275
+ /** Build meta/index.md in existing rich format. */
276
+ function buildIndexMarkdown(registry: Registry): string {
164
277
  const byType: Record<string, Array<{ id: string; entry: RegistryEntry }>> = {};
165
278
 
166
279
  for (const [id, entry] of Object.entries(registry.pages)) {
@@ -178,7 +291,7 @@ export function buildIndexMarkdown(registry: Registry): string {
178
291
  const label = `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
179
292
  sections.push(`## ${label}\n`);
180
293
  for (const { id, entry } of items.sort((a, b) => a.id.localeCompare(b.id))) {
181
- sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created})*`);
294
+ sections.push(`- [[${id}]] — ${entry.title} *(created: ${entry.created || "unknown"})*`);
182
295
  }
183
296
  sections.push("");
184
297
  }
@@ -189,17 +302,20 @@ export function buildIndexMarkdown(registry: Registry): string {
189
302
  return `${sections.join("\n")}\n`;
190
303
  }
191
304
 
192
- /** Build log markdown from events.jsonl. */
193
- export function buildLogMarkdown(paths: VaultPaths): string {
305
+ /** Build meta/log.md in existing rich format. */
306
+ function buildLogMarkdown(paths: VaultPaths): string {
194
307
  const eventsPath = join(paths.meta, "events.jsonl");
195
308
  const events: WikiEvent[] = [];
196
309
 
197
310
  if (existsSync(eventsPath)) {
198
- const raw = readFileSync(eventsPath, "utf-8").trim();
311
+ const raw = readText(eventsPath).trim();
199
312
  for (const line of raw.split("\n")) {
200
313
  if (!line.trim()) continue;
201
314
  try {
202
- events.push(JSON.parse(line) as WikiEvent);
315
+ const candidate: unknown = JSON.parse(line);
316
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) {
317
+ events.push(candidate as WikiEvent);
318
+ }
203
319
  } catch {
204
320
  // skip malformed
205
321
  }
@@ -231,20 +347,319 @@ export function buildLogMarkdown(paths: VaultPaths): string {
231
347
 
232
348
  /** Append an event to events.jsonl. */
233
349
  export function appendEvent(paths: VaultPaths, event: Omit<WikiEvent, "timestamp">): void {
350
+ assertWritableVault(paths);
351
+ const { timestamp: _ignored, kind: rawKind, ...details } = event as WikiEvent;
352
+ const kind = typeof rawKind === "string" ? rawKind.trim() : "";
353
+ if (!kind) throw new Error("Event kind must be a non-empty string");
234
354
  mkdirSync(paths.meta, { recursive: true });
235
355
  const eventsPath = join(paths.meta, "events.jsonl");
236
- const line = JSON.stringify({ timestamp: new Date().toISOString(), ...event });
356
+ const line = JSON.stringify({ ...details, timestamp: new Date().toISOString(), kind });
237
357
  writeFileSync(eventsPath, `${line}\n`, { flag: "a", encoding: "utf-8" });
238
358
  }
239
359
 
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");
360
+ /** Atomic write: temp file + rename. */
361
+ function atomicWriteFile(path: string, content: string): void {
362
+ mkdirSync(dirname(path), { recursive: true });
363
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
364
+ writeFileSync(temporary, content, "utf8");
365
+ renameSync(temporary, path);
366
+ }
367
+
368
+ /** Read text file or return empty string. */
369
+ function readText(path: string): string {
370
+ try {
371
+ if (!existsSync(path)) return "";
372
+ return readFileSync(path, "utf8");
373
+ } catch {
374
+ return "";
375
+ }
376
+ }
377
+
378
+ /** Prune obsolete generated indexes in OKF mode. */
379
+ function pruneObsoleteIndexes(paths: VaultPaths, currentIndexes: Map<string, string>): void {
380
+ const currentPaths = new Set(currentIndexes.keys());
381
+
382
+ function walkDir(dir: string, relative: string): string[] {
383
+ const results: string[] = [];
384
+ if (!existsSync(dir)) return results;
385
+ for (const entry of readdirSync(dir)) {
386
+ const fullPath = join(dir, entry);
387
+ let stat: ReturnType<typeof lstatSync>;
388
+ try {
389
+ stat = lstatSync(fullPath);
390
+ } catch {
391
+ continue;
392
+ }
393
+ if (stat.isSymbolicLink()) continue;
394
+ const relPath = relative ? `${relative}/${entry}` : entry;
395
+ if (entry.toLowerCase() === "index.md") {
396
+ results.push(relPath);
397
+ } else if (stat.isDirectory()) {
398
+ results.push(...walkDir(fullPath, relPath));
399
+ }
400
+ }
401
+ return results;
402
+ }
403
+
404
+ const allIndexes = walkDir(paths.wiki, "");
405
+ for (const indexPath of allIndexes) {
406
+ // Never prune root index.md
407
+ if (indexPath === "index.md") continue;
408
+ if (!currentPaths.has(indexPath)) {
409
+ const fullPath = join(paths.wiki, indexPath);
410
+ try {
411
+ if (lstatSync(fullPath).isSymbolicLink() || !isPathWithin(paths.wiki, fullPath)) continue;
412
+ rmSync(fullPath);
413
+ // Try to remove parent dir if empty
414
+ const parentDir = dirname(fullPath);
415
+ if (
416
+ !lstatSync(parentDir).isSymbolicLink() &&
417
+ isPathWithin(paths.wiki, parentDir) &&
418
+ existsSync(parentDir) &&
419
+ readdirSync(parentDir).length === 0
420
+ ) {
421
+ rmSync(parentDir);
422
+ }
423
+ } catch {
424
+ // Ignore errors during pruning
425
+ }
426
+ }
427
+ }
428
+ }
429
+
430
+ // ===== OKF Projection Renderers =====
431
+
432
+ function escapeLabel(value: string): string {
433
+ return value.replace(/\\/g, "\\\\").replace(/\[/g, "\\[").replace(/\]/g, "\\]");
434
+ }
435
+
436
+ function encodeRelativePath(value: string): string {
437
+ return value.split("/").map(encodeURIComponent).join("/");
438
+ }
439
+
440
+ function compactDescription(value: unknown): string | undefined {
441
+ if (typeof value !== "string") return undefined;
442
+ const compact = value.replace(/\s+/g, " ").trim();
443
+ return compact || undefined;
444
+ }
445
+
446
+ export function buildDirectoryIndexes(
447
+ documents: KnowledgeDocument[],
448
+ config: { name?: unknown },
449
+ ): Map<string, string> {
450
+ const indexes = new Map<string, string>();
451
+
452
+ // Build directory tree from concept documents only
453
+ const directories = new Map<string, { dirs: Set<string>; concepts: KnowledgeDocument[] }>();
454
+
455
+ for (const doc of documents) {
456
+ const parts = doc.id.split("/");
457
+
458
+ // Walk the path, tracking parent-child directory relationships
459
+ for (let i = 0; i < parts.length; i++) {
460
+ const parentPath = i === 0 ? "" : parts.slice(0, i).join("/");
461
+ if (!directories.has(parentPath)) {
462
+ directories.set(parentPath, { dirs: new Set(), concepts: [] });
463
+ }
464
+
465
+ if (i === parts.length - 1) {
466
+ // Last part is the concept file
467
+ directories.get(parentPath)!.concepts.push(doc);
468
+ } else {
469
+ // This is a subdirectory
470
+ directories.get(parentPath)!.dirs.add(parts[i]);
471
+ }
472
+ }
473
+ }
474
+
475
+ // Always emit root index
476
+ const vaultName =
477
+ typeof config.name === "string" && config.name.trim() ? config.name.trim() : "Wiki";
478
+ if (!directories.has("")) {
479
+ directories.set("", { dirs: new Set(), concepts: [] });
480
+ }
481
+
482
+ // Render each index
483
+ for (const [dirPath, { dirs, concepts }] of directories) {
484
+ const indexPath = dirPath ? `${dirPath}/index.md` : "index.md";
485
+ const lines: string[] = [];
486
+
487
+ if (dirPath === "") {
488
+ lines.push("---");
489
+ lines.push('okf_version: "0.2"');
490
+ lines.push("---");
491
+ lines.push("");
492
+ lines.push(`# ${escapeLabel(vaultName)}`);
493
+ } else {
494
+ const dirName = dirPath.split("/").pop()!;
495
+ lines.push(`# ${escapeLabel(dirName)}`);
496
+ }
497
+
498
+ // List directories first
499
+ if (dirs.size > 0) {
500
+ lines.push("");
501
+ lines.push("## Directories");
502
+ lines.push("");
503
+ for (const subDir of [...dirs].sort(compareCodePoint)) {
504
+ const encoded = `${encodeRelativePath(subDir)}/`;
505
+ lines.push(`- [${escapeLabel(subDir)}/](${encoded})`);
506
+ }
507
+ }
508
+
509
+ // List concepts
510
+ if (concepts.length > 0) {
511
+ lines.push("");
512
+ lines.push("## Concepts");
513
+ lines.push("");
514
+ const sorted = [...concepts].sort((a, b) => {
515
+ const aRel = dirPath ? a.id.slice(dirPath.length + 1) : a.id;
516
+ const bRel = dirPath ? b.id.slice(dirPath.length + 1) : b.id;
517
+ return compareCodePoint(aRel, bRel);
518
+ });
519
+ for (const doc of sorted) {
520
+ const relId = dirPath ? doc.id.slice(dirPath.length + 1) : doc.id;
521
+ const title =
522
+ typeof doc.frontmatter.title === "string" && doc.frontmatter.title.trim()
523
+ ? doc.frontmatter.title.trim()
524
+ : relId.split("/").pop()!;
525
+ const desc = compactDescription(doc.frontmatter.description);
526
+ const encoded = encodeRelativePath(`${relId}.md`);
527
+ const descPart = desc ? ` — ${desc}` : "";
528
+ lines.push(`- [${escapeLabel(title)}](${encoded})${descPart}`);
529
+ }
530
+ }
531
+
532
+ indexes.set(indexPath, `${lines.join("\n")}\n`);
533
+ }
534
+
535
+ return indexes;
536
+ }
247
537
 
248
- const log = buildLogMarkdown(paths);
249
- writeFileSync(join(paths.meta, "log.md"), log, "utf-8");
538
+ export interface OkfLogResult {
539
+ markdown: string;
540
+ diagnostics: KnowledgeDiagnostic[];
541
+ }
542
+
543
+ function canonicalJsonValue(value: unknown): unknown {
544
+ if (Array.isArray(value)) return value.map(canonicalJsonValue);
545
+ if (value && typeof value === "object") {
546
+ return Object.fromEntries(
547
+ Object.entries(value as Record<string, unknown>)
548
+ .sort(([a], [b]) => compareCodePoint(a, b))
549
+ .map(([key, child]) => [key, canonicalJsonValue(child)]),
550
+ );
551
+ }
552
+ return value;
553
+ }
554
+
555
+ function okfDiag(
556
+ severity: "warning" | "error",
557
+ code: KnowledgeDiagnostic["code"],
558
+ path: string,
559
+ message: string,
560
+ ): KnowledgeDiagnostic {
561
+ return { severity, code, path, message };
562
+ }
563
+
564
+ export function buildOkfLog(eventsJsonl: string, path = "meta/events.jsonl"): OkfLogResult {
565
+ const diagnostics: KnowledgeDiagnostic[] = [];
566
+ const events: Array<{
567
+ seq: number;
568
+ timestamp: string;
569
+ epoch: number;
570
+ date: string;
571
+ kind: string;
572
+ details: string;
573
+ }> = [];
574
+
575
+ const lines = eventsJsonl.split("\n");
576
+ for (let i = 0; i < lines.length; i++) {
577
+ const line = lines[i];
578
+ if (!line.trim()) continue;
579
+
580
+ let parsed: Record<string, unknown>;
581
+ try {
582
+ const candidate: unknown = JSON.parse(line);
583
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) {
584
+ throw new Error("event must be a JSON object");
585
+ }
586
+ parsed = candidate as Record<string, unknown>;
587
+ } catch {
588
+ diagnostics.push(
589
+ okfDiag("warning", "event_invalid_json", path, `Invalid JSON at line ${i + 1}`),
590
+ );
591
+ continue;
592
+ }
593
+
594
+ const rawTs = parsed.timestamp;
595
+ if (typeof rawTs !== "string" || Number.isNaN(Date.parse(rawTs))) {
596
+ diagnostics.push(
597
+ okfDiag("warning", "event_invalid_timestamp", path, `Invalid timestamp at line ${i + 1}`),
598
+ );
599
+ continue;
600
+ }
601
+
602
+ const rawKind = parsed.kind;
603
+ if (typeof rawKind !== "string" || !rawKind.trim()) {
604
+ diagnostics.push(
605
+ okfDiag("warning", "event_missing_kind", path, `Missing kind at line ${i + 1}`),
606
+ );
607
+ continue;
608
+ }
609
+
610
+ const ts = new Date(rawTs);
611
+ const date = ts.toISOString().split("T")[0];
612
+ const kind = rawKind.trim();
613
+
614
+ const detailEntries: [string, unknown][] = Object.entries(parsed).filter(
615
+ ([k]) => k !== "timestamp" && k !== "kind",
616
+ );
617
+ const details =
618
+ detailEntries.length > 0
619
+ ? JSON.stringify(canonicalJsonValue(Object.fromEntries(detailEntries)))
620
+ : "";
621
+
622
+ events.push({ seq: i, timestamp: rawTs, epoch: ts.getTime(), date, kind, details });
623
+ }
624
+
625
+ // Group by date
626
+ const byDate = new Map<string, typeof events>();
627
+ for (const ev of events) {
628
+ if (!byDate.has(ev.date)) byDate.set(ev.date, []);
629
+ byDate.get(ev.date)!.push(ev);
630
+ }
631
+
632
+ const sortedDates = [...byDate.keys()].sort((a, b) => b.localeCompare(a));
633
+
634
+ const outLines: string[] = ["# Wiki Update Log"];
635
+
636
+ for (const date of sortedDates) {
637
+ const dayEvents = byDate.get(date)!;
638
+ dayEvents.sort((a, b) => b.epoch - a.epoch || b.seq - a.seq);
639
+
640
+ outLines.push("");
641
+ outLines.push(`## ${date}`);
642
+ outLines.push("");
643
+
644
+ for (const ev of dayEvents) {
645
+ const escapedKind = ev.kind
646
+ .replace(/\\/g, "\\\\")
647
+ .replace(/\*/g, "\\*")
648
+ .replace(/_/g, "\\_")
649
+ .replace(/\[/g, "\\[")
650
+ .replace(/\]/g, "\\]")
651
+ .replace(/\s+/g, " ");
652
+
653
+ if (ev.details) {
654
+ outLines.push(`- **${escapedKind}**: ${ev.details}`);
655
+ } else {
656
+ outLines.push(`- **${escapedKind}**`);
657
+ }
658
+ }
659
+ }
660
+
661
+ return {
662
+ markdown: `${outLines.join("\n")}\n`,
663
+ diagnostics,
664
+ };
250
665
  }