@zosmaai/pi-llm-wiki 0.8.2 → 0.9.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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Non-blocking vault (re)indexing.
3
+ *
4
+ * Writing a wiki page (observe / retro / capture / ensure_page) and editing one
5
+ * by hand both require the derived metadata — `meta/registry.json`,
6
+ * `backlinks.json`, `index.md`, `log.md` — to be rebuilt, plus (optionally) the
7
+ * semantic embedding store to be refreshed. That rebuild is O(pages): it
8
+ * rescans every page in the vault. Doing it inline on the tool's / turn's
9
+ * critical path makes every write get slower as the vault grows.
10
+ *
11
+ * `scheduleReindex` moves that work off the caller's stack onto the shared
12
+ * background Runtime (#64) and coalesces a burst of writes into a single pass:
13
+ *
14
+ * - A leading micro-yield guarantees the caller (a tool's `execute`, or the
15
+ * turn_end handler) returns BEFORE the heavy rebuild runs.
16
+ * - A per-vault `dirty` flag + drain loop means writes that land while a pass
17
+ * is in flight are folded into a trailing rebuild instead of being lost —
18
+ * this also covers the async window of the embeddings refresh.
19
+ * - A per-vault `inflight` guard collapses concurrent schedule calls onto the
20
+ * same promise (single-flight), so N writes in a turn cost one rebuild.
21
+ *
22
+ * Errors are isolated by `Runtime.launchTask`; the promise never rejects. The
23
+ * embeddings step is a no-op unless an embedder is configured (#66/#67).
24
+ */
25
+
26
+ import { reindexEmbeddings, resolveEmbedder } from "./embeddings.js";
27
+ import { rebuildMetadataLight } from "./metadata.js";
28
+ import type { LaunchCtx, Runtime } from "./runtime.js";
29
+ import type { VaultPaths } from "./utils.js";
30
+
31
+ /** Promise of the current background pass, keyed by vault root. */
32
+ const inflight = new Map<string, Promise<void>>();
33
+ /** Vault roots with writes awaiting a (re)build. */
34
+ const dirty = new Set<string>();
35
+
36
+ /** Stable single-flight label for a vault's background index pass. */
37
+ export function indexLabel(root: string): string {
38
+ return `index:${root}`;
39
+ }
40
+
41
+ /**
42
+ * Schedule a non-blocking metadata rebuild (+ embeddings refresh) for a vault.
43
+ * Returns the promise of the in-flight pass so callers/tests can await drainage
44
+ * (the agent loop itself never awaits it). Safe to call on every write.
45
+ */
46
+ export function scheduleReindex(
47
+ runtime: Runtime,
48
+ ctx: LaunchCtx,
49
+ paths: VaultPaths,
50
+ ): Promise<void> {
51
+ const root = paths.root;
52
+ dirty.add(root);
53
+
54
+ const active = inflight.get(root);
55
+ if (active) return active;
56
+
57
+ const pass = runtime.launchTask(ctx, indexLabel(root), async () => {
58
+ // Yield once so the caller returns before the O(pages) rebuild runs. This
59
+ // is what makes the surrounding write non-blocking.
60
+ await Promise.resolve();
61
+ try {
62
+ // Drain: keep rebuilding until no new write arrived during the previous
63
+ // pass. The loop re-checks AFTER the awaited embeddings step, so writes
64
+ // that land during embedding are not lost.
65
+ while (dirty.has(root)) {
66
+ dirty.delete(root);
67
+ rebuildMetadataLight(paths);
68
+
69
+ // Refresh embeddings only after metadata is consistent. Stale-aware and
70
+ // a no-op unless an embedder is configured.
71
+ runtime.ensureConfig(root);
72
+ const embedder = resolveEmbedder(runtime.config);
73
+ if (embedder) await reindexEmbeddings(paths, embedder);
74
+ }
75
+ } finally {
76
+ inflight.delete(root);
77
+ }
78
+ });
79
+
80
+ inflight.set(root, pass);
81
+ return pass;
82
+ }
83
+
84
+ /** Test-only: clear coalescing state between cases. */
85
+ export function __resetIndexingState(): void {
86
+ inflight.clear();
87
+ dirty.clear();
88
+ }
@@ -0,0 +1,281 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { AgentTool } from "@mariozechner/pi-agent-core";
4
+ import type { Api, Model } from "@mariozechner/pi-ai";
5
+ import { Type } from "typebox";
6
+ import type { Static } from "typebox";
7
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
8
+ import { runSubAgent } from "./subagent.js";
9
+ import { type VaultPaths, fmtDate, slugify } from "./utils.js";
10
+
11
+ /**
12
+ * Background ingest synthesis (issue #65, part of epic #63).
13
+ *
14
+ * Moves the work the main agent used to do during `wiki_ingest` — reading a
15
+ * captured source's extracted text and writing the source page + entity /
16
+ * concept pages — onto a background sub-agent, so capturing/ingesting never
17
+ * stalls the user.
18
+ *
19
+ * Design: the sub-agent produces ONE structured `commit_synthesis` call; the
20
+ * persistence (`commitSynthesis`) is fully deterministic and unit-testable
21
+ * without an LLM. This mirrors pi-observational-memory's single-structured-tool
22
+ * pattern and keeps the file-writing logic verifiable in isolation.
23
+ */
24
+
25
+ // ── structured synthesis schema ───────────────────────────
26
+ export const CommitSynthesisSchema = Type.Object({
27
+ summary: Type.String({
28
+ minLength: 1,
29
+ description: "2-3 paragraph summary of the source's key content.",
30
+ }),
31
+ key_takeaways: Type.Array(Type.String({ minLength: 1 }), {
32
+ description: "The most important points, one per item.",
33
+ }),
34
+ entities: Type.Array(
35
+ Type.Object({
36
+ title: Type.String({
37
+ minLength: 1,
38
+ description: "Entity name (person, org, tool, product).",
39
+ }),
40
+ description: Type.String({ description: "One-line description of the entity." }),
41
+ }),
42
+ { description: "Named entities mentioned in the source." },
43
+ ),
44
+ concepts: Type.Array(
45
+ Type.Object({
46
+ title: Type.String({ minLength: 1, description: "Concept name (idea, pattern, framework)." }),
47
+ definition: Type.String({ description: "One-line definition of the concept." }),
48
+ }),
49
+ { description: "Concepts discussed in the source." },
50
+ ),
51
+ quotes: Type.Optional(
52
+ Type.Array(
53
+ Type.Object({
54
+ text: Type.String({ minLength: 1 }),
55
+ attribution: Type.Optional(Type.String()),
56
+ }),
57
+ { description: "Notable verbatim quotes." },
58
+ ),
59
+ ),
60
+ contradictions: Type.Optional(
61
+ Type.Array(Type.String({ minLength: 1 }), {
62
+ description: "Tensions/contradictions with existing wiki content, if any.",
63
+ }),
64
+ ),
65
+ });
66
+
67
+ export type SynthesisData = Static<typeof CommitSynthesisSchema>;
68
+
69
+ export interface CommitResult {
70
+ sourceId: string;
71
+ sourcePage: string;
72
+ entitiesCreated: string[];
73
+ conceptsCreated: string[];
74
+ entitiesLinked: string[];
75
+ conceptsLinked: string[];
76
+ contradictions: number;
77
+ }
78
+
79
+ // ── deterministic persistence (no LLM) ────────────────────
80
+
81
+ function buildEntityPage(
82
+ title: string,
83
+ description: string,
84
+ date: string,
85
+ sourceId: string,
86
+ ): string {
87
+ const desc = description.trim() || "One-line description.";
88
+ return `---\ntype: entity\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${desc}\n\n## Overview\n\n[Key facts]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
89
+ }
90
+
91
+ function buildConceptPage(
92
+ title: string,
93
+ definition: string,
94
+ date: string,
95
+ sourceId: string,
96
+ ): string {
97
+ const def = definition.trim() || "One-line definition.";
98
+ return `---\ntype: concept\ncreated: ${date}\nupdated: ${date}\nsources: [[[sources/${sourceId}]]]\n---\n\n# ${title}\n\n${def}\n\n## Definition\n\n[Clear explanation]\n\n## Links\n\n- [[sources/${sourceId}]]\n`;
99
+ }
100
+
101
+ /** Rebuild the source page from synthesis data, marking it ingested. */
102
+ export function buildIngestedSourcePage(
103
+ manifest: Record<string, unknown>,
104
+ data: SynthesisData,
105
+ date: string,
106
+ ): string {
107
+ const id = String(manifest.id);
108
+ const title = String(manifest.title || id);
109
+ const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
110
+ const format = String(manifest.format || "unknown");
111
+ const captured = String(manifest.captured || date);
112
+
113
+ const takeaways =
114
+ data.key_takeaways.length > 0
115
+ ? data.key_takeaways.map((t) => `- ${t.trim()}`).join("\n")
116
+ : "- [None recorded]";
117
+ const entities =
118
+ data.entities.length > 0
119
+ ? data.entities.map((e) => `- [[${slugify(e.title)}]]`).join("\n")
120
+ : "- [None]";
121
+ const concepts =
122
+ data.concepts.length > 0
123
+ ? data.concepts.map((c) => `- [[${slugify(c.title)}]]`).join("\n")
124
+ : "- [None]";
125
+ const quotes =
126
+ data.quotes && data.quotes.length > 0
127
+ ? data.quotes
128
+ .map((q) => `> ${q.text.trim()}${q.attribution ? ` — ${q.attribution}` : ""}`)
129
+ .join("\n\n")
130
+ : "> [None recorded]";
131
+ const contradictions =
132
+ data.contradictions && data.contradictions.length > 0
133
+ ? `\n## Contradictions\n\n${data.contradictions.map((c) => `⚠️ **Contradiction**: ${c.trim()}`).join("\n")}\n`
134
+ : "";
135
+
136
+ return `---\ntype: source\nformat: ${format}\nsource_id: ${id}\nraw_path: raw/sources/${id}/extracted.md\ncaptured: ${captured}\nstatus: ingested\nupdated: ${date}\n---\n\n# ${title}${url}\n\n## Summary\n\n${data.summary.trim()}\n\n## Key Takeaways\n\n${takeaways}\n\n## Entities Mentioned\n\n${entities}\n\n## Concepts Mentioned\n\n${concepts}\n\n## Notable Quotes\n\n${quotes}\n${contradictions}\n## Source Packet\n\n- **ID:** \`[[sources/${id}]]\`\n- **Extracted:** [raw/sources/${id}/extracted.md](../raw/sources/${id}/extracted.md)\n- **Manifest:** [raw/sources/${id}/manifest.json](../raw/sources/${id}/manifest.json)\n`;
137
+ }
138
+
139
+ /**
140
+ * Persist a synthesis deterministically: rewrite the source page (status →
141
+ * ingested), create missing entity/concept pages (existing pages are linked,
142
+ * never overwritten), and log the event. Pure file I/O — no LLM, no network.
143
+ */
144
+ export function commitSynthesis(
145
+ paths: VaultPaths,
146
+ sourceId: string,
147
+ manifest: Record<string, unknown>,
148
+ data: SynthesisData,
149
+ date: string = fmtDate(),
150
+ ): CommitResult {
151
+ const result: CommitResult = {
152
+ sourceId,
153
+ sourcePage: join(paths.wiki, "sources", `${sourceId}.md`),
154
+ entitiesCreated: [],
155
+ conceptsCreated: [],
156
+ entitiesLinked: [],
157
+ conceptsLinked: [],
158
+ contradictions: data.contradictions?.length ?? 0,
159
+ };
160
+
161
+ // Source page (always rewritten from skeleton → ingested).
162
+ mkdirSync(join(paths.wiki, "sources"), { recursive: true });
163
+ writeFileSync(result.sourcePage, buildIngestedSourcePage(manifest, data, date), "utf-8");
164
+
165
+ // Entity pages — create if absent, link if present.
166
+ mkdirSync(join(paths.wiki, "entities"), { recursive: true });
167
+ for (const e of data.entities) {
168
+ const slug = slugify(e.title);
169
+ if (!slug) continue;
170
+ const pagePath = join(paths.wiki, "entities", `${slug}.md`);
171
+ if (existsSync(pagePath)) {
172
+ result.entitiesLinked.push(slug);
173
+ } else {
174
+ writeFileSync(pagePath, buildEntityPage(e.title, e.description, date, sourceId), "utf-8");
175
+ result.entitiesCreated.push(slug);
176
+ }
177
+ }
178
+
179
+ // Concept pages — create if absent, link if present.
180
+ mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
181
+ for (const c of data.concepts) {
182
+ const slug = slugify(c.title);
183
+ if (!slug) continue;
184
+ const pagePath = join(paths.wiki, "concepts", `${slug}.md`);
185
+ if (existsSync(pagePath)) {
186
+ result.conceptsLinked.push(slug);
187
+ } else {
188
+ writeFileSync(pagePath, buildConceptPage(c.title, c.definition, date, sourceId), "utf-8");
189
+ result.conceptsCreated.push(slug);
190
+ }
191
+ }
192
+
193
+ appendEvent(paths, {
194
+ kind: "ingest",
195
+ source_id: sourceId,
196
+ entities_created: result.entitiesCreated.length,
197
+ concepts_created: result.conceptsCreated.length,
198
+ contradictions: result.contradictions,
199
+ background: true,
200
+ });
201
+
202
+ return result;
203
+ }
204
+
205
+ // ── sub-agent synthesis (LLM) ─────────────────────────────
206
+
207
+ export const INGEST_SYSTEM = `You are the LLM Wiki ingestion synthesizer. You turn a single captured source's extracted text into structured wiki knowledge.
208
+
209
+ Read the source content, then call \`commit_synthesis\` EXACTLY ONCE with:
210
+ - summary: a faithful 2-3 paragraph summary (no fabrication).
211
+ - key_takeaways: the most important points.
212
+ - entities: named people, organizations, tools, products actually mentioned.
213
+ - concepts: ideas, patterns, frameworks actually discussed.
214
+ - quotes: notable verbatim quotes (optional).
215
+ - contradictions: tensions with general knowledge or noted in the text (optional).
216
+
217
+ Rules:
218
+ - Never fabricate. Only include entities/concepts present in the source.
219
+ - Keep descriptions to one line.
220
+ - After calling commit_synthesis once, reply with a one-line confirmation and stop.`;
221
+
222
+ export interface RunIngestSynthesisArgs {
223
+ model: Model<Api>;
224
+ apiKey: string;
225
+ headers?: Record<string, string>;
226
+ paths: VaultPaths;
227
+ sourceId: string;
228
+ manifest: Record<string, unknown>;
229
+ extracted: string;
230
+ /** Cap on extracted chars fed to the model (avoid huge prompts). Default 24k. */
231
+ maxChars?: number;
232
+ signal?: AbortSignal;
233
+ }
234
+
235
+ /**
236
+ * Run the synthesis sub-agent for a single source, then commit + rebuild
237
+ * metadata. Returns the commit result, or undefined if the model produced no
238
+ * synthesis.
239
+ */
240
+ export async function runIngestSynthesis(
241
+ args: RunIngestSynthesisArgs,
242
+ ): Promise<CommitResult | undefined> {
243
+ const { model, apiKey, headers, paths, sourceId, manifest, extracted, maxChars, signal } = args;
244
+ const content = extracted.slice(0, maxChars ?? 24_000);
245
+ if (!content.trim()) return undefined;
246
+
247
+ let committed: CommitResult | undefined;
248
+
249
+ const commitTool: AgentTool<typeof CommitSynthesisSchema> = {
250
+ name: "commit_synthesis",
251
+ label: "Commit synthesis",
252
+ description:
253
+ "Persist the structured synthesis of this source into wiki pages. Call exactly once.",
254
+ parameters: CommitSynthesisSchema,
255
+ execute: async (_id, params) => {
256
+ committed = commitSynthesis(paths, sourceId, manifest, params);
257
+ const ack = `Committed: source page + ${committed.entitiesCreated.length} new entit${
258
+ committed.entitiesCreated.length === 1 ? "y" : "ies"
259
+ }, ${committed.conceptsCreated.length} new concept${
260
+ committed.conceptsCreated.length === 1 ? "" : "s"
261
+ }. Reply with a one-line confirmation and stop.`;
262
+ return { content: [{ type: "text", text: ack }], details: { sourceId } };
263
+ },
264
+ };
265
+
266
+ const title = String(manifest.title || sourceId);
267
+ const userPrompt = `Synthesize this captured source into wiki knowledge by calling commit_synthesis once.\n\nSOURCE: ${title} (${sourceId})\n\nEXTRACTED CONTENT:\n${content}`;
268
+
269
+ await runSubAgent({
270
+ model,
271
+ apiKey,
272
+ headers,
273
+ systemPrompt: INGEST_SYSTEM,
274
+ userPrompt,
275
+ tools: [commitTool as AgentTool],
276
+ signal,
277
+ });
278
+
279
+ if (committed) rebuildMetadataLight(paths);
280
+ return committed;
281
+ }
@@ -0,0 +1,128 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { Runtime } from "./runtime.js";
3
+ import { type TaskConfig, parseModelRef, persistTaskModel } from "./task-config.js";
4
+
5
+ /**
6
+ * Model selection surface for the wiki background lane (issue #69, epic #63).
7
+ *
8
+ * The `taskModel` config field (read by `Runtime.resolveModel`) already exists;
9
+ * this module adds the user-facing *surface* to view and set it:
10
+ * - the `/wiki-model` slash command (interactive picker + scriptable arg),
11
+ * - a status-bar label of the active task model,
12
+ * - the per-call override is wired on heavy tools (e.g. `wiki_ingest`).
13
+ *
14
+ * The default is always the session model: when no `taskModel` is configured
15
+ * and no override is passed, background work runs on the current session model.
16
+ */
17
+
18
+ /** A minimal view of a registry model (provider + id, optional display name). */
19
+ interface ModelLike {
20
+ provider: string;
21
+ id: string;
22
+ name?: string;
23
+ }
24
+
25
+ /** Words that clear the override and revert to the session model. */
26
+ const CLEAR_WORDS = new Set(["session", "default", "reset", "clear", "none", "unset"]);
27
+
28
+ /** The status-bar key for the active-model label (so we can update it in place). */
29
+ export const MODEL_STATUS_KEY = "llm-wiki-model";
30
+
31
+ /**
32
+ * Human-readable label for the active background task model. Shows the
33
+ * configured `provider/id` when set, otherwise the session model (with its id
34
+ * when known). Pure — safe to unit test and reuse for the status line.
35
+ */
36
+ export function formatActiveModelLabel(config: TaskConfig, sessionModelId?: string): string {
37
+ if (config.taskModel) return `${config.taskModel.provider}/${config.taskModel.id}`;
38
+ return sessionModelId ? `session model (${sessionModelId})` : "session model";
39
+ }
40
+
41
+ /** "provider/id" ref for a model. */
42
+ function modelRef(m: ModelLike): string {
43
+ return `${m.provider}/${m.id}`;
44
+ }
45
+
46
+ /**
47
+ * Register the `/wiki-model` slash command. Lets the user view the active
48
+ * background task model and choose another (or revert to the session model).
49
+ * The choice is persisted to project settings and applied immediately.
50
+ *
51
+ * /wiki-model → interactive picker (lists available models)
52
+ * /wiki-model provider/id → set directly (scriptable / no UI needed)
53
+ * /wiki-model session|clear → clear the override, use the session model
54
+ */
55
+ export function registerWikiModelCommand(pi: ExtensionAPI, runtime: Runtime): void {
56
+ pi.registerCommand("wiki-model", {
57
+ description:
58
+ "View or set the model used for LLM Wiki background tasks (default: session model)",
59
+ handler: async (args, ctx) => {
60
+ runtime.ensureConfig(ctx.cwd);
61
+ const sessionId = (ctx.model as ModelLike | undefined)?.id;
62
+
63
+ const apply = (model: { provider: string; id: string } | undefined): void => {
64
+ persistTaskModel(ctx.cwd, model);
65
+ runtime.config = { ...runtime.config, taskModel: model };
66
+ runtime.configLoaded = true;
67
+ const label = formatActiveModelLabel(runtime.config, sessionId);
68
+ ctx.ui.setStatus(MODEL_STATUS_KEY, `🧠 wiki model: ${label}`);
69
+ ctx.ui.notify(`LLM Wiki: background tasks now use ${label}`, "info");
70
+ };
71
+
72
+ const trimmed = args.trim();
73
+
74
+ // Explicit clear → session model.
75
+ if (trimmed && CLEAR_WORDS.has(trimmed.toLowerCase())) {
76
+ apply(undefined);
77
+ return;
78
+ }
79
+
80
+ // Direct "provider/id" set (works without UI).
81
+ if (trimmed) {
82
+ const ref = parseModelRef(trimmed);
83
+ if (!ref) {
84
+ ctx.ui.notify(
85
+ `LLM Wiki: could not parse "${trimmed}". Use provider/id (e.g. anthropic/claude-haiku) or "session".`,
86
+ "error",
87
+ );
88
+ return;
89
+ }
90
+ const found = ctx.modelRegistry.find(ref.provider, ref.id) as ModelLike | undefined;
91
+ if (!found) {
92
+ ctx.ui.notify(
93
+ `LLM Wiki: model ${ref.provider}/${ref.id} is not in the registry (run /wiki-model with no argument to pick from available models).`,
94
+ "error",
95
+ );
96
+ return;
97
+ }
98
+ apply({ provider: found.provider, id: found.id });
99
+ return;
100
+ }
101
+
102
+ // No argument: interactive picker.
103
+ const current = formatActiveModelLabel(runtime.config, sessionId);
104
+ if (!ctx.hasUI) {
105
+ ctx.ui.notify(
106
+ `LLM Wiki: active background model is ${current}. Pass provider/id to change it (no interactive UI here).`,
107
+ "info",
108
+ );
109
+ return;
110
+ }
111
+
112
+ const available = (ctx.modelRegistry.getAvailable() as ModelLike[]) ?? [];
113
+ const pool = available.length > 0 ? available : (ctx.modelRegistry.getAll() as ModelLike[]);
114
+ const sessionOption = "↩ Use session model (clear override)";
115
+ const options = [sessionOption, ...pool.map(modelRef)];
116
+
117
+ const picked = await ctx.ui.select(`Wiki background model (current: ${current})`, options);
118
+ if (picked === undefined) return; // cancelled
119
+
120
+ if (picked === sessionOption) {
121
+ apply(undefined);
122
+ return;
123
+ }
124
+ const ref = parseModelRef(picked);
125
+ if (ref) apply(ref);
126
+ },
127
+ });
128
+ }
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
+ import { scheduleReindex } from "./indexing.js";
5
6
  import { appendEvent, rebuildMetadataLight } from "./metadata.js";
7
+ import type { Runtime } from "./runtime.js";
6
8
  import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";
7
9
 
8
10
  // ─── Types ─────────────────────────────────────────────
@@ -44,7 +46,11 @@ const RELEVANCE_EMOJIS: Record<string, string> = {
44
46
  * Observations are stored in wiki/sources/ with type: source and
45
47
  * status: observation. They are searchable via wiki_recail.
46
48
  */
47
- export function saveObservation(paths: VaultPaths, input: ObservationInput): ObservationResult {
49
+ export function saveObservation(
50
+ paths: VaultPaths,
51
+ input: ObservationInput,
52
+ opts?: { rebuild?: boolean },
53
+ ): ObservationResult {
48
54
  const today = fmtDate();
49
55
  const timestamp = new Date().toISOString();
50
56
 
@@ -109,8 +115,10 @@ export function saveObservation(paths: VaultPaths, input: ObservationInput): Obs
109
115
  relevance: input.relevance,
110
116
  });
111
117
 
112
- // Rebuild metadata so the observation is immediately searchable
113
- rebuildMetadataLight(paths);
118
+ // Rebuild metadata so the observation is immediately searchable. Callers that
119
+ // background this (the wiki_observe tool) pass { rebuild: false } and schedule
120
+ // a non-blocking reindex instead.
121
+ if (opts?.rebuild !== false) rebuildMetadataLight(paths);
114
122
 
115
123
  return { slug, pagePath };
116
124
  }
@@ -137,7 +145,11 @@ export function createReminderState(): ReminderState {
137
145
  * The model calls this to record observations during a session.
138
146
  * Observations are saved to the wiki and become searchable.
139
147
  */
140
- export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderState): void {
148
+ export function registerWikiObserve(
149
+ pi: ExtensionAPI,
150
+ runtime?: Runtime,
151
+ reminderState?: ReminderState,
152
+ ): void {
141
153
  pi.registerTool({
142
154
  name: "wiki_observe",
143
155
  label: "Wiki Observe",
@@ -214,13 +226,24 @@ export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderSt
214
226
  };
215
227
  }
216
228
 
217
- const result = saveObservation(paths, {
218
- title: params.title,
219
- content: params.content,
220
- relevance: params.relevance,
221
- tags: params.tags,
222
- source_context: params.source_context,
223
- });
229
+ const result = saveObservation(
230
+ paths,
231
+ {
232
+ title: params.title,
233
+ content: params.content,
234
+ relevance: params.relevance,
235
+ tags: params.tags,
236
+ source_context: params.source_context,
237
+ },
238
+ // When a background runtime is available, write the page synchronously
239
+ // but defer the O(pages) metadata rebuild + embeddings off the tool's
240
+ // critical path. Without a runtime, fall back to the inline rebuild.
241
+ { rebuild: !runtime },
242
+ );
243
+ if (runtime) {
244
+ const launchCtx = { hasUI: ctx.hasUI, ui: ctx.ui };
245
+ scheduleReindex(runtime, launchCtx, paths);
246
+ }
224
247
 
225
248
  // Signal the reminder to stop nagging this session
226
249
  if (reminderState) {