@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 +6 -0
- package/extensions/llm-wiki/index.ts +32 -4
- package/extensions/llm-wiki/lib/observation.ts +50 -10
- package/extensions/llm-wiki/lib/recall.ts +1 -1
- package/extensions/llm-wiki/lib/runtime.ts +49 -1
- package/extensions/llm-wiki/lib/task-config.ts +22 -0
- package/extensions/llm-wiki/lib/tools.ts +218 -168
- package/package.json +1 -1
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
|
-
|
|
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,
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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.
|
|
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",
|