@zosmaai/pi-llm-wiki 0.8.2 → 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.
@@ -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) {
@@ -260,17 +283,64 @@ export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderSt
260
283
 
261
284
  // ─── Turn-End Reminder ─────────────────────────────────
262
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
+
263
323
  /**
264
324
  * Track observation cadence and send turn-end reminders.
265
325
  * After every N significant turns, reminds the model to call wiki_observe
266
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.
267
331
  */
268
332
  export function registerObservationReminder(
269
333
  pi: ExtensionAPI,
270
334
  reminderState: ReminderState,
271
- options?: { turnsBetweenReminders?: number },
335
+ options?: { turnsBetweenReminders?: number; display?: boolean | (() => boolean) },
272
336
  ): void {
273
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
+ };
274
344
  let turnsSinceLastReminder = 0;
275
345
 
276
346
  pi.on("session_start", async () => {
@@ -292,15 +362,8 @@ export function registerObservationReminder(
292
362
  pi.sendMessage(
293
363
  {
294
364
  customType: "wiki-observe-reminder",
295
- content: [
296
- "**Wiki observation reminder:** If the work in this session produced non-trivial ",
297
- "decisions, findings, constraints, or completions worth preserving across sessions,",
298
- "call `wiki_observe` to record them. Observations are searchable via `wiki_recall`",
299
- "and compound your wiki's knowledge over time.",
300
- "",
301
- "One observation per call. Separate distinct findings into multiple calls.",
302
- ].join(" "),
303
- display: false,
365
+ content: buildReminderText(),
366
+ display: resolveDisplay(),
304
367
  },
305
368
  {
306
369
  deliverAs: "nextTurn",
@@ -2,7 +2,17 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
+ import {
6
+ type Embedder,
7
+ type EmbeddingStore,
8
+ cosineSimilarity,
9
+ normalizeVector,
10
+ readEmbeddingStore,
11
+ resolveEmbedder,
12
+ } from "./embeddings.js";
5
13
  import type { Registry } from "./metadata.js";
14
+ import type { Runtime } from "./runtime.js";
15
+ import type { TaskConfig } from "./task-config.js";
6
16
  import {
7
17
  type VaultPaths,
8
18
  getPersonalWikiPaths,
@@ -37,8 +47,57 @@ type Scored = {
37
47
  score: number;
38
48
  pagePath: string;
39
49
  bestChunkPreview: string;
50
+ /** Cosine similarity to the query vector (0 when no semantic context). */
51
+ semCos: number;
40
52
  };
41
53
 
54
+ // ─── Hybrid (lexical + semantic) ranking ─────────────────
55
+
56
+ /**
57
+ * Semantic re-ranking context for a single search (issue #67, epic #63).
58
+ *
59
+ * The query vector is computed ONCE per query (a single, cached embedding
60
+ * lookup) in the async wrapper; the per-vault page vectors are read from the
61
+ * precomputed `meta/embeddings.json` sidecar (written at #66 write-time). The
62
+ * actual ranking is pure vector math — there is NO embedding/LLM call in
63
+ * `searchWiki` itself, so the lexical hot path stays synchronous and offline.
64
+ */
65
+ export interface SemanticContext {
66
+ /** L2-normalized embedding of the query string. */
67
+ queryVector: number[];
68
+ /** Blend weight for the semantic signal (0 = lexical only, 1 = max boost). */
69
+ weight: number;
70
+ }
71
+
72
+ /** Default blend weight when none is configured. */
73
+ export const DEFAULT_SEMANTIC_WEIGHT = 0.5;
74
+
75
+ /**
76
+ * Lexical points a perfect (cosine = 1) semantic match is worth at full
77
+ * weight. Chosen so a strong paraphrase match (cosine ≳ 0.84) at the default
78
+ * weight (0.5) clears the auto-injection threshold (minScore = 5) on its own,
79
+ * while weak/incidental similarity stays below it.
80
+ */
81
+ export const SEMANTIC_SCALE = 12;
82
+
83
+ /**
84
+ * Minimum cosine for a page with NO lexical match to even be considered a
85
+ * semantic candidate. Keeps the candidate set bounded (near-orthogonal pages
86
+ * are ignored) instead of pulling in the entire embedded vault.
87
+ */
88
+ export const SEMANTIC_MIN_COSINE = 0.2;
89
+
90
+ /**
91
+ * Blend a lexical score with a cosine similarity. The lexical score keeps its
92
+ * original absolute scale (so `minScore` semantics survive); the semantic
93
+ * signal is added as a bounded, weighted boost on a comparable scale. With no
94
+ * semantic signal (cosine ≤ 0) this is the identity on the lexical score, so
95
+ * the pure-lexical path is preserved exactly.
96
+ */
97
+ export function fuseScores(lexical: number, cosine: number, weight: number): number {
98
+ return lexical + weight * SEMANTIC_SCALE * Math.max(cosine, 0);
99
+ }
100
+
42
101
  /**
43
102
  * Normalize text for recall matching.
44
103
  *
@@ -314,6 +373,7 @@ export function searchWiki(
314
373
  query: string,
315
374
  maxResults = 5,
316
375
  minScore = 0,
376
+ semantic?: SemanticContext,
317
377
  ): RecallResult[] {
318
378
  const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
319
379
  version: "1.0",
@@ -324,6 +384,12 @@ export function searchWiki(
324
384
  const terms = queryTerms(query);
325
385
  if (terms.length === 0) return [];
326
386
 
387
+ // Read this vault's precomputed embedding sidecar (synchronous, offline).
388
+ // Missing/empty sidecar => no semantic signal => pure lexical, by construction.
389
+ const embeddingStore: EmbeddingStore | undefined = semantic
390
+ ? readEmbeddingStore(paths)
391
+ : undefined;
392
+
327
393
  const scored: Scored[] = [];
328
394
 
329
395
  for (const [id, entry] of Object.entries(registry.pages)) {
@@ -385,13 +451,26 @@ export function searchWiki(
385
451
  // Add best chunk score to total page score
386
452
  score += bestChunkScore;
387
453
 
388
- if (score > 0) {
454
+ // Semantic candidacy: a page with no lexical match can still qualify if its
455
+ // precomputed vector is sufficiently close to the query vector. The boost
456
+ // itself is applied AFTER pseudo-relevance feedback so PRF stays lexical.
457
+ let semCos = 0;
458
+ if (semantic && embeddingStore) {
459
+ const vec = embeddingStore.entries[id]?.vector;
460
+ if (vec && vec.length === semantic.queryVector.length) {
461
+ semCos = cosineSimilarity(semantic.queryVector, vec);
462
+ }
463
+ }
464
+ const semEligible = semCos >= SEMANTIC_MIN_COSINE;
465
+
466
+ if (score > 0 || semEligible) {
389
467
  scored.push({
390
468
  id,
391
469
  entry,
392
470
  score,
393
471
  pagePath,
394
472
  bestChunkPreview: bestChunkContent ? chunkPreview(bestChunkHeading, bestChunkContent) : "",
473
+ semCos,
395
474
  });
396
475
  }
397
476
  }
@@ -427,7 +506,18 @@ export function searchWiki(
427
506
  }
428
507
  }
429
508
 
430
- // Re-sort after expansion scoring
509
+ // ── Semantic fusion ─────────────────────────────────
510
+ // Blend the precomputed cosine similarity into the (lexical + PRF) score.
511
+ // Applied last so PRF expansion remains purely lexical and so a strongly
512
+ // paraphrase-relevant page that lexical missed can clear `minScore`. With no
513
+ // semantic context every boost is 0, leaving the lexical ranking untouched.
514
+ if (semantic) {
515
+ for (const item of scored) {
516
+ item.score = fuseScores(item.score, item.semCos, semantic.weight);
517
+ }
518
+ }
519
+
520
+ // Re-sort after expansion + semantic scoring
431
521
  scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
432
522
  const top = scored.filter((s) => s.score >= minScore).slice(0, maxResults);
433
523
 
@@ -463,9 +553,10 @@ export function searchWikiLayered(
463
553
  maxResults = 5,
464
554
  minScore = 0,
465
555
  includePersonal = true,
556
+ semantic?: SemanticContext,
466
557
  ): RecallResult[] {
467
558
  // Search primary vault
468
- const primaryResults = searchWiki(primaryPaths, query, maxResults, minScore);
559
+ const primaryResults = searchWiki(primaryPaths, query, maxResults, minScore, semantic);
469
560
 
470
561
  // If primary is already the personal vault, no layered search needed
471
562
  if (isPersonalVault(primaryPaths)) return primaryResults;
@@ -475,7 +566,7 @@ export function searchWikiLayered(
475
566
  if (includePersonal) {
476
567
  const personalPaths = getPersonalWikiPaths();
477
568
  if (existsSync(join(personalPaths.dotWiki, "config.json"))) {
478
- personalResults = searchWiki(personalPaths, query, maxResults, minScore);
569
+ personalResults = searchWiki(personalPaths, query, maxResults, minScore, semantic);
479
570
  }
480
571
  }
481
572
 
@@ -498,15 +589,206 @@ export function searchWikiLayered(
498
589
  return merged.slice(0, maxResults);
499
590
  }
500
591
 
592
+ // ─── Async hybrid entry point (the single, cached query embedding) ───
593
+
594
+ /**
595
+ * Cache of query string → normalized embedding vector. The query embedding is
596
+ * the ONLY embedding call in the recall hot path; caching collapses repeated
597
+ * recalls of the same query within a session (e.g. auto-injection + an explicit
598
+ * wiki_recall) into a single network call, satisfying the #67 "single cached
599
+ * query-embedding lookup" bound.
600
+ */
601
+ const queryEmbeddingCache = new Map<string, number[]>();
602
+ const QUERY_CACHE_MAX = 256;
603
+
604
+ function queryCacheKey(model: string, query: string): string {
605
+ return `${model}\u0000${normalizeText(query)}`;
606
+ }
607
+
608
+ /** Test-only: reset the module-level query-embedding cache. */
609
+ export function __clearQueryEmbeddingCache(): void {
610
+ queryEmbeddingCache.clear();
611
+ }
612
+
613
+ /** True if a vault has at least one stored embedding vector. */
614
+ function storeHasEntries(paths: VaultPaths): boolean {
615
+ return Object.keys(readEmbeddingStore(paths).entries).length > 0;
616
+ }
617
+
618
+ /**
619
+ * Embed the query string once (cached), returning a normalized vector, or
620
+ * `undefined` when no embedder is configured or the call yields nothing.
621
+ */
622
+ async function embedQuery(embedder: Embedder, query: string): Promise<number[] | undefined> {
623
+ const key = queryCacheKey(embedder.model, query);
624
+ const cached = queryEmbeddingCache.get(key);
625
+ if (cached) return cached;
626
+
627
+ const [raw] = await embedder.embed([query]);
628
+ if (!raw || raw.length === 0) return undefined;
629
+ const vec = normalizeVector(raw);
630
+
631
+ if (queryEmbeddingCache.size >= QUERY_CACHE_MAX) {
632
+ const oldest = queryEmbeddingCache.keys().next().value;
633
+ if (oldest !== undefined) queryEmbeddingCache.delete(oldest);
634
+ }
635
+ queryEmbeddingCache.set(key, vec);
636
+ return vec;
637
+ }
638
+
639
+ /**
640
+ * Hybrid layered recall: lexical scoring blended with semantic cosine ranking.
641
+ *
642
+ * Design (issue #67): page vectors are precomputed at write time (#66); the
643
+ * ONLY per-query embedding work is a single, cached lookup of the (short) query
644
+ * string. If no vault has embeddings, the query embedding is skipped entirely
645
+ * and this degrades to exactly `searchWikiLayered` (pure lexical, zero network).
646
+ * Likewise when no embedder is configured. `opts.embedder` is an injection seam
647
+ * for tests (mirrors `embedPages`) so unit tests never touch the network.
648
+ */
649
+ export async function searchWikiHybrid(
650
+ primaryPaths: VaultPaths,
651
+ query: string,
652
+ maxResults = 5,
653
+ minScore = 0,
654
+ includePersonal = true,
655
+ opts: { config?: TaskConfig; embedder?: Embedder } = {},
656
+ ): Promise<RecallResult[]> {
657
+ // Pure-lexical fast path: no semantic signal anywhere => no embedding call.
658
+ let anyEmbeddings = storeHasEntries(primaryPaths);
659
+ if (!anyEmbeddings && includePersonal && !isPersonalVault(primaryPaths)) {
660
+ const personalPaths = getPersonalWikiPaths();
661
+ if (existsSync(join(personalPaths.dotWiki, "config.json"))) {
662
+ anyEmbeddings = storeHasEntries(personalPaths);
663
+ }
664
+ }
665
+ if (!anyEmbeddings) {
666
+ return searchWikiLayered(primaryPaths, query, maxResults, minScore, includePersonal);
667
+ }
668
+
669
+ const embedder = opts.embedder ?? (opts.config ? resolveEmbedder(opts.config) : undefined);
670
+ if (!embedder) {
671
+ // Embeddings exist but no embedder configured to embed the query: fall back
672
+ // to pure lexical rather than guess. (Degrades gracefully.)
673
+ return searchWikiLayered(primaryPaths, query, maxResults, minScore, includePersonal);
674
+ }
675
+
676
+ let semantic: SemanticContext | undefined;
677
+ try {
678
+ const queryVector = await embedQuery(embedder, query);
679
+ if (queryVector) {
680
+ const weight = opts.config?.semanticWeight ?? DEFAULT_SEMANTIC_WEIGHT;
681
+ semantic = { queryVector, weight };
682
+ }
683
+ } catch {
684
+ // Network/embedding failure must never break recall — fall back to lexical.
685
+ semantic = undefined;
686
+ }
687
+
688
+ return searchWikiLayered(primaryPaths, query, maxResults, minScore, includePersonal, semantic);
689
+ }
690
+
691
+ /**
692
+ * Default page-count gate for two-stage (links-first) recall (issue #68).
693
+ * When a vault's registered page count exceeds this, recall returns ranked
694
+ * links (expand on demand via `read`) instead of inline content previews.
695
+ */
696
+ export const DEFAULT_RECALL_LINKS_THRESHOLD = 50;
697
+
698
+ /** Max characters of the 1-line snippet shown beside a link in links-first mode. */
699
+ const LINKS_SNIPPET_MAX = 80;
700
+
701
+ /** Count the registered pages of a single vault (O(1), no page-body I/O). */
702
+ function registryPageCount(paths: VaultPaths): number {
703
+ const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
704
+ version: "1.0",
705
+ last_updated: "",
706
+ pages: {},
707
+ });
708
+ return Object.keys(registry.pages).length;
709
+ }
710
+
711
+ /**
712
+ * Total registered page count across the vault(s) recall will actually search.
713
+ * Mirrors `searchWikiLayered`'s vault selection so the two-stage gate is keyed
714
+ * to the same corpus the agent sees. Reads only `registry.json` — never a page
715
+ * body — so the gate stays cheap as the vault grows.
716
+ */
717
+ export function vaultPageCount(primaryPaths: VaultPaths, includePersonal = true): number {
718
+ let count = registryPageCount(primaryPaths);
719
+ if (includePersonal && !isPersonalVault(primaryPaths)) {
720
+ const personalPaths = getPersonalWikiPaths();
721
+ if (existsSync(join(personalPaths.dotWiki, "config.json"))) {
722
+ count += registryPageCount(personalPaths);
723
+ }
724
+ }
725
+ return count;
726
+ }
727
+
728
+ /**
729
+ * Decide whether recall should use links-first (stage 1) rendering: true when
730
+ * the vault page count is STRICTLY GREATER THAN the configured threshold.
731
+ * Threshold 0 forces links-first for any non-empty vault; a very large value
732
+ * keeps previews inline always. Default `DEFAULT_RECALL_LINKS_THRESHOLD`.
733
+ */
734
+ export function shouldUseLinksFirst(pageCount: number, config?: TaskConfig): boolean {
735
+ const threshold = config?.recallLinksThreshold ?? DEFAULT_RECALL_LINKS_THRESHOLD;
736
+ return pageCount > threshold;
737
+ }
738
+
739
+ /** One-line snippet for links-first rendering, derived from the chunk preview. */
740
+ function linkSnippet(preview: string): string {
741
+ const oneLine = preview.replace(/\s+/g, " ").trim();
742
+ if (!oneLine) return "";
743
+ return oneLine.length > LINKS_SNIPPET_MAX ? `${oneLine.slice(0, LINKS_SNIPPET_MAX)}…` : oneLine;
744
+ }
745
+
501
746
  /**
502
747
  * Format recall results as a compact system-prompt section.
748
+ *
749
+ * Two render modes (issue #68):
750
+ * - Default / `linksOnly: false` — preview-inline (unchanged for small vaults).
751
+ * - `linksOnly: true` — stage-1 "links-first": a ranked list of links carrying
752
+ * id, title, type, score, and a single short snippet. The agent expands the
753
+ * links it wants on demand via `read` (stage 2). Used above the vault-size
754
+ * threshold to keep large vaults from flooding context.
503
755
  */
504
- export function formatRecallContext(results: RecallResult[]): string {
756
+ export function formatRecallContext(
757
+ results: RecallResult[],
758
+ opts: { linksOnly?: boolean } = {},
759
+ ): string {
505
760
  if (results.length === 0) return "";
506
761
 
507
762
  const hasLayered = results.some((r) => r.vaultLabel);
508
763
  const label = hasLayered ? " (personal + project)" : "";
509
764
 
765
+ if (opts.linksOnly) {
766
+ const lines: string[] = [
767
+ "## Relevant Wiki Knowledge (links-first)",
768
+ "",
769
+ `_${results.length} page(s) matched your query${label}, ranked. Two-stage recall: links only — open the ones you need to read their full content._`,
770
+ "",
771
+ ];
772
+
773
+ results.forEach((r, i) => {
774
+ const vaultTag = r.vaultLabel ? ` ${r.vaultLabel}` : "";
775
+ const snippet = linkSnippet(r.preview);
776
+ const tail = snippet ? ` — ${snippet}` : "";
777
+ lines.push(
778
+ `${i + 1}. **[[${r.id}]]** — *${r.type}* — score ${r.score.toFixed(1)}${vaultTag} — ${r.title}${tail}`,
779
+ );
780
+ });
781
+
782
+ lines.push(
783
+ "",
784
+ "Call `read` on the links you need to pull their full content." +
785
+ " Add new findings via wiki_ensure_page or wiki_retro.",
786
+ "",
787
+ );
788
+
789
+ return lines.join("\n");
790
+ }
791
+
510
792
  const lines: string[] = [
511
793
  "## Relevant Wiki Knowledge",
512
794
  "",
@@ -540,13 +822,14 @@ export function formatRecallContext(results: RecallResult[]): string {
540
822
  * The model can call this explicitly to search the wiki.
541
823
  * It is also called automatically via before_agent_start hook.
542
824
  */
543
- export function registerWikiRecall(pi: ExtensionAPI): void {
825
+ export function registerWikiRecall(pi: ExtensionAPI, runtime?: Runtime): void {
544
826
  pi.registerTool({
545
827
  name: "wiki_recall",
546
828
  label: "Wiki Recall",
547
829
  description:
548
830
  "Search the wiki for pages relevant to a query. " +
549
- "Returns matching page IDs, titles, types, and content previews. " +
831
+ "Returns matching page IDs, titles, types, and content previews (small vaults) " +
832
+ "or a ranked list of links to expand with `read` (large vaults, two-stage recall). " +
550
833
  "Called automatically at session start — use explicitly to dig deeper.",
551
834
  promptSnippet: "Recall wiki knowledge relevant to the current task",
552
835
  promptGuidelines: [
@@ -578,8 +861,13 @@ export function registerWikiRecall(pi: ExtensionAPI): void {
578
861
  }
579
862
 
580
863
  const maxResults = Math.min(params.max_results ?? 5, 10);
581
- // Use layered search: personal vault + project vault
582
- const results = searchWikiLayered(paths, params.query, maxResults);
864
+ // Use layered hybrid search: personal vault + project vault, blending
865
+ // lexical scoring with precomputed semantic embeddings when available.
866
+ // No embeddings / no embedder => pure lexical, no network call.
867
+ if (runtime) runtime.ensureConfig(ctx.cwd ?? paths.root);
868
+ const results = await searchWikiHybrid(paths, params.query, maxResults, 0, true, {
869
+ config: runtime?.config,
870
+ });
583
871
 
584
872
  if (results.length === 0) {
585
873
  return {
@@ -596,6 +884,36 @@ export function registerWikiRecall(pi: ExtensionAPI): void {
596
884
  const hasPersonal = results.some((r) => r.vaultLabel);
597
885
  const layerTag = hasPersonal ? " (personal + project)" : "";
598
886
 
887
+ // Two-stage gate (issue #68): large vaults return ranked LINKS only;
888
+ // the agent expands chosen links on demand via `read`. Small vaults keep
889
+ // the inline-preview behavior. Page count is read from the registry only.
890
+ const linksFirst = shouldUseLinksFirst(vaultPageCount(paths, true), runtime?.config);
891
+
892
+ if (linksFirst) {
893
+ const linkLines = results
894
+ .map((r, i) => {
895
+ const vault = r.vaultLabel ? ` ${r.vaultLabel}` : "";
896
+ const snippet = linkSnippet(r.preview);
897
+ const tail = snippet ? ` — ${snippet}` : "";
898
+ return `${i + 1}. [[${r.id}]] — ${r.title} (${r.type}, score ${r.score.toFixed(1)})${vault}\n Path: ${r.path}${tail}`;
899
+ })
900
+ .join("\n");
901
+ const text = [
902
+ `Found ${results.length} wiki page(s) matching "${params.query}"${layerTag} (two-stage recall — ranked links, expand on demand):`,
903
+ "",
904
+ linkLines,
905
+ "",
906
+ "Call `read` on the path(s) you need to pull full content.",
907
+ ].join("\n");
908
+ return {
909
+ content: [{ type: "text", text }],
910
+ details: { query: params.query, mode: "links", matches: results } as Record<
911
+ string,
912
+ unknown
913
+ >,
914
+ };
915
+ }
916
+
599
917
  return {
600
918
  content: [
601
919
  {
@@ -608,7 +926,10 @@ export function registerWikiRecall(pi: ExtensionAPI): void {
608
926
  .join("\n\n---\n\n")}`,
609
927
  },
610
928
  ],
611
- details: { query: params.query, matches: results } as Record<string, unknown>,
929
+ details: { query: params.query, mode: "preview", matches: results } as Record<
930
+ string,
931
+ unknown
932
+ >,
612
933
  };
613
934
  },
614
935
  });
@@ -2,7 +2,9 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { ExtensionAPI } 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
  // ─── Public API ────────────────────────────────────────
@@ -28,6 +30,7 @@ export function saveInsight(
28
30
  title: string,
29
31
  body: string,
30
32
  category?: string,
33
+ opts?: { rebuild?: boolean },
31
34
  ): RetroResult {
32
35
  const today = fmtDate();
33
36
 
@@ -73,8 +76,9 @@ export function saveInsight(
73
76
  category: category || "uncategorized",
74
77
  });
75
78
 
76
- // Rebuild metadata so the insight is immediately searchable
77
- rebuildMetadataLight(paths);
79
+ // Rebuild metadata so the insight is immediately searchable. The wiki_retro
80
+ // tool passes { rebuild: false } and schedules a non-blocking reindex instead.
81
+ if (opts?.rebuild !== false) rebuildMetadataLight(paths);
78
82
 
79
83
  return { slug, sourcePagePath };
80
84
  }
@@ -86,7 +90,7 @@ export function saveInsight(
86
90
  * The model calls this to save an atomic insight from a completed task.
87
91
  * Inspired by the memex_retro pattern.
88
92
  */
89
- export function registerWikiRetro(pi: ExtensionAPI): void {
93
+ export function registerWikiRetro(pi: ExtensionAPI, runtime?: Runtime): void {
90
94
  pi.registerTool({
91
95
  name: "wiki_retro",
92
96
  label: "Wiki Retro",
@@ -134,7 +138,12 @@ export function registerWikiRetro(pi: ExtensionAPI): void {
134
138
  };
135
139
  }
136
140
 
137
- const result = saveInsight(paths, params.slug, params.title, params.body, params.category);
141
+ const result = saveInsight(paths, params.slug, params.title, params.body, params.category, {
142
+ rebuild: !runtime,
143
+ });
144
+ if (runtime) {
145
+ scheduleReindex(runtime, { hasUI: ctx.hasUI, ui: ctx.ui }, paths);
146
+ }
138
147
 
139
148
  return {
140
149
  content: [