@zosmaai/pi-llm-wiki 0.9.0 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@
6
6
  - **Personal wiki created at doubled path `~/.llm-wiki/.llm-wiki/…`**: `getPersonalWikiRoot()` returned the dot-dir itself (`~/.llm-wiki`) while `getVaultPaths()` then appended another `.llm-wiki/` segment, so the personal vault was written to `~/.llm-wiki/.llm-wiki/wiki/…`. Fixed by aligning `getPersonalWikiRoot()` with the same "root = parent of `.llm-wiki/`" contract used by project vaults. `WIKI_HOME` continues to override the parent.
7
7
 
8
8
  ### Added
9
+ - **Visible wiki activity + background/reported mutations** (Issue #77): the wiki was effectively invisible — recall was appended only to the **system prompt**, the observe/retro reminder was sent with `display: false`, and the lone user-facing cue was a static status line. The wiki now surfaces what it does, and mutating work is pushed off the agent's critical path.
10
+ - **Visible surfaces**: a one-time **session notice** (`buildSessionNotice`) announces the full loop — retrieval (recall → `wiki_search` → `read`, all synchronous because the LLM consumes their output) and capture (`wiki_observe` → `wiki_retro`, background + reported); the periodic reminder is now `display: true` and names **both** capture tools (`buildReminderText`); and the status line becomes **recall-aware** (`🧠 LLM Wiki — recalled N page(s) for this task`) when auto-recall matches.
11
+ - **Background + reported principle**: only `wiki_search` / `read` / `wiki_recall` stay synchronous. Heavy mutations — `wiki_rebuild_meta`, `wiki_reindex_embeddings`, `wiki_lint` — now dispatch to the background runtime and **report a visible completion message** instead of blocking the turn; `wiki_ingest` gained a persistent completion report alongside its toast. New `Runtime.report()` / `Runtime.launchReported()` primitives and a `dispatchReported()` tool helper (with a synchronous fallback when no runtime is available, preserving prior behavior and unit tests).
12
+ - **`notices` setting** (namespaced `llm-wiki`, default **on**): set `false` to restore the previous quiet behavior — static status line and silently-injected (`display: false`) reminders/reports.
13
+ - **Fixed** a dangling reference to a non-existent `wiki_read` tool in the links-first recall output (now points at `read`).
14
+ - **16 tests** (`test/visible-activity.test.ts`, `test/background-tools.test.ts`): `notices` parsing/defaulting, reminder + session-notice content, `Runtime.report`/`launchReported` (display gating, no-pi no-op, error isolation, null-summary skip), and `wiki_rebuild_meta` background-dispatch + report vs synchronous fallback.
9
15
  - **Model selection surface for background tasks** (Issue #69, part of epic #63): the wiki background lane (ingest synthesis, etc.) now has a user-facing surface to choose its model, defaulting to the **session model** with zero config.
10
16
  - **`/wiki-model` slash command**: run with no argument for an interactive picker (lists `modelRegistry.getAvailable()`); `/wiki-model provider/id` to set directly (scriptable, no UI needed); `/wiki-model session` (or `clear`/`default`/`reset`) to revert to the session model. The choice is **persisted** to project settings (`.pi/settings.json` under `llm-wiki.taskModel`, preserving other keys) and applied immediately, with a status-bar label of the active model.
11
17
  - **Per-call `model` override** on heavy tools (`wiki_ingest`): an optional `'provider/id'` param that overrides the configured `taskModel` for that one call. Precedence is **override > configured taskModel > session model**; each layer is applied only when the model is in the registry, and a missing/unknown layer warns (when UI is available) and falls through — so a bad ref degrades gracefully instead of failing.
@@ -8,6 +8,7 @@ import {
8
8
  registerWikiModelCommand,
9
9
  } from "./lib/model-command.js";
10
10
  import {
11
+ buildSessionNotice,
11
12
  createReminderState,
12
13
  registerObservationReminder,
13
14
  registerWikiObserve,
@@ -21,6 +22,7 @@ import {
21
22
  } from "./lib/recall.js";
22
23
  import { registerWikiRetro } from "./lib/retro.js";
23
24
  import { registerBackgroundRuntime } from "./lib/runtime.js";
25
+ import { noticesEnabled } from "./lib/task-config.js";
24
26
  import {
25
27
  registerWikiBootstrap,
26
28
  registerWikiCaptureSource,
@@ -71,9 +73,9 @@ export default function (pi: ExtensionAPI) {
71
73
  registerWikiIngest(pi, runtime);
72
74
  registerWikiEnsurePage(pi, runtime);
73
75
  registerWikiSearch(pi);
74
- registerWikiLint(pi);
76
+ registerWikiLint(pi, runtime);
75
77
  registerWikiStatus(pi);
76
- registerWikiRebuildMeta(pi);
78
+ registerWikiRebuildMeta(pi, runtime);
77
79
  registerWikiReindexEmbeddings(pi, runtime);
78
80
  registerWikiLogEvent(pi);
79
81
  registerWikiWatch(pi);
@@ -85,7 +87,12 @@ export default function (pi: ExtensionAPI) {
85
87
  registerWikiModelCommand(pi, runtime);
86
88
  const reminderState = createReminderState();
87
89
  registerWikiObserve(pi, runtime, reminderState);
88
- registerObservationReminder(pi, reminderState);
90
+ // Visible observe/retro reminder by default (issue #77); silenced when the
91
+ // user sets `llm-wiki.notices: false`. Resolver reads the live config so the
92
+ // setting takes effect without a restart.
93
+ registerObservationReminder(pi, reminderState, {
94
+ display: () => noticesEnabled(runtime.config),
95
+ });
89
96
 
90
97
  installGuardrails(pi, runtime);
91
98
 
@@ -151,6 +158,17 @@ export default function (pi: ExtensionAPI) {
151
158
  runtime.ensureConfig(process.cwd());
152
159
  const modelLabel = formatActiveModelLabel(runtime.config, (ctx.model as { id?: string })?.id);
153
160
  ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${modelLabel}`);
161
+
162
+ // One-time, user-visible session notice announcing the full wiki loop
163
+ // (issue #77). Without this, recall/observe/retro are invisible — they
164
+ // live only in the system prompt. Queued for the first prompt so it never
165
+ // interrupts; silenced when `llm-wiki.notices: false`.
166
+ if (noticesEnabled(runtime.config)) {
167
+ pi.sendMessage(
168
+ { customType: "wiki-session-notice", content: buildSessionNotice(), display: true },
169
+ { deliverAs: "nextTurn" },
170
+ );
171
+ }
154
172
  });
155
173
 
156
174
  // ─── Layered recall + topic inference hook ──────────
@@ -158,7 +176,7 @@ export default function (pi: ExtensionAPI) {
158
176
  // 1. If wiki was just auto-created, inject a directive to infer topic/mode
159
177
  // from the user's first prompt and update config via wiki_bootstrap.
160
178
  // 2. Search both personal + project vaults for relevant pages.
161
- pi.on("before_agent_start", async (event, _ctx) => {
179
+ pi.on("before_agent_start", async (event, ctx) => {
162
180
  const paths = resolveVaultPaths(process.cwd());
163
181
  if (!existsSync(join(paths.dotWiki, "config.json"))) {
164
182
  return;
@@ -227,6 +245,16 @@ Then call wiki_bootstrap with the inferred topic and mode to finalize the setup.
227
245
  if (recallContext) {
228
246
  injectedContext += `\n\n${recallContext}`;
229
247
  }
248
+ // Recall-aware status line (issue #77): make it visible that recall
249
+ // actually fired and how many pages matched. Purely a UI signal — no
250
+ // added model context. Honors the `notices` opt-out.
251
+ if (ctx?.hasUI && noticesEnabled(runtime.config)) {
252
+ const n = results.length;
253
+ ctx.ui.setStatus(
254
+ "llm-wiki",
255
+ `\u{1F9E0} LLM Wiki — recalled ${n} page${n === 1 ? "" : "s"} for this task`,
256
+ );
257
+ }
230
258
  }
231
259
  }
232
260
 
@@ -283,17 +283,64 @@ export function registerWikiObserve(
283
283
 
284
284
  // ─── Turn-End Reminder ─────────────────────────────────
285
285
 
286
+ /**
287
+ * Build the one-time, user-visible session notice (issue #77) that announces
288
+ * the full wiki loop so the user can SEE the wiki is active and what it offers:
289
+ *
290
+ * retrieval (sync, on the LLM's critical path): recall → search → read
291
+ * capture (background + reported): observe → retro
292
+ *
293
+ * Shown once per session when `notices` are enabled; silenced otherwise.
294
+ */
295
+ export function buildSessionNotice(): string {
296
+ return [
297
+ "\u{1F9E0} **LLM Wiki active.**",
298
+ "Retrieval (inline): recall runs automatically each turn — use `wiki_search` to query",
299
+ "and `read` to open pages.",
300
+ "Capture (background + reported): `wiki_observe` for timestamped notes,",
301
+ "`wiki_retro` for durable insights. All other wiki actions run in the background and",
302
+ "report when done. Silence these notices with `llm-wiki.notices: false`.",
303
+ ].join(" ");
304
+ }
305
+
306
+ /**
307
+ * Build the periodic observe/retro reminder text. Mentions BOTH capture tools
308
+ * (issue #77): `wiki_observe` for timestamped session observations and
309
+ * `wiki_retro` for distilled, durable insights at task end.
310
+ */
311
+ export function buildReminderText(): string {
312
+ return [
313
+ "**Wiki capture reminder:** If the work in this session produced non-trivial",
314
+ "decisions, findings, constraints, or completions worth preserving across sessions,",
315
+ "record them now: call `wiki_observe` for timestamped observations, or `wiki_retro`",
316
+ "to save a distilled insight. Both are searchable via `wiki_recall` and compound",
317
+ "your wiki's knowledge over time.",
318
+ "",
319
+ "One item per call. Separate distinct findings into multiple calls.",
320
+ ].join(" ");
321
+ }
322
+
286
323
  /**
287
324
  * Track observation cadence and send turn-end reminders.
288
325
  * After every N significant turns, reminds the model to call wiki_observe
289
326
  * for non-trivial findings (same pattern as memex-retro reminders).
327
+ *
328
+ * `options.display` (issue #77) controls whether the reminder is shown to the
329
+ * user (`true`, the default) or injected silently into model context only
330
+ * (`false`). Pass a resolver so the live `notices` config is read at send time.
290
331
  */
291
332
  export function registerObservationReminder(
292
333
  pi: ExtensionAPI,
293
334
  reminderState: ReminderState,
294
- options?: { turnsBetweenReminders?: number },
335
+ options?: { turnsBetweenReminders?: number; display?: boolean | (() => boolean) },
295
336
  ): void {
296
337
  const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
338
+ const resolveDisplay = (): boolean => {
339
+ const d = options?.display;
340
+ if (typeof d === "function") return d();
341
+ if (typeof d === "boolean") return d;
342
+ return true;
343
+ };
297
344
  let turnsSinceLastReminder = 0;
298
345
 
299
346
  pi.on("session_start", async () => {
@@ -315,15 +362,8 @@ export function registerObservationReminder(
315
362
  pi.sendMessage(
316
363
  {
317
364
  customType: "wiki-observe-reminder",
318
- content: [
319
- "**Wiki observation reminder:** If the work in this session produced non-trivial ",
320
- "decisions, findings, constraints, or completions worth preserving across sessions,",
321
- "call `wiki_observe` to record them. Observations are searchable via `wiki_recall`",
322
- "and compound your wiki's knowledge over time.",
323
- "",
324
- "One observation per call. Separate distinct findings into multiple calls.",
325
- ].join(" "),
326
- display: false,
365
+ content: buildReminderText(),
366
+ display: resolveDisplay(),
327
367
  },
328
368
  {
329
369
  deliverAs: "nextTurn",
@@ -781,7 +781,7 @@ export function formatRecallContext(
781
781
 
782
782
  lines.push(
783
783
  "",
784
- "Call `read` (or `wiki_read`) on the links you need to pull full content." +
784
+ "Call `read` on the links you need to pull their full content." +
785
785
  " Add new findings via wiki_ensure_page or wiki_retro.",
786
786
  "",
787
787
  );
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig } from "./task-config.js";
2
+ import { TASK_DEFAULTS, type TaskConfig, loadTaskConfig, noticesEnabled } from "./task-config.js";
3
3
 
4
4
  /**
5
5
  * Background-task runtime for the LLM Wiki (issue #64, part of #63).
@@ -53,6 +53,13 @@ export class Runtime {
53
53
  config: TaskConfig = { ...TASK_DEFAULTS };
54
54
  configLoaded = false;
55
55
 
56
+ /**
57
+ * Extension API handle, attached at registration. Used by `report()` to emit
58
+ * visible completion messages for background actions (issue #77). Optional so
59
+ * the Runtime stays unit-testable without a live `pi`.
60
+ */
61
+ pi?: ExtensionAPI;
62
+
56
63
  /** Labels of tasks currently in flight (single-flight guard per label). */
57
64
  private inFlightLabels = new Set<string>();
58
65
  /** All in-flight task promises, keyed for await-at-exit and dedupe. */
@@ -176,6 +183,44 @@ export class Runtime {
176
183
  return promise;
177
184
  }
178
185
 
186
+ /**
187
+ * Report a completed background action to the user (issue #77).
188
+ *
189
+ * Every mutating wiki action runs off the agent's critical path; this is how
190
+ * the work becomes visible. Emits a `wiki-action-report` custom message,
191
+ * shown in the UI when notices are enabled (the `notices` config, default
192
+ * on) and otherwise injected silently. Delivered as `nextTurn` so it never
193
+ * interrupts or triggers a turn. Never throws — reporting must not crash the
194
+ * background task that called it.
195
+ */
196
+ report(summary: string, opts?: { display?: boolean }): void {
197
+ if (!this.pi || !summary) return;
198
+ const display = opts?.display ?? noticesEnabled(this.config);
199
+ try {
200
+ this.pi.sendMessage(
201
+ { customType: "wiki-action-report", content: summary, display },
202
+ { deliverAs: "nextTurn" },
203
+ );
204
+ } catch {
205
+ // Reporting is best-effort; a stale/torn-down session must not propagate.
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Run a mutating action in the background and report its result (issue #77).
211
+ *
212
+ * Thin wrapper over `launchTask`: `work` performs the off-thread mutation and
213
+ * returns a one-line human summary (or null to stay silent). On success the
214
+ * summary is surfaced via `report()`. Single-flight, error-isolated, and
215
+ * awaited-at-exit exactly like `launchTask`.
216
+ */
217
+ launchReported(ctx: LaunchCtx, label: string, work: () => Promise<string | null>): Promise<void> {
218
+ return this.launchTask(ctx, label, async () => {
219
+ const summary = await work();
220
+ if (summary) this.report(summary);
221
+ });
222
+ }
223
+
179
224
  /**
180
225
  * Await all in-flight background tasks. Call at compaction / session exit so
181
226
  * background work is not lost. Never rejects — task errors are already
@@ -198,6 +243,9 @@ export class Runtime {
198
243
  */
199
244
  export function registerBackgroundRuntime(pi: ExtensionAPI): Runtime {
200
245
  const runtime = new Runtime();
246
+ // Attach the API so background tasks can emit visible completion reports
247
+ // (issue #77). Done here (not in the constructor) to keep Runtime testable.
248
+ runtime.pi = pi;
201
249
 
202
250
  pi.on("turn_start", (_event, ctx) => {
203
251
  runtime.ensureConfig(ctx.cwd);
@@ -69,10 +69,28 @@ export interface TaskConfig {
69
69
  * previews inline. Clamped to a non-negative integer.
70
70
  */
71
71
  recallLinksThreshold?: number;
72
+
73
+ /**
74
+ * Surface wiki activity in the UI (issue #77). When enabled (the default),
75
+ * the status line reflects recall hits and the periodic observe/retro
76
+ * reminder is shown to the user (`display: true`) instead of being injected
77
+ * silently. Set to `false` to restore the previous quiet behavior — a static
78
+ * status line and a hidden (`display: false`) reminder — for users who do
79
+ * not want any chat-level wiki notices.
80
+ */
81
+ notices?: boolean;
72
82
  }
73
83
 
74
84
  export const TASK_DEFAULTS: TaskConfig = {};
75
85
 
86
+ /**
87
+ * Resolve whether user-facing wiki notices are enabled (issue #77). Defaults
88
+ * to `true`; only an explicit `notices: false` disables them.
89
+ */
90
+ export function noticesEnabled(config: TaskConfig | undefined): boolean {
91
+ return config?.notices !== false;
92
+ }
93
+
76
94
  const SETTINGS_KEY = "llm-wiki";
77
95
 
78
96
  function readModelSpec(value: unknown): { provider: string; id: string } | undefined {
@@ -115,6 +133,10 @@ function readNamespacedConfig(path: string): Partial<TaskConfig> {
115
133
  if (typeof threshold === "number" && Number.isFinite(threshold)) {
116
134
  out.recallLinksThreshold = Math.max(0, Math.floor(threshold));
117
135
  }
136
+
137
+ if (typeof section.notices === "boolean") {
138
+ out.notices = section.notices;
139
+ }
118
140
  return out;
119
141
  } catch {
120
142
  return {};
@@ -44,6 +44,55 @@ function requireVault(paths: VaultPaths): { ok: true } | { ok: false; reason: st
44
44
  return { ok: true };
45
45
  }
46
46
 
47
+ type WikiToolResult = {
48
+ content: { type: "text"; text: string }[];
49
+ details: Record<string, unknown>;
50
+ isError?: boolean;
51
+ };
52
+
53
+ type ToolCtx = {
54
+ cwd?: string;
55
+ hasUI: boolean;
56
+ ui?: { notify: (message: string, type?: string) => void };
57
+ };
58
+
59
+ /**
60
+ * Dispatch a heavy mutating action to the background runtime and report its
61
+ * result (issue #77). The agent turn is never blocked: `work` runs off-thread
62
+ * and the returned one-line summary is surfaced to the user via
63
+ * `runtime.report()`. Returns an immediate, non-blocking tool result.
64
+ *
65
+ * When no runtime is available (unit tests / degraded mode), `work` runs
66
+ * synchronously and its summary is returned inline, preserving prior behavior.
67
+ * Retrieval tools (search/read/recall/status) never use this — the model needs
68
+ * their output inline.
69
+ */
70
+ async function dispatchReported(
71
+ runtime: Runtime | undefined,
72
+ ctx: ToolCtx,
73
+ opts: {
74
+ label: string;
75
+ /** Immediate, non-blocking acknowledgement shown while work runs. */
76
+ started: string;
77
+ /** Off-thread work; resolves to the human-readable completion summary. */
78
+ work: () => Promise<string>;
79
+ details?: Record<string, unknown>;
80
+ },
81
+ ): Promise<WikiToolResult> {
82
+ if (!runtime) {
83
+ const summary = await opts.work();
84
+ return {
85
+ content: [{ type: "text", text: summary }],
86
+ details: { background: false, ...opts.details },
87
+ };
88
+ }
89
+ runtime.launchReported({ hasUI: ctx.hasUI, ui: ctx.ui }, opts.label, opts.work);
90
+ return {
91
+ content: [{ type: "text", text: opts.started }],
92
+ details: { background: true, ...opts.details },
93
+ };
94
+ }
95
+
47
96
  // ─── 1. wiki_bootstrap ──────────────────────────────────
48
97
 
49
98
  export function registerWikiBootstrap(pi: ExtensionAPI): void {
@@ -381,14 +430,15 @@ export function registerWikiIngest(pi: ExtensionAPI, runtime?: Runtime): void {
381
430
  ];
382
431
  launchEmbedPages(runtime, launchCtx, paths, pageIds, `embed:ingest:${s.id}`);
383
432
  }
433
+ const summary = committed
434
+ ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
435
+ : `LLM Wiki: ${s.id} produced no synthesis`;
384
436
  if (ctx.hasUI) {
385
- ctx.ui.notify(
386
- committed
387
- ? `LLM Wiki: ingested ${s.id} → ${committed.entitiesCreated.length} entit${committed.entitiesCreated.length === 1 ? "y" : "ies"}, ${committed.conceptsCreated.length} concept${committed.conceptsCreated.length === 1 ? "" : "s"}`
388
- : `LLM Wiki: ${s.id} produced no synthesis`,
389
- committed ? "info" : "warning",
390
- );
437
+ ctx.ui.notify(summary, committed ? "info" : "warning");
391
438
  }
439
+ // Persistent, user-visible completion report (issue #77) in
440
+ // addition to the transient toast above. Notices-gated.
441
+ runtime.report(committed ? `✅ ${summary}` : `⚠️ ${summary}`);
392
442
  });
393
443
  }
394
444
  return {
@@ -672,7 +722,7 @@ export function registerWikiSearch(pi: ExtensionAPI): void {
672
722
 
673
723
  // ─── 6. wiki_lint ───────────────────────────────────────
674
724
 
675
- export function registerWikiLint(pi: ExtensionAPI): void {
725
+ export function registerWikiLint(pi: ExtensionAPI, runtime?: Runtime): void {
676
726
  pi.registerTool({
677
727
  name: "wiki_lint",
678
728
  label: "Wiki Lint",
@@ -699,138 +749,143 @@ export function registerWikiLint(pi: ExtensionAPI): void {
699
749
  };
700
750
  }
701
751
 
702
- const pages = findWikiPages(paths.wiki);
703
- const registry = buildRegistry(paths);
704
- buildBacklinks(paths, registry); // ensures backlinks.json is current
705
-
706
- const findings: string[] = [];
707
- let orphans = 0;
708
- let missingPages = 0;
709
- let contradictions = 0;
710
- const gaps: Array<{ topic: string; mentionedBy: string[] }> = [];
711
-
712
- const allPageIds = new Set(pages.map((p) => p.relative));
713
- const linkCounts: Record<string, number> = {};
714
-
715
- for (const page of pages) {
716
- const links = extractWikilinks(page.content);
717
- for (const link of links) {
718
- if (!allPageIds.has(link)) {
719
- missingPages++;
720
- findings.push(`Missing page: [[${link}]] (in [[${page.relative}]])`);
721
- const existing = gaps.find((g) => g.topic === link);
722
- if (existing) {
723
- if (!existing.mentionedBy.includes(page.relative))
724
- existing.mentionedBy.push(page.relative);
725
- } else {
726
- gaps.push({ topic: link, mentionedBy: [page.relative] });
727
- }
728
- } else {
729
- linkCounts[link] = (linkCounts[link] || 0) + 1;
730
- }
731
- }
732
- }
752
+ // Full-vault scan (+ optional auto-fix writes + reindex) is O(pages):
753
+ // run it in the background and report the health summary (issue #77).
754
+ return dispatchReported(runtime, ctx as ToolCtx, {
755
+ label: `lint:${paths.root}`,
756
+ started:
757
+ "\u{1F9F9} LLM Wiki: lint started in the background — the health report will be posted when it completes.",
758
+ work: async () => runWikiLint(paths, params.auto_fix === true),
759
+ });
760
+ },
761
+ });
762
+ }
733
763
 
734
- for (const page of pages) {
735
- if (!linkCounts[page.relative] || linkCounts[page.relative] === 0) {
736
- orphans++;
737
- findings.push(`Orphan: [[${page.relative}]] has no inbound links`);
764
+ /**
765
+ * Run the wiki health scan (issue #77 extracted it from the tool body so it can
766
+ * run off-thread via `dispatchReported`). Returns the human-readable summary.
767
+ */
768
+ function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
769
+ const pages = findWikiPages(paths.wiki);
770
+ const registry = buildRegistry(paths);
771
+ buildBacklinks(paths, registry); // ensures backlinks.json is current
772
+
773
+ const findings: string[] = [];
774
+ let orphans = 0;
775
+ let missingPages = 0;
776
+ let contradictions = 0;
777
+ const gaps: Array<{ topic: string; mentionedBy: string[] }> = [];
778
+
779
+ const allPageIds = new Set(pages.map((p) => p.relative));
780
+ const linkCounts: Record<string, number> = {};
781
+
782
+ for (const page of pages) {
783
+ const links = extractWikilinks(page.content);
784
+ for (const link of links) {
785
+ if (!allPageIds.has(link)) {
786
+ missingPages++;
787
+ findings.push(`Missing page: [[${link}]] (in [[${page.relative}]])`);
788
+ const existing = gaps.find((g) => g.topic === link);
789
+ if (existing) {
790
+ if (!existing.mentionedBy.includes(page.relative))
791
+ existing.mentionedBy.push(page.relative);
792
+ } else {
793
+ gaps.push({ topic: link, mentionedBy: [page.relative] });
738
794
  }
795
+ } else {
796
+ linkCounts[link] = (linkCounts[link] || 0) + 1;
739
797
  }
798
+ }
799
+ }
740
800
 
741
- for (const page of pages) {
742
- if (page.content.includes("⚠️ **Contradiction")) {
743
- contradictions++;
744
- findings.push(`Contradiction flagged in [[${page.relative}]]`);
745
- }
746
- }
801
+ for (const page of pages) {
802
+ if (!linkCounts[page.relative] || linkCounts[page.relative] === 0) {
803
+ orphans++;
804
+ findings.push(`Orphan: [[${page.relative}]] has no inbound links`);
805
+ }
806
+ }
747
807
 
748
- let fixesApplied = 0;
749
- if (params.auto_fix) {
750
- for (const gap of gaps) {
751
- if (gap.mentionedBy.length >= 2) {
752
- const folder = gap.topic.includes("/") ? gap.topic.split("/")[0] : "concepts";
753
- const name = gap.topic.includes("/") ? gap.topic.split("/").pop()! : gap.topic;
754
- const pagePath = join(paths.wiki, folder, `${name}.md`);
755
- if (!existsSync(pagePath)) {
756
- mkdirSync(join(paths.wiki, folder), { recursive: true });
757
- writeFileSync(
758
- pagePath,
759
- `---\ntype: concept\ncreated: ${fmtDate()}\nupdated: ${fmtDate()}\nsources: []\nstatus: stub\n---\n\n# ${name.replace(/-/g, " ")}\n\n_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy.map((r) => `[[${r}]]`).join(", ")}_\n`,
760
- "utf-8",
761
- );
762
- fixesApplied++;
763
- }
764
- }
808
+ for (const page of pages) {
809
+ if (page.content.includes("⚠️ **Contradiction")) {
810
+ contradictions++;
811
+ findings.push(`Contradiction flagged in [[${page.relative}]]`);
812
+ }
813
+ }
814
+
815
+ let fixesApplied = 0;
816
+ if (autoFix) {
817
+ for (const gap of gaps) {
818
+ if (gap.mentionedBy.length >= 2) {
819
+ const folder = gap.topic.includes("/") ? gap.topic.split("/")[0] : "concepts";
820
+ const name = gap.topic.includes("/") ? gap.topic.split("/").pop()! : gap.topic;
821
+ const pagePath = join(paths.wiki, folder, `${name}.md`);
822
+ mkdirSync(join(paths.wiki, folder), { recursive: true });
823
+ try {
824
+ // Atomic create-if-absent: the `wx` flag fails with EEXIST instead of
825
+ // overwriting, avoiding the existsSync→write TOCTOU race (CodeQL).
826
+ writeFileSync(
827
+ pagePath,
828
+ `---\ntype: concept\ncreated: ${fmtDate()}\nupdated: ${fmtDate()}\nsources: []\nstatus: stub\n---\n\n# ${name.replace(/-/g, " ")}\n\n_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy.map((r) => `[[${r}]]`).join(", ")}_\n`,
829
+ { encoding: "utf-8", flag: "wx" },
830
+ );
831
+ fixesApplied++;
832
+ } catch (err) {
833
+ // Page already exists — nothing to fix. Re-throw anything else.
834
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
765
835
  }
766
836
  }
837
+ }
838
+ }
767
839
 
768
- writeJson(join(paths.discoveries, "gaps.json"), {
769
- gaps,
770
- generated: new Date().toISOString(),
771
- });
772
-
773
- const reportLines = [
774
- "# Wiki Lint Report",
775
- `Generated: ${fmtDate()}`,
776
- "",
777
- "## Summary",
778
- `- Total pages: ${pages.length}`,
779
- `- Orphans: ${orphans}`,
780
- `- Missing pages: ${missingPages}`,
781
- `- Contradictions: ${contradictions}`,
782
- params.auto_fix ? `- Fixes applied: ${fixesApplied}` : "",
783
- "",
784
- "## Findings",
785
- findings.length > 0 ? findings.map((f) => `- ${f}`).join("\n") : "✅ No issues found!",
786
- "",
787
- ].filter(Boolean);
788
-
789
- const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`);
790
- mkdirSync(paths.outputs, { recursive: true });
791
- writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf-8");
792
-
793
- appendEvent(paths, {
794
- kind: "lint",
795
- orphans,
796
- missing_pages: missingPages,
797
- contradictions,
798
- auto_fix: params.auto_fix ?? false,
799
- });
800
-
801
- rebuildMetadataLight(paths);
840
+ writeJson(join(paths.discoveries, "gaps.json"), {
841
+ gaps,
842
+ generated: new Date().toISOString(),
843
+ });
802
844
 
803
- return {
804
- content: [
805
- {
806
- type: "text",
807
- text: [
808
- "🧹 **Lint complete**",
809
- "",
810
- `- Pages: ${pages.length}`,
811
- `- Orphans: ${orphans}`,
812
- `- Missing: ${missingPages}`,
813
- `- Contradictions: ${contradictions}`,
814
- params.auto_fix ? `- Auto-fixes: ${fixesApplied}` : "",
815
- "",
816
- `📄 Report: \`${reportPath}\``,
817
- gaps.length > 0 ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
818
- ]
819
- .filter(Boolean)
820
- .join("\n"),
821
- },
822
- ],
823
- details: {
824
- pages: pages.length,
825
- orphans,
826
- missingPages,
827
- contradictions,
828
- reportPath,
829
- gaps: gaps.length,
830
- } as Record<string, unknown>,
831
- };
832
- },
845
+ const reportLines = [
846
+ "# Wiki Lint Report",
847
+ `Generated: ${fmtDate()}`,
848
+ "",
849
+ "## Summary",
850
+ `- Total pages: ${pages.length}`,
851
+ `- Orphans: ${orphans}`,
852
+ `- Missing pages: ${missingPages}`,
853
+ `- Contradictions: ${contradictions}`,
854
+ autoFix ? `- Fixes applied: ${fixesApplied}` : "",
855
+ "",
856
+ "## Findings",
857
+ findings.length > 0 ? findings.map((f) => `- ${f}`).join("\n") : "✅ No issues found!",
858
+ "",
859
+ ].filter(Boolean);
860
+
861
+ const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`);
862
+ mkdirSync(paths.outputs, { recursive: true });
863
+ writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf-8");
864
+
865
+ appendEvent(paths, {
866
+ kind: "lint",
867
+ orphans,
868
+ missing_pages: missingPages,
869
+ contradictions,
870
+ auto_fix: autoFix,
833
871
  });
872
+
873
+ rebuildMetadataLight(paths);
874
+
875
+ return [
876
+ "🧹 **LLM Wiki lint complete**",
877
+ "",
878
+ `- Pages: ${pages.length}`,
879
+ `- Orphans: ${orphans}`,
880
+ `- Missing: ${missingPages}`,
881
+ `- Contradictions: ${contradictions}`,
882
+ autoFix ? `- Auto-fixes: ${fixesApplied}` : "",
883
+ "",
884
+ `📄 Report: \`${reportPath}\``,
885
+ gaps.length > 0 ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
886
+ ]
887
+ .filter(Boolean)
888
+ .join("\n");
834
889
  }
835
890
 
836
891
  // ─── 7. wiki_status ─────────────────────────────────────
@@ -912,7 +967,7 @@ export function registerWikiStatus(pi: ExtensionAPI): void {
912
967
 
913
968
  // ─── 8. wiki_rebuild_meta ───────────────────────────────
914
969
 
915
- export function registerWikiRebuildMeta(pi: ExtensionAPI): void {
970
+ export function registerWikiRebuildMeta(pi: ExtensionAPI, runtime?: Runtime): void {
916
971
  pi.registerTool({
917
972
  name: "wiki_rebuild_meta",
918
973
  label: "Wiki Rebuild Meta",
@@ -931,24 +986,23 @@ export function registerWikiRebuildMeta(pi: ExtensionAPI): void {
931
986
  };
932
987
  }
933
988
 
934
- rebuildMetadata(paths);
935
- appendEvent(paths, { kind: "rebuild_meta" });
936
-
937
- const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
938
- version: "1.0",
939
- last_updated: "",
940
- pages: {},
989
+ // Heavy O(pages) rebuild — dispatch off the agent's critical path and
990
+ // report on completion (issue #77).
991
+ return dispatchReported(runtime, ctx as ToolCtx, {
992
+ label: `rebuild_meta:${paths.root}`,
993
+ started:
994
+ "\u{1F9E0} LLM Wiki: metadata rebuild started in the background — the result will be reported when it completes.",
995
+ work: async () => {
996
+ rebuildMetadata(paths);
997
+ appendEvent(paths, { kind: "rebuild_meta" });
998
+ const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
999
+ version: "1.0",
1000
+ last_updated: "",
1001
+ pages: {},
1002
+ });
1003
+ return `✅ LLM Wiki: metadata rebuilt — ${Object.keys(registry.pages).length} pages indexed.`;
1004
+ },
941
1005
  });
942
-
943
- return {
944
- content: [
945
- {
946
- type: "text",
947
- text: `✅ Metadata rebuilt. ${Object.keys(registry.pages).length} pages indexed.`,
948
- },
949
- ],
950
- details: { pageCount: Object.keys(registry.pages).length } as Record<string, unknown>,
951
- };
952
1006
  },
953
1007
  });
954
1008
  }
@@ -998,28 +1052,24 @@ export function registerWikiReindexEmbeddings(pi: ExtensionAPI, runtime?: Runtim
998
1052
  };
999
1053
  }
1000
1054
 
1001
- const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
1002
- appendEvent(paths, {
1003
- kind: "reindex_embeddings",
1004
- embedded: stats.embedded,
1005
- skipped: stats.skipped,
1006
- pruned: stats.pruned,
1007
- model: embedder.model,
1055
+ // Embedding is network-bound and O(pages) run it in the background and
1056
+ // report the stats on completion (issue #77).
1057
+ return dispatchReported(runtime, ctx as ToolCtx, {
1058
+ label: `reindex_embeddings:${paths.root}`,
1059
+ started: `\u{1F9E0} LLM Wiki: embedding reindex started in the background (${embedder.model}) — stats will be reported when it completes.`,
1060
+ details: { enabled: true, model: embedder.model },
1061
+ work: async () => {
1062
+ const stats = await reindexEmbeddings(paths, embedder, { force: params.force === true });
1063
+ appendEvent(paths, {
1064
+ kind: "reindex_embeddings",
1065
+ embedded: stats.embedded,
1066
+ skipped: stats.skipped,
1067
+ pruned: stats.pruned,
1068
+ model: embedder.model,
1069
+ });
1070
+ return `✅ LLM Wiki: embeddings reindexed (${embedder.model}) — ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`;
1071
+ },
1008
1072
  });
1009
-
1010
- return {
1011
- content: [
1012
- {
1013
- type: "text",
1014
- text: `✅ Embeddings reindexed (${embedder.model}): ${stats.embedded} embedded, ${stats.skipped} fresh, ${stats.pruned} pruned.`,
1015
- },
1016
- ],
1017
- details: {
1018
- enabled: true,
1019
- ...stats,
1020
- model: embedder.model,
1021
- } as Record<string, unknown>,
1022
- };
1023
1073
  },
1024
1074
  });
1025
1075
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",