@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.
@@ -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` (or `wiki_read`) on the links you need to pull 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: [