@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.
- package/CHANGELOG.md +41 -0
- package/extensions/llm-wiki/index.ts +81 -14
- package/extensions/llm-wiki/lib/embeddings.ts +420 -0
- package/extensions/llm-wiki/lib/guardrails.ts +15 -4
- package/extensions/llm-wiki/lib/indexing.ts +88 -0
- package/extensions/llm-wiki/lib/ingest-worker.ts +281 -0
- package/extensions/llm-wiki/lib/model-command.ts +128 -0
- package/extensions/llm-wiki/lib/observation.ts +84 -21
- package/extensions/llm-wiki/lib/recall.ts +331 -10
- package/extensions/llm-wiki/lib/retro.ts +13 -4
- package/extensions/llm-wiki/lib/runtime.ts +264 -0
- package/extensions/llm-wiki/lib/subagent.ts +82 -0
- package/extensions/llm-wiki/lib/task-config.ts +217 -0
- package/extensions/llm-wiki/lib/tools.ts +369 -149
- package/package.json +1 -1
- package/prompts/wiki-ingest.md +7 -4
- package/skills/llm-wiki/SKILL.md +30 -0
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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: [
|