@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,1192 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { Type } from "typebox";
4
+ import { bootstrapVault } from "./bootstrap.js";
5
+ import { launchEmbedPages, reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
6
+ import { scheduleReindex } from "./indexing.js";
7
+ import { runIngestSynthesis } from "./ingest-worker.js";
8
+ import { createKnowledgeDocument, serializeKnowledgeDocument, writeKnowledgeDocumentFile, } from "./knowledge-document.js";
9
+ import { buildResolvedBacklinks } from "./knowledge-links.js";
10
+ import { repairLegacyKnowledgeDocuments } from "./legacy-repair.js";
11
+ import { appendEvent, rebuildMetadata, rebuildMetadataLight } from "./metadata.js";
12
+ import { captureFile, captureText, captureUrl } from "./source-packet.js";
13
+ import { parseModelRef } from "./task-config.js";
14
+ import { detectVaultFormat, fmtDate, getVaultPaths, readJson, resolveVaultPaths, slugify, writeJson, } from "./utils.js";
15
+ import { assertWritableVault, compareCodePoint, discoverKnowledgeDocuments, inspectVaultFormat, inspectWritableVault, } from "./vault-format.js";
16
+ import { getWikiStatus, searchRegistry } from "./wiki-service.js";
17
+ /**
18
+ * All LLM Wiki custom tools.
19
+ */
20
+ function getPaths(cwd) {
21
+ return resolveVaultPaths(cwd ?? process.cwd());
22
+ }
23
+ function requireVault(paths) {
24
+ if (detectVaultFormat(paths.root) === "none") {
25
+ return { ok: false, reason: `No wiki found at ${paths.root}. Run wiki_bootstrap first.` };
26
+ }
27
+ return { ok: true };
28
+ }
29
+ /**
30
+ * Dispatch a heavy mutating action to the background runtime and report its
31
+ * result (issue #77). The agent turn is never blocked: `work` runs off-thread
32
+ * and the returned one-line summary is surfaced to the user via
33
+ * `runtime.report()`. Returns an immediate, non-blocking tool result.
34
+ *
35
+ * When no runtime is available (unit tests / degraded mode), `work` runs
36
+ * synchronously and its summary is returned inline, preserving prior behavior.
37
+ * Retrieval tools (search/read/recall/status) never use this — the model needs
38
+ * their output inline.
39
+ */
40
+ async function dispatchReported(runtime, ctx, opts) {
41
+ if (!runtime) {
42
+ const summary = await opts.work();
43
+ return {
44
+ content: [{ type: "text", text: summary }],
45
+ details: { background: false, ...opts.details },
46
+ };
47
+ }
48
+ runtime.launchReported({ hasUI: ctx.hasUI, ui: ctx.ui }, opts.label, opts.work);
49
+ return {
50
+ content: [{ type: "text", text: opts.started }],
51
+ details: { background: true, ...opts.details },
52
+ };
53
+ }
54
+ // ─── 1. wiki_bootstrap ──────────────────────────────────
55
+ export function registerWikiBootstrap(pi) {
56
+ pi.registerTool({
57
+ name: "wiki_bootstrap",
58
+ label: "Wiki Bootstrap",
59
+ description: "Initialize a new LLM Wiki vault with the 4-layer architecture. " +
60
+ "Creates config, templates, schema, and metadata scaffolding.",
61
+ promptSnippet: "Initialize a new LLM Wiki vault",
62
+ promptGuidelines: ["Use wiki_bootstrap when the user wants to start a new wiki."],
63
+ parameters: Type.Object({
64
+ topic: Type.String({ description: "Main topic of the wiki" }),
65
+ mode: Type.Optional(Type.String({ description: "personal or company (default: personal)" })),
66
+ root: Type.Optional(Type.String({ description: "Root directory (default: current directory)" })),
67
+ }),
68
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
69
+ const root = params.root ?? ctx.cwd ?? process.cwd();
70
+ const mode = params.mode || "personal";
71
+ const paths = getVaultPaths(root);
72
+ const result = bootstrapVault(paths, { topic: params.topic, mode });
73
+ if (!result.ok) {
74
+ return {
75
+ content: [{ type: "text", text: `Wiki vault error: ${result.diagnostics[0].message}` }],
76
+ details: {
77
+ error: result.diagnostics[0].code,
78
+ diagnostics: result.diagnostics,
79
+ },
80
+ isError: true,
81
+ };
82
+ }
83
+ if (!result.projection.ok) {
84
+ return {
85
+ content: [
86
+ {
87
+ type: "text",
88
+ text: `✅ Wiki bootstrapped but projection rebuild had issues: ${result.projection.diagnostics
89
+ .map((diagnostic) => `${diagnostic.code}: ${diagnostic.message}`)
90
+ .join("; ")}`,
91
+ },
92
+ ],
93
+ details: {
94
+ root,
95
+ mode,
96
+ topic: params.topic,
97
+ diagnostics: result.projection.diagnostics,
98
+ },
99
+ };
100
+ }
101
+ return {
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: [
106
+ `✅ Wiki bootstrapped at \`${paths.root}\``,
107
+ "**Scope:** project-local",
108
+ "",
109
+ "**Structure:**",
110
+ "- .llm-wiki/raw/sources/ — immutable source packets",
111
+ "- .llm-wiki/wiki/ — editable knowledge pages",
112
+ "- .llm-wiki/meta/ — auto-generated metadata",
113
+ "- .llm-wiki/ — config and templates",
114
+ "- .llm-wiki/WIKI_SCHEMA.md — operating rules",
115
+ "",
116
+ "Next: Use wiki_capture_source to add your first source.",
117
+ ].join("\n"),
118
+ },
119
+ ],
120
+ details: { root, mode, topic: params.topic },
121
+ };
122
+ },
123
+ });
124
+ }
125
+ // ─── 2. wiki_capture_source ─────────────────────────────
126
+ export function registerWikiCaptureSource(pi, runtime) {
127
+ pi.registerTool({
128
+ name: "wiki_capture_source",
129
+ label: "Wiki Capture Source",
130
+ description: "Capture a URL, local file, or pasted text into an immutable source packet and skeleton source page.",
131
+ promptSnippet: "Capture a source into the wiki as an immutable packet",
132
+ promptGuidelines: [
133
+ "Use wiki_capture_source when the user provides a URL, file, or text to capture.",
134
+ "After capture, read the extracted text and update the skeleton source page.",
135
+ ],
136
+ parameters: Type.Object({
137
+ url: Type.Optional(Type.String({ description: "URL to capture" })),
138
+ file_path: Type.Optional(Type.String({ description: "Local file path to capture" })),
139
+ text: Type.Optional(Type.String({ description: "Pasted text content" })),
140
+ title: Type.Optional(Type.String({ description: "Title for pasted text" })),
141
+ }),
142
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
143
+ const paths = getPaths(ctx.cwd);
144
+ const vaultCheck = inspectWritableVault(paths);
145
+ if (!vaultCheck.ok) {
146
+ return {
147
+ content: [
148
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
149
+ ],
150
+ details: {
151
+ error: vaultCheck.diagnostics[0].code,
152
+ diagnostics: vaultCheck.diagnostics,
153
+ },
154
+ isError: true,
155
+ };
156
+ }
157
+ let result;
158
+ if (params.url) {
159
+ result = await captureUrl(pi, paths, params.url, signal);
160
+ }
161
+ else if (params.file_path) {
162
+ result = await captureFile(pi, paths, params.file_path, signal);
163
+ }
164
+ else if (params.text) {
165
+ result = captureText(paths, params.text, params.title);
166
+ }
167
+ else {
168
+ return {
169
+ content: [{ type: "text", text: "❌ Provide one of: url, file_path, or text" }],
170
+ details: { error: "missing_source" },
171
+ isError: true,
172
+ };
173
+ }
174
+ if (runtime) {
175
+ scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
176
+ }
177
+ else {
178
+ rebuildMetadataLight(paths);
179
+ }
180
+ return {
181
+ content: [
182
+ {
183
+ type: "text",
184
+ text: [
185
+ `✅ Captured source **${result.sourceId}**`,
186
+ "",
187
+ `- Packet: \`${result.packetPath}\``,
188
+ `- Skeleton page: \`${result.sourcePagePath}\``,
189
+ "",
190
+ "**Next:** Read the extracted text and update the source page with a proper summary, entities, and concepts.",
191
+ ].join("\n"),
192
+ },
193
+ ],
194
+ details: {
195
+ sourceId: result.sourceId,
196
+ packetPath: result.packetPath,
197
+ sourcePagePath: result.sourcePagePath,
198
+ extractedPreview: result.extracted.slice(0, 300),
199
+ },
200
+ };
201
+ },
202
+ });
203
+ }
204
+ // ─── 3. wiki_ingest ─────────────────────────────────────
205
+ export function registerWikiIngest(pi, runtime) {
206
+ pi.registerTool({
207
+ name: "wiki_ingest",
208
+ label: "Wiki Ingest",
209
+ description: "Process uningested source packets. By default synthesis runs in the background (non-blocking) on the configured task model; pass background=false to return extracted content for the main agent to synthesize itself.",
210
+ promptSnippet: "Ingest source packets (background synthesis by default)",
211
+ promptGuidelines: [
212
+ "Use wiki_ingest when the user wants to process captured sources.",
213
+ "By default ingestion runs in the BACKGROUND — you'll get a notification, not extracted content. Do NOT synthesize those sources yourself.",
214
+ "If the tool returns extracted content (background unavailable, or background=false), then read each source's extracted.md, update its source page, create entity/concept pages, and cross-reference.",
215
+ "The extension auto-updates metadata — you do NOT need to edit meta/ files.",
216
+ ],
217
+ parameters: Type.Object({
218
+ source_id: Type.Optional(Type.String({ description: "Specific source ID to ingest. Leave empty for all new." })),
219
+ batch_size: Type.Optional(Type.Number({ description: "Max sources to process (default: 3, max: 5)", default: 3 })),
220
+ background: Type.Optional(Type.Boolean({
221
+ description: "Synthesize in the background without blocking (default: true). Set false to return extracted content for the main agent to synthesize.",
222
+ default: true,
223
+ })),
224
+ model: Type.Optional(Type.String({
225
+ description: "Per-call model override as 'provider/id' (e.g. anthropic/claude-haiku). Overrides the configured wiki taskModel for this call; defaults to the configured/session model.",
226
+ })),
227
+ }),
228
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
229
+ const paths = getPaths(ctx.cwd);
230
+ const vaultCheck = inspectWritableVault(paths);
231
+ if (!vaultCheck.ok) {
232
+ return {
233
+ content: [
234
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
235
+ ],
236
+ details: {
237
+ error: vaultCheck.diagnostics[0].code,
238
+ diagnostics: vaultCheck.diagnostics,
239
+ },
240
+ isError: true,
241
+ };
242
+ }
243
+ const batchSize = Math.min(params.batch_size ?? 3, 5);
244
+ if (!existsSync(paths.rawSources)) {
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: "No raw/sources/ directory. Capture sources first with wiki_capture_source.",
250
+ },
251
+ ],
252
+ details: { error: "no_sources" },
253
+ };
254
+ }
255
+ const packets = readdirSync(paths.rawSources)
256
+ .filter((d) => d.startsWith("SRC-"))
257
+ .sort();
258
+ const registry = readJson(join(paths.meta, "registry.json"), {
259
+ version: "1.0",
260
+ last_updated: "",
261
+ pages: {},
262
+ });
263
+ const ingested = new Set();
264
+ for (const [id, entry] of Object.entries(registry.pages)) {
265
+ if (entry.type === "source" && entry.status !== "skeleton") {
266
+ const base = id.split("/").pop();
267
+ if (base)
268
+ ingested.add(base);
269
+ }
270
+ }
271
+ let toProcess = packets.filter((p) => !ingested.has(p));
272
+ if (params.source_id) {
273
+ if (!toProcess.includes(params.source_id) && !packets.includes(params.source_id)) {
274
+ return {
275
+ content: [
276
+ { type: "text", text: `Source ${params.source_id} not found or already ingested.` },
277
+ ],
278
+ details: { source_id: params.source_id, status: "not_found" },
279
+ };
280
+ }
281
+ toProcess = [params.source_id];
282
+ }
283
+ const batch = toProcess.slice(0, batchSize);
284
+ if (batch.length === 0) {
285
+ return {
286
+ content: [
287
+ {
288
+ type: "text",
289
+ text: "✅ All sources ingested. Use wiki_capture_source to add new ones.",
290
+ },
291
+ ],
292
+ details: { ingested: ingested.size, total: packets.length },
293
+ };
294
+ }
295
+ const sources = batch.map((id) => {
296
+ const extractedPath = join(paths.rawSources, id, "extracted.md");
297
+ const manifestPath = join(paths.rawSources, id, "manifest.json");
298
+ const extracted = existsSync(extractedPath) ? readFileSync(extractedPath, "utf-8") : "";
299
+ const manifest = readJson(manifestPath, {});
300
+ // Vault-relative path used in tool messages so the read tool can open
301
+ // the file from the vault root (fix #101: agent previously got
302
+ // "raw/sources/..." and failed on new-layout vaults).
303
+ const relRaw = relative(paths.root, paths.rawSources);
304
+ return { id, extracted, manifest, relRaw };
305
+ });
306
+ // ── Background synthesis (issue #65) ──────────────────
307
+ // Default path: dispatch each source to a background sub-agent so the
308
+ // main agent is not blocked. Falls back to the synchronous return below
309
+ // when no runtime/model is available (resolveModel ok:false).
310
+ const wantBackground = params.background !== false;
311
+ if (wantBackground && runtime) {
312
+ runtime.ensureConfig(ctx.cwd);
313
+ // Per-call model override (issue #69): 'provider/id' beats the
314
+ // configured taskModel; a malformed/unknown ref degrades to the
315
+ // configured/session model inside resolveModel.
316
+ const override = params.model ? parseModelRef(params.model) : undefined;
317
+ const resolved = await runtime.resolveModel(ctx, override);
318
+ if (resolved.ok) {
319
+ const launchCtx = { hasUI: ctx.hasUI, ui: ctx.ui };
320
+ for (const s of sources) {
321
+ runtime.launchTask(launchCtx, `ingest:${s.id}`, async () => {
322
+ const committed = await runIngestSynthesis({
323
+ model: resolved.model,
324
+ apiKey: resolved.apiKey,
325
+ headers: resolved.headers,
326
+ paths,
327
+ sourceId: s.id,
328
+ manifest: s.manifest,
329
+ extracted: s.extracted,
330
+ });
331
+ if (committed) {
332
+ // Background semantic embeddings (#66): embed the pages this
333
+ // ingest just wrote, off-thread. No-op when unconfigured.
334
+ const pageIds = [
335
+ `sources/${committed.sourceId}`,
336
+ ...committed.entitiesCreated.map((e) => `entities/${e}`),
337
+ ...committed.entitiesLinked.map((e) => `entities/${e}`),
338
+ ...committed.conceptsCreated.map((c) => `concepts/${c}`),
339
+ ...committed.conceptsLinked.map((c) => `concepts/${c}`),
340
+ ];
341
+ launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`);
342
+ }
343
+ const summary = committed
344
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
345
+ : `LLM Wiki: ${s.id} produced no synthesis`;
346
+ if (ctx.hasUI) {
347
+ ctx.ui.notify(summary, committed ? "info" : "warning");
348
+ }
349
+ // Persistent, user-visible completion report (issue #77) in
350
+ // addition to the transient toast above. Notices-gated.
351
+ runtime.report(committed ? `✅ ${summary}` : `⚠️ ${summary}`);
352
+ });
353
+ }
354
+ return {
355
+ content: [
356
+ {
357
+ type: "text",
358
+ text: [
359
+ `🔄 **Ingesting ${sources.length} source(s) in the background** (${toProcess.length - batch.length} remaining).`,
360
+ "",
361
+ ...sources.map((s) => `- **${s.id}**: ${s.manifest.title || s.id}`),
362
+ "",
363
+ "Synthesis runs on the configured task model without blocking. You'll be notified as each source completes — do NOT synthesize these yourself.",
364
+ ].join("\n"),
365
+ },
366
+ ],
367
+ details: {
368
+ background: true,
369
+ dispatched: sources.map((s) => s.id),
370
+ remaining: toProcess.length - batch.length,
371
+ },
372
+ };
373
+ }
374
+ }
375
+ return {
376
+ content: [
377
+ {
378
+ type: "text",
379
+ text: [
380
+ `📥 **${batch.length} source(s) ready** (${toProcess.length - batch.length} remaining)`,
381
+ "",
382
+ ...sources.map((s) => [
383
+ `- **${s.id}**: ${s.manifest.title || s.id}`,
384
+ ` - Extracted: ${s.extracted.length} chars`,
385
+ ` - Read: \`${s.relRaw}/${s.id}/extracted.md\``,
386
+ ].join("\n")),
387
+ "",
388
+ "**Next steps for each source:**",
389
+ "1. Read extracted.md",
390
+ "2. Update the skeleton source page in wiki/sources/",
391
+ "3. Create/update entity pages in wiki/entities/",
392
+ "4. Create/update concept pages in wiki/concepts/",
393
+ "5. Add [[wikilinks]] cross-references",
394
+ "6. Flag contradictions",
395
+ "",
396
+ "The extension will auto-update metadata when you're done.",
397
+ ].join("\n"),
398
+ },
399
+ ],
400
+ details: {
401
+ batch: sources.map((s) => s.id),
402
+ remaining: toProcess.length - batch.length,
403
+ },
404
+ };
405
+ },
406
+ });
407
+ }
408
+ // ─── 4. wiki_ensure_page ────────────────────────────────
409
+ export function registerWikiEnsurePage(pi, runtime) {
410
+ pi.registerTool({
411
+ name: "wiki_ensure_page",
412
+ label: "Wiki Ensure Page",
413
+ description: "Resolve or safely create a canonical wiki page. Returns the page path.",
414
+ promptSnippet: "Create a canonical wiki page if it doesn't exist",
415
+ promptGuidelines: [
416
+ "Use wiki_ensure_page before creating pages to avoid duplicates.",
417
+ "Search existing pages first with wiki_search.",
418
+ ],
419
+ parameters: Type.Object({
420
+ type: Type.String({
421
+ description: "Page type: entity | concept | synthesis | analysis | requirement | skill | case",
422
+ }),
423
+ title: Type.String({ description: "Page title" }),
424
+ content: Type.Optional(Type.String({ description: "Optional initial content (otherwise uses template)" })),
425
+ }),
426
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
427
+ const paths = getPaths(ctx.cwd);
428
+ const vaultCheck = inspectWritableVault(paths);
429
+ if (!vaultCheck.ok) {
430
+ return {
431
+ content: [
432
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
433
+ ],
434
+ details: {
435
+ error: vaultCheck.diagnostics[0].code,
436
+ diagnostics: vaultCheck.diagnostics,
437
+ },
438
+ isError: true,
439
+ };
440
+ }
441
+ const type = params.type;
442
+ const slug = slugify(params.title);
443
+ const folderMap = {
444
+ entity: "entities",
445
+ concept: "concepts",
446
+ synthesis: "syntheses",
447
+ analysis: "analyses",
448
+ requirement: "requirements",
449
+ skill: "skills",
450
+ case: "cases",
451
+ };
452
+ const folder = folderMap[type] || "concepts";
453
+ const pagePath = join(paths.wiki, folder, `${slug}.md`);
454
+ if (existsSync(pagePath)) {
455
+ return {
456
+ content: [{ type: "text", text: `✅ Page already exists: \`${pagePath}\`` }],
457
+ details: { path: pagePath, created: false },
458
+ };
459
+ }
460
+ const today = fmtDate();
461
+ const body = params.content ?? buildPageBody(type, params.title);
462
+ const doc = createKnowledgeDocument(`${folder}/${slug}.md`, {
463
+ type,
464
+ title: params.title,
465
+ created: today,
466
+ updated: today,
467
+ }, body);
468
+ mkdirSync(join(paths.wiki, folder), { recursive: true });
469
+ writeKnowledgeDocumentFile(pagePath, doc);
470
+ appendEvent(paths, {
471
+ kind: "ensure_page",
472
+ page_type: type,
473
+ title: params.title,
474
+ path: `${folder}/${slug}`,
475
+ });
476
+ // Register the new page so retrieval + embeddings can see it. When a
477
+ // background runtime is available, the rebuild + embeddings run off the
478
+ // tool's critical path; otherwise fall back to a synchronous rebuild.
479
+ if (runtime) {
480
+ scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
481
+ }
482
+ else {
483
+ rebuildMetadataLight(paths);
484
+ }
485
+ return {
486
+ content: [{ type: "text", text: `✅ Created ${type} page: \`${pagePath}\`` }],
487
+ details: { path: pagePath, created: true },
488
+ };
489
+ },
490
+ });
491
+ }
492
+ function buildPageBody(type, title) {
493
+ if (type === "entity") {
494
+ return `# ${title}
495
+
496
+ One-line description.
497
+
498
+ ## Overview
499
+
500
+ [Key facts]
501
+
502
+ ## Links
503
+
504
+ - [related-page](/concepts/related-page.md)`;
505
+ }
506
+ if (type === "concept") {
507
+ return `# ${title}
508
+
509
+ One-line definition.
510
+
511
+ ## Definition
512
+
513
+ [Clear explanation]
514
+
515
+ ## Links
516
+
517
+ - [related-page](/concepts/related-page.md)`;
518
+ }
519
+ if (type === "synthesis") {
520
+ return `# ${title}
521
+
522
+ Cross-cutting analysis.
523
+
524
+ ## Question
525
+
526
+ [What drove this?]
527
+
528
+ ## Links
529
+
530
+ - [related-page](/concepts/related-page.md)`;
531
+ }
532
+ if (type === "analysis") {
533
+ return `# ${title}
534
+
535
+ Durable answer from a query.
536
+
537
+ ## Question
538
+
539
+ [Original question]
540
+
541
+ ## Links
542
+
543
+ - [related-page](/concepts/related-page.md)`;
544
+ }
545
+ if (type === "skill") {
546
+ return `# ${title}
547
+
548
+ _One-line summary of the reusable pattern this skill captures._
549
+
550
+ ## When to Use
551
+
552
+ [Trigger conditions — when this pattern applies]
553
+
554
+ ## Procedure
555
+
556
+ 1. [Step 1]
557
+ 2. [Step 2]
558
+
559
+ ## Pitfalls
560
+
561
+ - [Known failure mode or caveat]
562
+
563
+ ## Distilled From
564
+
565
+ _Trajectories this skill was generalized from._
566
+
567
+ - [trajectories/TRJ-...](/trajectories/TRJ-....md)`;
568
+ }
569
+ if (type === "case") {
570
+ return `# ${title}
571
+
572
+ _One-line summary of the specific task this case records._
573
+
574
+ ## Task
575
+
576
+ [What was requested]
577
+
578
+ ## Approach
579
+
580
+ [How the agent solved it — key steps and decisions]
581
+
582
+ ## Outcome
583
+
584
+ [Result, and anything worth reusing or avoiding next time]
585
+
586
+ ## Trajectory
587
+
588
+ - [trajectories/TRJ-...](/trajectories/TRJ-....md) — captured tool-call run`;
589
+ }
590
+ if (type === "requirement") {
591
+ return `# ${title}
592
+
593
+ ## Description
594
+
595
+ [Clear description of what this requirement entails]
596
+
597
+ ## Acceptance Criteria
598
+
599
+ - [ ] [Criterion 1]
600
+ - [ ] [Criterion 2]
601
+
602
+ ## Dependencies
603
+
604
+ _Pages this requirement depends on._
605
+
606
+ ## Implementation Notes
607
+
608
+ [Optional notes]
609
+
610
+ ## Sources
611
+
612
+ - [sources/SRC-...](/sources/SRC-....md) — original concept capture`;
613
+ }
614
+ return `# ${title}
615
+
616
+ [Description to be filled]
617
+
618
+ ## Links
619
+
620
+ - [related-page](/concepts/related-page.md)`;
621
+ }
622
+ // ─── 5. wiki_search ─────────────────────────────────────
623
+ export function registerWikiSearch(pi) {
624
+ pi.registerTool({
625
+ name: "wiki_search",
626
+ label: "Wiki Search",
627
+ description: "Search the wiki registry for pages matching a query.",
628
+ promptSnippet: "Search the wiki registry for pages",
629
+ promptGuidelines: ["Use wiki_search to find existing pages before creating duplicates."],
630
+ parameters: Type.Object({
631
+ query: Type.String({ description: "Search term" }),
632
+ type: Type.Optional(Type.String({ description: "Filter by page type" })),
633
+ }),
634
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
635
+ const paths = getPaths(ctx.cwd);
636
+ const result = searchRegistry(paths, params.query, params.type);
637
+ if (result.matches.length === 0) {
638
+ return {
639
+ content: [{ type: "text", text: `No pages found for "${params.query}"` }],
640
+ details: { query: params.query, matches: [], diagnostics: result.diagnostics },
641
+ };
642
+ }
643
+ return {
644
+ content: [
645
+ {
646
+ type: "text",
647
+ text: [
648
+ `🔍 **${result.matches.length} result(s)** for "${params.query}":`,
649
+ "",
650
+ ...result.matches.map((m) => `- [[${m.id}]] — *${m.type}* — ${m.title}`),
651
+ ].join("\n"),
652
+ },
653
+ ],
654
+ details: {
655
+ query: params.query,
656
+ matches: result.matches,
657
+ diagnostics: result.diagnostics,
658
+ },
659
+ };
660
+ },
661
+ });
662
+ }
663
+ // ─── 6. wiki_lint ───────────────────────────────────────
664
+ export function registerWikiLint(pi, runtime) {
665
+ pi.registerTool({
666
+ name: "wiki_lint",
667
+ label: "Wiki Lint",
668
+ description: "Health check the wiki. Scans for orphans, missing pages, contradictions, gaps. Optionally auto-fixes.",
669
+ promptSnippet: "Lint the wiki for health issues",
670
+ promptGuidelines: [
671
+ "Use wiki_lint when the user asks to check wiki health.",
672
+ "Contradictions always need human review.",
673
+ ],
674
+ parameters: Type.Object({
675
+ auto_fix: Type.Optional(Type.Boolean({ description: "Auto-fix orphans and missing pages", default: false })),
676
+ }),
677
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
678
+ const paths = getPaths(ctx.cwd);
679
+ const vaultCheck = inspectWritableVault(paths);
680
+ if (!vaultCheck.ok) {
681
+ return {
682
+ content: [
683
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
684
+ ],
685
+ details: {
686
+ error: vaultCheck.diagnostics[0].code,
687
+ diagnostics: vaultCheck.diagnostics,
688
+ },
689
+ isError: true,
690
+ };
691
+ }
692
+ // Full-vault scan (+ optional auto-fix writes + reindex) is O(pages):
693
+ // run it in the background and report the health summary (issue #77).
694
+ return dispatchReported(runtime, ctx, {
695
+ label: `lint:${paths.root}`,
696
+ started: "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted when it completes.",
697
+ work: async () => runWikiLint(paths, params.auto_fix === true),
698
+ });
699
+ },
700
+ });
701
+ }
702
+ /**
703
+ * Run the wiki health scan (issue #77 extracted it from the tool body so it can
704
+ * run off-thread via `dispatchReported`). Returns the human-readable summary.
705
+ */
706
+ function runWikiLint(paths, autoFix) {
707
+ assertWritableVault(paths);
708
+ let repair;
709
+ if (autoFix) {
710
+ let projection = rebuildMetadata(paths);
711
+ repair = !projection.ok ? repairLegacyKnowledgeDocuments(paths) : undefined;
712
+ if (repair?.repaired)
713
+ projection = rebuildMetadata(paths);
714
+ if (!projection.ok) {
715
+ return [
716
+ "# Wiki Lint Report",
717
+ "",
718
+ repair?.repaired ? `Legacy pages repaired: ${repair.repaired}` : "",
719
+ repair?.manifestPath ? `Repair manifest: ${repair.manifestPath}` : "",
720
+ "Projection-blocking diagnostics:",
721
+ ...projection.diagnostics.map((diagnostic) => `- ${diagnostic.code}: ${diagnostic.path}: ${diagnostic.message}`),
722
+ ]
723
+ .filter(Boolean)
724
+ .join("\n");
725
+ }
726
+ }
727
+ else {
728
+ const vault = inspectVaultFormat(paths);
729
+ const audit = discoverKnowledgeDocuments(paths);
730
+ const diagnostics = [...vault.diagnostics, ...audit.diagnostics];
731
+ if (vault.blocking || audit.blocking) {
732
+ return [
733
+ "# Wiki Lint Report",
734
+ "",
735
+ "Projection-blocking diagnostics:",
736
+ ...diagnostics.map((diagnostic) => `- ${diagnostic.code}: ${diagnostic.path}: ${diagnostic.message}`),
737
+ ].join("\n");
738
+ }
739
+ }
740
+ const discovery = discoverKnowledgeDocuments(paths);
741
+ const pages = discovery.documents;
742
+ const knownIds = new Set(pages.map((page) => page.id));
743
+ const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
744
+ const gapSources = new Map();
745
+ const findings = [];
746
+ let missingPages = 0;
747
+ let contradictions = 0;
748
+ for (const page of pages) {
749
+ const resolved = buildResolvedBacklinks(page.id, page.body, knownIds);
750
+ for (const target of resolved.targets)
751
+ inbound[target]++;
752
+ for (const unresolved of resolved.unresolved) {
753
+ const sources = gapSources.get(unresolved.target) ?? new Set();
754
+ sources.add(page.id);
755
+ gapSources.set(unresolved.target, sources);
756
+ missingPages++;
757
+ findings.push(`Missing page: ${unresolved.target} (in ${page.id})`);
758
+ }
759
+ }
760
+ let orphans = 0;
761
+ for (const page of pages) {
762
+ if (inbound[page.id] === 0) {
763
+ orphans++;
764
+ findings.push(`Orphan: ${page.id} has no inbound links`);
765
+ }
766
+ if (page.body.includes("⚠️ **Contradiction")) {
767
+ contradictions++;
768
+ findings.push(`Contradiction flagged in ${page.id}`);
769
+ }
770
+ }
771
+ const gaps = [...gapSources.entries()]
772
+ .map(([topic, sources]) => ({ topic, mentionedBy: [...sources].sort(compareCodePoint) }))
773
+ .sort((left, right) => compareCodePoint(left.topic, right.topic));
774
+ let fixesApplied = 0;
775
+ if (autoFix) {
776
+ for (const gap of gaps) {
777
+ if (gap.mentionedBy.length < 2)
778
+ continue;
779
+ const parts = gap.topic.split("/");
780
+ const name = parts.length === 1
781
+ ? parts[0]
782
+ : parts.length === 2 && parts[0] === "concepts"
783
+ ? parts[1]
784
+ : "";
785
+ if (!name || slugify(name) !== name)
786
+ continue;
787
+ const pagePath = join(paths.wiki, "concepts", `${name}.md`);
788
+ mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
789
+ const document = createKnowledgeDocument(`concepts/${name}.md`, {
790
+ type: "concept",
791
+ title: name.replace(/-/g, " "),
792
+ created: fmtDate(),
793
+ updated: fmtDate(),
794
+ status: "stub",
795
+ }, `_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy
796
+ .map((source) => `[${source}](/${source}.md)`)
797
+ .join(", ")}_`);
798
+ try {
799
+ writeFileSync(pagePath, serializeKnowledgeDocument(document), {
800
+ encoding: "utf8",
801
+ flag: "wx",
802
+ });
803
+ fixesApplied++;
804
+ }
805
+ catch (error) {
806
+ if (error.code !== "EEXIST")
807
+ throw error;
808
+ }
809
+ }
810
+ }
811
+ const reportLines = [
812
+ "# Wiki Lint Report",
813
+ `Generated: ${fmtDate()}`,
814
+ "",
815
+ "## Summary",
816
+ `- Total pages: ${pages.length}`,
817
+ `- Orphans: ${orphans}`,
818
+ `- Missing pages: ${missingPages}`,
819
+ `- Contradictions: ${contradictions}`,
820
+ autoFix ? `- Missing-page fixes applied: ${fixesApplied}` : "",
821
+ repair?.repaired ? `- Legacy pages repaired: ${repair.repaired}` : "",
822
+ repair?.manifestPath ? `- Repair manifest: ${repair.manifestPath}` : "",
823
+ "",
824
+ "## Findings",
825
+ findings.length ? findings.map((finding) => `- ${finding}`).join("\n") : "✅ No issues found!",
826
+ "",
827
+ ].filter(Boolean);
828
+ const reportPath = autoFix ? join(paths.outputs, `lint-${fmtDate()}.md`) : undefined;
829
+ if (autoFix && reportPath) {
830
+ writeJson(join(paths.discoveries, "gaps.json"), {
831
+ gaps,
832
+ generated: new Date().toISOString(),
833
+ });
834
+ mkdirSync(paths.outputs, { recursive: true });
835
+ writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf8");
836
+ appendEvent(paths, {
837
+ kind: "lint",
838
+ orphans,
839
+ missing_pages: missingPages,
840
+ contradictions,
841
+ auto_fix: true,
842
+ legacy_pages_repaired: repair?.repaired ?? 0,
843
+ });
844
+ rebuildMetadataLight(paths);
845
+ }
846
+ return [
847
+ "🧹 **LLM Wiki lint complete**",
848
+ "",
849
+ `- Pages: ${pages.length}`,
850
+ `- Orphans: ${orphans}`,
851
+ `- Missing: ${missingPages}`,
852
+ `- Contradictions: ${contradictions}`,
853
+ autoFix ? `- Missing-page fixes: ${fixesApplied}` : "",
854
+ repair?.repaired ? `- Legacy pages repaired: ${repair.repaired}` : "",
855
+ "",
856
+ reportPath ? `📄 Report: \`${reportPath}\`` : "",
857
+ repair?.manifestPath ? `🛟 Repair manifest: \`${repair.manifestPath}\`` : "",
858
+ gaps.length ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
859
+ ]
860
+ .filter(Boolean)
861
+ .join("\n");
862
+ }
863
+ // ─── 7. wiki_status ─────────────────────────────────────
864
+ export function registerWikiStatus(pi) {
865
+ pi.registerTool({
866
+ name: "wiki_status",
867
+ label: "Wiki Status",
868
+ description: "Report wiki health and stats instantly from generated registry.",
869
+ promptSnippet: "Report wiki health and stats",
870
+ promptGuidelines: ["Use wiki_status for a quick overview."],
871
+ parameters: Type.Object({}),
872
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
873
+ const paths = getPaths(ctx.cwd);
874
+ const vaultCheck = requireVault(paths);
875
+ if (!vaultCheck.ok) {
876
+ return {
877
+ content: [{ type: "text", text: vaultCheck.reason }],
878
+ details: { error: vaultCheck.reason },
879
+ isError: true,
880
+ };
881
+ }
882
+ const status = getWikiStatus(paths);
883
+ const config = readJson(join(paths.dotWiki, "config.json"), {});
884
+ const backlinks = readJson(join(paths.meta, "backlinks.json"), {});
885
+ const orphanCount = Object.entries(backlinks).filter(([, inbound]) => inbound.length === 0).length;
886
+ const gaps = readJson(join(paths.discoveries, "gaps.json"), {
887
+ gaps: [],
888
+ });
889
+ const health = status.totalPages === 0 ? "🔴 Empty" : orphanCount > 5 ? "⚠️ Warning" : "✅ Good";
890
+ const diagLines = status.blockingDiagnostics.length > 0
891
+ ? [
892
+ "",
893
+ "⚠️ Blocking diagnostics:",
894
+ ...status.blockingDiagnostics.map((d) => ` - ${d.code}: ${d.message}`),
895
+ ]
896
+ : [];
897
+ const lines = [
898
+ "📊 LLM Wiki Status",
899
+ "══════════════════",
900
+ `Topic: ${config.topic || "Unknown"}`,
901
+ `Mode: ${config.mode || "personal"}`,
902
+ `Knowledge format: ${status.knowledgeFormat}`,
903
+ `Pages: ${status.totalPages}`,
904
+ ...Object.entries(status.byType).map(([t, c]) => ` - ${t}s: ${c}`),
905
+ `Orphans: ${orphanCount}`,
906
+ `Gaps: ${gaps.gaps?.length || 0}`,
907
+ `Health: ${health}`,
908
+ `Last updated: ${status.lastUpdated || "Never"}`,
909
+ ...diagLines,
910
+ ];
911
+ return {
912
+ content: [{ type: "text", text: lines.join("\n") }],
913
+ details: {
914
+ topic: config.topic,
915
+ mode: config.mode,
916
+ knowledgeFormat: status.knowledgeFormat,
917
+ totalPages: status.totalPages,
918
+ byType: status.byType,
919
+ orphans: orphanCount,
920
+ gaps: gaps.gaps?.length || 0,
921
+ health,
922
+ blockingDiagnostics: status.blockingDiagnostics,
923
+ },
924
+ };
925
+ },
926
+ });
927
+ }
928
+ // ─── 8. wiki_rebuild_meta ───────────────────────────────
929
+ export function registerWikiRebuildMeta(pi, runtime) {
930
+ pi.registerTool({
931
+ name: "wiki_rebuild_meta",
932
+ label: "Wiki Rebuild Meta",
933
+ description: "Force a full metadata rebuild (registry, backlinks, index, log).",
934
+ promptSnippet: "Rebuild all wiki metadata",
935
+ promptGuidelines: ["Use wiki_rebuild_meta if metadata seems out of sync."],
936
+ parameters: Type.Object({}),
937
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
938
+ const paths = getPaths(ctx.cwd);
939
+ const vaultCheck = inspectWritableVault(paths);
940
+ if (!vaultCheck.ok) {
941
+ return {
942
+ content: [
943
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
944
+ ],
945
+ details: {
946
+ error: vaultCheck.diagnostics[0].code,
947
+ diagnostics: vaultCheck.diagnostics,
948
+ },
949
+ isError: true,
950
+ };
951
+ }
952
+ // Heavy O(pages) rebuild — dispatch off the agent's critical path and
953
+ // report on completion (issue #77).
954
+ return dispatchReported(runtime, ctx, {
955
+ label: `rebuild_meta:${paths.root}`,
956
+ started: "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported when it completes.",
957
+ work: async () => {
958
+ const result = rebuildMetadata(paths);
959
+ // No rebuild_meta event — rebuild is a projection, not an authoritative mutation
960
+ if (!result.ok) {
961
+ return `⚠️ LLM Wiki: rebuild had issues — ${result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("; ")}`;
962
+ }
963
+ const registry = readJson(join(paths.meta, "registry.json"), {
964
+ version: "1.0",
965
+ last_updated: "",
966
+ pages: {},
967
+ });
968
+ return `✅ LLM Wiki: metadata rebuilt — ${Object.keys(registry.pages).length} pages indexed.`;
969
+ },
970
+ });
971
+ },
972
+ });
973
+ }
974
+ // ─── 9. wiki_log_event ──────────────────────────────────
975
+ export function registerWikiReindexEmbeddings(pi, runtime) {
976
+ pi.registerTool({
977
+ name: "wiki_reindex_embeddings",
978
+ label: "Wiki Reindex Embeddings",
979
+ description: "Backfill / refresh semantic embeddings for the vault. Embeds pages that " +
980
+ "are new or stale (content changed); pass force to re-embed everything. " +
981
+ "No-op when no embedding provider is configured.",
982
+ promptSnippet: "Backfill semantic embeddings for the wiki",
983
+ promptGuidelines: [
984
+ "Use wiki_reindex_embeddings to embed an existing vault or refresh stale embeddings.",
985
+ "Embeddings are optional: this no-ops cleanly when no embedding provider is configured.",
986
+ ],
987
+ parameters: Type.Object({
988
+ force: Type.Optional(Type.Boolean({ description: "Re-embed every page, ignoring staleness (default: false)" })),
989
+ }),
990
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
991
+ const paths = getPaths(ctx.cwd);
992
+ const vaultCheck = inspectWritableVault(paths);
993
+ if (!vaultCheck.ok) {
994
+ return {
995
+ content: [
996
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
997
+ ],
998
+ details: {
999
+ error: vaultCheck.diagnostics[0].code,
1000
+ diagnostics: vaultCheck.diagnostics,
1001
+ },
1002
+ isError: true,
1003
+ };
1004
+ }
1005
+ if (runtime)
1006
+ runtime.ensureConfig(ctx.cwd ?? paths.root);
1007
+ const embedder = runtime ? resolveEmbedder(runtime.config) : undefined;
1008
+ if (!embedder) {
1009
+ return {
1010
+ content: [
1011
+ {
1012
+ type: "text",
1013
+ text: 'ℹ️ No embedding provider configured — semantic embeddings are disabled. Set `llm-wiki.embeddingProvider` (e.g. "openai") in settings to enable.',
1014
+ },
1015
+ ],
1016
+ details: { enabled: false },
1017
+ };
1018
+ }
1019
+ // Embedding is network-bound and O(pages) — run it in the background and
1020
+ // report the stats on completion (issue #77).
1021
+ return dispatchReported(runtime, ctx, {
1022
+ label: `reindex_embeddings:${paths.root}`,
1023
+ started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported when it completes.`,
1024
+ details: { enabled: true, model: embedder.model },
1025
+ work: async () => {
1026
+ const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
1027
+ appendEvent(paths, {
1028
+ kind: "reindex_embeddings",
1029
+ embedded: stats.embedded,
1030
+ skipped: stats.skipped,
1031
+ pruned: stats.pruned,
1032
+ model: embedder.model,
1033
+ });
1034
+ return `✅ LLM Wiki: embeddings reindexed (${embedder.model}) — ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`;
1035
+ },
1036
+ });
1037
+ },
1038
+ });
1039
+ }
1040
+ export function registerWikiLogEvent(pi) {
1041
+ pi.registerTool({
1042
+ name: "wiki_log_event",
1043
+ label: "Wiki Log Event",
1044
+ description: "Append a structured event to meta/events.jsonl and regenerate meta/log.md.",
1045
+ promptSnippet: "Log an event to the wiki activity log",
1046
+ promptGuidelines: ["Use wiki_log_event to record significant actions manually."],
1047
+ parameters: Type.Object({
1048
+ kind: Type.String({ description: "Event kind (e.g., ingest, query, decision)" }),
1049
+ details: Type.Optional(Type.Object({}, { description: "Additional event fields" })),
1050
+ }),
1051
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1052
+ const paths = getPaths(ctx.cwd);
1053
+ const vaultCheck = inspectWritableVault(paths);
1054
+ if (!vaultCheck.ok) {
1055
+ return {
1056
+ content: [
1057
+ { type: "text", text: `Wiki vault error: ${vaultCheck.diagnostics[0].message}` },
1058
+ ],
1059
+ details: {
1060
+ error: vaultCheck.diagnostics[0].code,
1061
+ diagnostics: vaultCheck.diagnostics,
1062
+ },
1063
+ isError: true,
1064
+ };
1065
+ }
1066
+ const kind = typeof params.kind === "string" ? params.kind.trim() : "";
1067
+ if (!kind) {
1068
+ return {
1069
+ content: [{ type: "text", text: "Event kind must be a non-empty string" }],
1070
+ details: { error: "event_missing_kind" },
1071
+ isError: true,
1072
+ };
1073
+ }
1074
+ const details = params.details ?? {};
1075
+ if (Object.hasOwn(details, "kind") || Object.hasOwn(details, "timestamp")) {
1076
+ return {
1077
+ content: [{ type: "text", text: "Event details cannot override kind or timestamp" }],
1078
+ details: { error: "event_reserved_field" },
1079
+ isError: true,
1080
+ };
1081
+ }
1082
+ appendEvent(paths, { kind, ...details });
1083
+ // Regenerate projections
1084
+ rebuildMetadata(paths);
1085
+ return {
1086
+ content: [{ type: "text", text: `✅ Event logged: ${kind}` }],
1087
+ details: { kind },
1088
+ };
1089
+ },
1090
+ });
1091
+ }
1092
+ // ─── 10. wiki_watch ─────────────────────────────────────
1093
+ export function registerWikiWatch(pi) {
1094
+ pi.registerTool({
1095
+ name: "wiki_watch",
1096
+ label: "Wiki Watch",
1097
+ description: "Print a ready-to-paste crontab line for scheduling automatic wiki updates (discover → ingest → lint). Does NOT schedule anything itself — it returns the command for the user to install.",
1098
+ promptSnippet: "Schedule auto-updates for the wiki",
1099
+ promptGuidelines: [
1100
+ "Use wiki_watch when the user wants the wiki to stay current automatically.",
1101
+ "wiki_watch only PRINTS a cron line — surface the output to the user verbatim so they can install it. Do not claim the schedule is active.",
1102
+ ],
1103
+ parameters: Type.Object({
1104
+ interval: Type.String({ description: "daily, weekly, hourly, or stop" }),
1105
+ }),
1106
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
1107
+ if (params.interval === "stop") {
1108
+ return {
1109
+ content: [
1110
+ {
1111
+ type: "text",
1112
+ text: [
1113
+ "🛑 To stop wiki auto-updates, remove the cron line you installed earlier:",
1114
+ "",
1115
+ "```bash",
1116
+ "crontab -e # then delete the line tagged '# llm-wiki-autoupdate'",
1117
+ "```",
1118
+ "",
1119
+ "Or list current jobs to confirm:",
1120
+ "",
1121
+ "```bash",
1122
+ "crontab -l | grep llm-wiki-autoupdate",
1123
+ "```",
1124
+ ].join("\n"),
1125
+ },
1126
+ ],
1127
+ details: { action: "stop_instructions" },
1128
+ };
1129
+ }
1130
+ // 5-field POSIX crontab expressions (min hour dom month dow).
1131
+ const intervals = {
1132
+ daily: { cron: "0 8 * * *", label: "Daily at 8:00 AM" },
1133
+ weekly: { cron: "0 9 * * 1", label: "Weekly on Monday at 9:00 AM" },
1134
+ hourly: { cron: "0 * * * *", label: "Every hour" },
1135
+ };
1136
+ const config = intervals[params.interval];
1137
+ if (!config) {
1138
+ return {
1139
+ content: [
1140
+ {
1141
+ type: "text",
1142
+ text: `❌ Unknown interval: "${params.interval}". Use: daily, weekly, hourly, or stop.`,
1143
+ },
1144
+ ],
1145
+ details: { error: "bad_interval" },
1146
+ isError: true,
1147
+ };
1148
+ }
1149
+ // Robustness for global crontab environments:
1150
+ // * `/bin/bash -lc` runs a LOGIN shell that sources /etc/profile +
1151
+ // ~/.profile / ~/.bash_profile, so npm-global / bun / nvm PATH
1152
+ // additions are imported — cron's default PATH is only
1153
+ // `/usr/bin:/bin` and would not find `pi`.
1154
+ // * `mkdir -p` makes the log dir self-healing for users with only
1155
+ // a project vault (no `~/.llm-wiki/` yet).
1156
+ // * All `$HOME` references are double-quoted to survive paths with spaces.
1157
+ // * `# llm-wiki-autoupdate` tags the line so the user can find and
1158
+ // remove it via `crontab -e` later (see `interval=stop`).
1159
+ const cronLine = `${config.cron} /bin/bash -lc 'mkdir -p "$HOME/.llm-wiki" && pi -p "/wiki-run" >> "$HOME/.llm-wiki/cron.log" 2>&1' # llm-wiki-autoupdate`;
1160
+ return {
1161
+ content: [
1162
+ {
1163
+ type: "text",
1164
+ text: [
1165
+ `⏰ To set up ${config.label} wiki updates, add this line to your crontab.`,
1166
+ "**This tool only prints the line — it does not install it.**",
1167
+ "",
1168
+ "```bash",
1169
+ "crontab -e",
1170
+ "```",
1171
+ "",
1172
+ "Then append:",
1173
+ "",
1174
+ "```cron",
1175
+ cronLine,
1176
+ "```",
1177
+ "",
1178
+ `The line uses \`/bin/bash -lc\` so your shell profile (and the \`pi\` binary on npm-global / bun PATH) is loaded. Output goes to \`~/.llm-wiki/cron.log\`. If your system has no \`/bin/bash\`, replace with \`/bin/sh -c\` and ensure \`pi\` is in cron's PATH yourself.`,
1179
+ ].join("\n"),
1180
+ },
1181
+ ],
1182
+ details: {
1183
+ interval: params.interval,
1184
+ cronSchedule: config.cron,
1185
+ label: config.label,
1186
+ cronLine,
1187
+ installed: false,
1188
+ },
1189
+ };
1190
+ },
1191
+ });
1192
+ }