@zosmaai/pi-llm-wiki 0.7.3 → 0.7.4

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,310 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import { Type } from "typebox";
5
+ import { appendEvent, rebuildMetadataLight } from "./metadata.js";
6
+ import { type VaultPaths, fmtDate, resolveVaultPaths } from "./utils.js";
7
+
8
+ // ─── Types ─────────────────────────────────────────────
9
+
10
+ export interface ObservationInput {
11
+ /** Short descriptive title (≤80 chars) */
12
+ title: string;
13
+ /** The observation content — what happened, was decided, or was learned */
14
+ content: string;
15
+ /** Relevance level for retention priority */
16
+ relevance: "low" | "medium" | "high" | "critical";
17
+ /** Optional space-separated tags for categorization */
18
+ tags?: string;
19
+ /** Context: what was being worked on when this was observed */
20
+ source_context?: string;
21
+ }
22
+
23
+ export interface ObservationResult {
24
+ slug: string;
25
+ pagePath: string;
26
+ }
27
+
28
+ // ─── Save Observation ──────────────────────────────────
29
+
30
+ const RELEVANCE_EMOJIS: Record<string, string> = {
31
+ low: "📝",
32
+ medium: "🔍",
33
+ high: "⭐",
34
+ critical: "🔴",
35
+ };
36
+
37
+ /**
38
+ * Save an observation as a wiki source page.
39
+ *
40
+ * Unlike wiki_retro (which saves atomic insights at task end),
41
+ * wiki_observe records timestamped observations during a session
42
+ * that can later be distilled into durable wiki pages.
43
+ *
44
+ * Observations are stored in wiki/sources/ with type: source and
45
+ * status: observation. They are searchable via wiki_recail.
46
+ */
47
+ export function saveObservation(paths: VaultPaths, input: ObservationInput): ObservationResult {
48
+ const today = fmtDate();
49
+ const timestamp = new Date().toISOString();
50
+
51
+ // Generate a slug from title
52
+ const slugBase = input.title
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]+/g, "-")
55
+ .replace(/^-|-$/g, "")
56
+ .slice(0, 60);
57
+ const slug = `obs-${today}-${slugBase}`;
58
+
59
+ // Write to wiki/sources/{slug}.md
60
+ const sourcePageDir = join(paths.wiki, "sources");
61
+ mkdirSync(sourcePageDir, { recursive: true });
62
+ const pagePath = join(sourcePageDir, `${slug}.md`);
63
+
64
+ const relevanceEmoji = RELEVANCE_EMOJIS[input.relevance] ?? "📝";
65
+ const tags = input.tags ?? "";
66
+ const sourceContext = input.source_context ?? "";
67
+
68
+ const pageContent = [
69
+ "---",
70
+ "type: source",
71
+ `title: "Observation: ${input.title}"`,
72
+ `slug: ${slug}`,
73
+ "status: observation",
74
+ `created: ${today}`,
75
+ `updated: ${today}`,
76
+ `relevance: ${input.relevance}`,
77
+ `observed_at: ${timestamp}`,
78
+ tags
79
+ ? `tags: [${tags
80
+ .split(/\s+/)
81
+ .filter(Boolean)
82
+ .map((t) => `"${t}"`)
83
+ .join(", ")}]`
84
+ : "",
85
+ sourceContext ? `source_context: "${sourceContext}"` : "",
86
+ "---",
87
+ "",
88
+ `# ${relevanceEmoji} Observation: ${input.title}`,
89
+ "",
90
+ input.content,
91
+ "",
92
+ `*Relevance: ${input.relevance}*`,
93
+ sourceContext ? `\n*Context: ${sourceContext}*` : "",
94
+ tags ? `\n*Tags: ${tags}*` : "",
95
+ "",
96
+ "---",
97
+ `*Observed: ${timestamp}*`,
98
+ "",
99
+ ]
100
+ .filter((l) => l !== "")
101
+ .join("\n");
102
+ writeFileSync(pagePath, pageContent, "utf-8");
103
+
104
+ // Log event
105
+ appendEvent(paths, {
106
+ kind: "observe",
107
+ slug,
108
+ title: input.title,
109
+ relevance: input.relevance,
110
+ });
111
+
112
+ // Rebuild metadata so the observation is immediately searchable
113
+ rebuildMetadataLight(paths);
114
+
115
+ return { slug, pagePath };
116
+ }
117
+
118
+ // ─── Shared Reminder State ────────────────────────────
119
+
120
+ /**
121
+ * Mutable state shared between wiki_observe tool and the turn-end reminder.
122
+ * When the model calls wiki_observe, the tool sets observeDoneThisSession
123
+ * so the reminder stops nagging.
124
+ */
125
+ export interface ReminderState {
126
+ observeDoneThisSession: boolean;
127
+ }
128
+
129
+ export function createReminderState(): ReminderState {
130
+ return { observeDoneThisSession: false };
131
+ }
132
+
133
+ // ─── Tool Registration ─────────────────────────────────
134
+
135
+ /**
136
+ * Register the `wiki_observe` tool.
137
+ * The model calls this to record observations during a session.
138
+ * Observations are saved to the wiki and become searchable.
139
+ */
140
+ export function registerWikiObserve(pi: ExtensionAPI, reminderState?: ReminderState): void {
141
+ pi.registerTool({
142
+ name: "wiki_observe",
143
+ label: "Wiki Observe",
144
+ description:
145
+ "Record an atomic observation from the current session into the wiki. " +
146
+ "Observations are timestamped, relevance-rated facts about decisions made, " +
147
+ "findings discovered, constraints established, or work completed. " +
148
+ "Saved observations are searchable via wiki_recall and can later be " +
149
+ "distilled into durable wiki pages via wiki_ensure_page. " +
150
+ "Call this proactively after non-trivial work — every observation " +
151
+ "compounds the wiki's knowledge across sessions.",
152
+ promptSnippet: "Record an observation about the current work",
153
+ promptGuidelines: [
154
+ "Call wiki_observe after non-trivial decisions, discoveries, or completions.",
155
+ "One observation per call. Use multiple calls for multiple observations.",
156
+ "Rate relevance honestly — most observations are medium or low, not critical.",
157
+ "Observations compound across sessions via wiki_recail.",
158
+ ],
159
+ parameters: Type.Object({
160
+ title: Type.String({
161
+ description:
162
+ "Short descriptive title (≤80 chars). Noun phrase, not a sentence. " +
163
+ "Example: 'JWT auth middleware added' or 'Postgres migration constraint discovered'",
164
+ }),
165
+ content: Type.String({
166
+ description:
167
+ "The observation in plain prose. What happened, was decided, or was learned. " +
168
+ "Preserve specific details: file paths, function names, error messages, " +
169
+ "quantitative results. Example: 'User decided to use JWT with refresh tokens. " +
170
+ "Implementation at src/auth/jwt.ts. Tests passing.'",
171
+ }),
172
+ relevance: Type.Union(
173
+ [
174
+ Type.Literal("low"),
175
+ Type.Literal("medium"),
176
+ Type.Literal("high"),
177
+ Type.Literal("critical"),
178
+ ],
179
+ {
180
+ description:
181
+ "Relevance level: low (routine), medium (task context), " +
182
+ "high (non-trivial decisions/constraints), critical (user identity, " +
183
+ "persistent preferences, completed work that must not be redone). " +
184
+ "Default: medium. Be honest — most observations are medium or low.",
185
+ },
186
+ ),
187
+ tags: Type.Optional(
188
+ Type.String({
189
+ description:
190
+ "Optional space-separated tags for categorization. " +
191
+ "Example: 'auth backend migration'",
192
+ }),
193
+ ),
194
+ source_context: Type.Optional(
195
+ Type.String({
196
+ description:
197
+ "What was being worked on. Example: 'Adding authentication module' or 'Debugging login timeout'",
198
+ }),
199
+ ),
200
+ }),
201
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
202
+ const paths = resolveVaultPaths(ctx.cwd ?? process.cwd());
203
+
204
+ if (!existsSync(join(paths.dotWiki, "config.json"))) {
205
+ return {
206
+ content: [
207
+ {
208
+ type: "text",
209
+ text: "No wiki vault found at this location. Initialize one with wiki_bootstrap first.",
210
+ },
211
+ ],
212
+ details: { error: "no_vault" } as Record<string, unknown>,
213
+ isError: true,
214
+ };
215
+ }
216
+
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
+ });
224
+
225
+ // Signal the reminder to stop nagging this session
226
+ if (reminderState) {
227
+ reminderState.observeDoneThisSession = true;
228
+ }
229
+
230
+ const relevanceEmoji = RELEVANCE_EMOJIS[params.relevance] ?? "📝";
231
+
232
+ return {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: [
237
+ `${relevanceEmoji} **Observation saved**: ${params.title}`,
238
+ "",
239
+ `- Page: \`${result.pagePath}\``,
240
+ `- Relevance: ${params.relevance}`,
241
+ params.tags ? `- Tags: ${params.tags}` : "",
242
+ "",
243
+ "This observation is now searchable via wiki_recall. " +
244
+ "It will compound with future observations across sessions.",
245
+ ]
246
+ .filter((l) => l !== "")
247
+ .join("\n"),
248
+ },
249
+ ],
250
+ details: {
251
+ slug: result.slug,
252
+ title: params.title,
253
+ relevance: params.relevance,
254
+ tags: params.tags || null,
255
+ } as Record<string, unknown>,
256
+ };
257
+ },
258
+ });
259
+ }
260
+
261
+ // ─── Turn-End Reminder ─────────────────────────────────
262
+
263
+ /**
264
+ * Track observation cadence and send turn-end reminders.
265
+ * After every N significant turns, reminds the model to call wiki_observe
266
+ * for non-trivial findings (same pattern as memex-retro reminders).
267
+ */
268
+ export function registerObservationReminder(
269
+ pi: ExtensionAPI,
270
+ reminderState: ReminderState,
271
+ options?: { turnsBetweenReminders?: number },
272
+ ): void {
273
+ const REMINDER_INTERVAL = options?.turnsBetweenReminders ?? 5;
274
+ let turnsSinceLastReminder = 0;
275
+
276
+ pi.on("session_start", async () => {
277
+ turnsSinceLastReminder = 0;
278
+ reminderState.observeDoneThisSession = false;
279
+ });
280
+
281
+ // After compaction, reset the reminder state so reminders resume
282
+ pi.on("session_compact", async () => {
283
+ turnsSinceLastReminder = 0;
284
+ reminderState.observeDoneThisSession = false;
285
+ });
286
+
287
+ pi.on("agent_end", async (_event, _ctx) => {
288
+ turnsSinceLastReminder++;
289
+ if (turnsSinceLastReminder < REMINDER_INTERVAL) return;
290
+ if (reminderState.observeDoneThisSession) return;
291
+
292
+ pi.sendMessage(
293
+ {
294
+ 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,
304
+ },
305
+ {
306
+ deliverAs: "nextTurn",
307
+ },
308
+ );
309
+ });
310
+ }
@@ -27,9 +27,17 @@ export interface RecallResult {
27
27
  path: string;
28
28
  /** Vault source label for dual-vault results */
29
29
  vaultLabel?: string;
30
+ /** Relevance score (higher = better match). Used for filtering auto-injected results. */
31
+ score: number;
30
32
  }
31
33
 
32
- type Scored = { id: string; entry: Registry["pages"][string]; score: number; pagePath: string };
34
+ type Scored = {
35
+ id: string;
36
+ entry: Registry["pages"][string];
37
+ score: number;
38
+ pagePath: string;
39
+ bestChunkPreview: string;
40
+ };
33
41
 
34
42
  /**
35
43
  * Normalize text for recall matching.
@@ -116,16 +124,197 @@ function scoreField(value: unknown, terms: string[], weight: number): number {
116
124
  return score;
117
125
  }
118
126
 
127
+ // ─── Common English stopwords ─────────────────────────
128
+
129
+ const STOPWORDS = new Set([
130
+ "the",
131
+ "this",
132
+ "that",
133
+ "with",
134
+ "from",
135
+ "have",
136
+ "been",
137
+ "were",
138
+ "they",
139
+ "their",
140
+ "them",
141
+ "will",
142
+ "would",
143
+ "could",
144
+ "should",
145
+ "about",
146
+ "there",
147
+ "which",
148
+ "what",
149
+ "when",
150
+ "where",
151
+ "than",
152
+ "then",
153
+ "also",
154
+ "just",
155
+ "more",
156
+ "some",
157
+ "such",
158
+ "only",
159
+ "other",
160
+ "into",
161
+ "over",
162
+ "very",
163
+ "after",
164
+ "before",
165
+ "because",
166
+ "between",
167
+ "through",
168
+ "during",
169
+ "without",
170
+ "within",
171
+ "along",
172
+ "these",
173
+ "those",
174
+ "page",
175
+ "section",
176
+ "note",
177
+ "info",
178
+ "type",
179
+ "used",
180
+ "using",
181
+ ]);
182
+
183
+ // ─── Chunk-Level Indexing ────────────────────────────
184
+
185
+ interface PageChunk {
186
+ /** The heading line (e.g. "## Configuration") or empty for the intro section */
187
+ heading: string;
188
+ /** Content of this chunk */
189
+ content: string;
190
+ /** Heading level (0 for intro, 1 for #, 2 for ##, etc.) */
191
+ level: number;
192
+ }
193
+
194
+ /**
195
+ * Split a page's body into chunks by headings.
196
+ * Each heading and its following content become one chunk.
197
+ * Content before the first heading becomes the intro chunk.
198
+ */
199
+ function chunkPage(body: string): PageChunk[] {
200
+ if (!body.trim()) return [];
201
+
202
+ const chunks: PageChunk[] = [];
203
+ const lines = body.split("\n");
204
+
205
+ let currentHeading = "";
206
+ let currentLevel = 0;
207
+ let currentContent: string[] = [];
208
+
209
+ for (const line of lines) {
210
+ const headingMatch = line.trim().match(/^(#{1,6})\s+(.+)$/);
211
+ if (headingMatch) {
212
+ // Save previous chunk
213
+ if (currentContent.length > 0 || currentHeading) {
214
+ chunks.push({
215
+ heading: currentHeading,
216
+ content: currentContent.join("\n").trim(),
217
+ level: currentLevel,
218
+ });
219
+ }
220
+ currentHeading = headingMatch[2].trim();
221
+ currentLevel = headingMatch[1].length;
222
+ currentContent = [];
223
+ } else {
224
+ currentContent.push(line);
225
+ }
226
+ }
227
+
228
+ // Save last chunk
229
+ if (currentContent.length > 0 || currentHeading) {
230
+ chunks.push({
231
+ heading: currentHeading,
232
+ content: currentContent.join("\n").trim(),
233
+ level: currentLevel,
234
+ });
235
+ }
236
+
237
+ return chunks;
238
+ }
239
+
119
240
  function pagePreview(content: string): string {
120
241
  const { body } = parseFrontmatter(content);
121
242
  return body.trim().slice(0, 200).replace(/\n/g, " ");
122
243
  }
123
244
 
245
+ /**
246
+ * Get a preview of the best-matching chunk, or fall back to the page intro.
247
+ * Shows the heading (if any) and the first ~200 chars of content.
248
+ */
249
+ function chunkPreview(heading: string, content: string): string {
250
+ const trimmed = content.slice(0, 180).replace(/\n/g, " ");
251
+ if (heading) {
252
+ return `#${heading} — ${trimmed}`;
253
+ }
254
+ return trimmed;
255
+ }
256
+
257
+ /**
258
+ * Extract distinctive terms from the top search results for query expansion.
259
+ * Pseudo-relevance feedback: terms from top-matching pages that aren't in
260
+ * the original query become expansion candidates.
261
+ */
262
+ function extractExpansionTerms(
263
+ scored: Scored[],
264
+ originalQuery: string,
265
+ paths: VaultPaths,
266
+ maxTerms = 6,
267
+ ): string[] {
268
+ const topResults = scored.slice(0, Math.min(3, scored.length));
269
+ if (topResults.length === 0) return [];
270
+
271
+ const originalNorm = normalizeText(originalQuery);
272
+ const termFreq = new Map<string, number>();
273
+
274
+ for (const { pagePath, entry } of topResults) {
275
+ // Collect text from registry metadata + file content
276
+ const metaText = normalizeText(
277
+ [entry.title, entry.aliases, entry.tags, entry.summary, entry.description]
278
+ .filter(Boolean)
279
+ .join(" "),
280
+ );
281
+ for (const w of metaText.split(/\s+/)) {
282
+ if (w.length >= 4 && !originalNorm.includes(w) && !STOPWORDS.has(w)) {
283
+ termFreq.set(w, (termFreq.get(w) || 0) + 1);
284
+ }
285
+ }
286
+
287
+ // Also extract from file body
288
+ if (existsSync(pagePath)) {
289
+ const content = readFileSync(pagePath, "utf-8");
290
+ const { body } = parseFrontmatter(content);
291
+ const bodyNorm = normalizeText(body);
292
+ for (const w of bodyNorm.split(/\s+/)) {
293
+ if (w.length >= 4 && !originalNorm.includes(w) && !STOPWORDS.has(w)) {
294
+ termFreq.set(w, (termFreq.get(w) || 0) + 1);
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ // Sort by frequency descending, take top N
301
+ return Array.from(termFreq.entries())
302
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
303
+ .slice(0, maxTerms)
304
+ .map(([term]) => term);
305
+ }
306
+
124
307
  /**
125
308
  * Search a single vault's registry for pages matching a query.
126
309
  * Returns up to `maxResults` matches, each with a content preview.
310
+ * Results below `minScore` are excluded (default 0 = no filtering).
127
311
  */
128
- export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): RecallResult[] {
312
+ export function searchWiki(
313
+ paths: VaultPaths,
314
+ query: string,
315
+ maxResults = 5,
316
+ minScore = 0,
317
+ ): RecallResult[] {
129
318
  const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
130
319
  version: "1.0",
131
320
  last_updated: "",
@@ -169,26 +358,83 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
169
358
  score += scoreField(frontmatter.category, terms, 2);
170
359
  score += scoreField(frontmatter.domain, terms, 2);
171
360
 
172
- // Body search makes the wiki useful even when registry metadata is sparse.
173
- // Headings get a stronger boost because they are human-authored labels.
174
- const headings = body
175
- .split("\n")
176
- .filter((line) => line.trim().startsWith("#"))
177
- .join(" ");
178
- score += scoreField(headings, terms, 4);
179
- score += scoreField(body, terms, 1);
361
+ // Body search: use chunk-level indexing for more precise matching.
362
+ // Each section of the page is scored independently, so a query about
363
+ // "Postgres" matches only the Postgres section, not the whole page.
364
+ let bestChunkScore = 0;
365
+ let bestChunkHeading = "";
366
+ let bestChunkContent = "";
367
+
368
+ if (body.trim()) {
369
+ const chunks = chunkPage(body);
370
+ for (const chunk of chunks) {
371
+ let chunkScore = 0;
372
+ // Heading gets a strong boost
373
+ chunkScore += scoreField(chunk.heading, terms, 4);
374
+ // Chunk body content
375
+ chunkScore += scoreField(chunk.content, terms, 1);
376
+
377
+ if (chunkScore > bestChunkScore) {
378
+ bestChunkScore = chunkScore;
379
+ bestChunkHeading = chunk.heading;
380
+ bestChunkContent = chunk.content;
381
+ }
382
+ }
383
+ }
384
+
385
+ // Add best chunk score to total page score
386
+ score += bestChunkScore;
180
387
 
181
388
  if (score > 0) {
182
- scored.push({ id, entry, score, pagePath });
389
+ scored.push({
390
+ id,
391
+ entry,
392
+ score,
393
+ pagePath,
394
+ bestChunkPreview: bestChunkContent ? chunkPreview(bestChunkHeading, bestChunkContent) : "",
395
+ });
183
396
  }
184
397
  }
185
398
 
186
399
  scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
187
- const top = scored.slice(0, maxResults);
188
400
 
189
- return top.map(({ id, entry, pagePath }) => {
190
- let preview = "";
191
- if (existsSync(pagePath)) {
401
+ // ── Pseudo-Relevance Feedback (PRF) ─────────────────
402
+ // Extract distinctive terms from the top 3 results and use them to
403
+ // boost semantically related pages. This gives "semantic" expansion
404
+ // without external dependencies: if an "Authentication" page mentions
405
+ // JWT, OAuth, and sessions, those terms boost other pages that discuss
406
+ // related concepts.
407
+ const expansionTerms = extractExpansionTerms(scored, query, paths, 6);
408
+ if (expansionTerms.length > 0) {
409
+ const expTermList = queryTerms(expansionTerms.join(" "));
410
+ // Apply expansion scoring to the top 25 results (cheap re-read)
411
+ const expansionCandidates = scored.slice(0, Math.min(25, scored.length));
412
+ for (const item of expansionCandidates) {
413
+ const content = existsSync(item.pagePath) ? readFileSync(item.pagePath, "utf-8") : "";
414
+ const { body } = parseFrontmatter(content);
415
+ let expChunkScore = 0;
416
+ if (body.trim()) {
417
+ const chunks = chunkPage(body);
418
+ for (const chunk of chunks) {
419
+ let cs = 0;
420
+ cs += scoreField(chunk.heading, expTermList, 2); // half weight
421
+ cs += scoreField(chunk.content, expTermList, 0.5);
422
+ if (cs > expChunkScore) expChunkScore = cs;
423
+ }
424
+ }
425
+ // Dampened addition — expansion contributes at most 40%
426
+ item.score += expChunkScore * 0.4;
427
+ }
428
+ }
429
+
430
+ // Re-sort after expansion scoring
431
+ scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
432
+ const top = scored.filter((s) => s.score >= minScore).slice(0, maxResults);
433
+
434
+ return top.map(({ id, entry, pagePath, score, bestChunkPreview }) => {
435
+ let preview = bestChunkPreview;
436
+ if (!preview && existsSync(pagePath)) {
437
+ // Fallback: no chunk matched, show page intro
192
438
  preview = pagePreview(readFileSync(pagePath, "utf-8"));
193
439
  }
194
440
 
@@ -198,6 +444,7 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
198
444
  type: String(entry.type || "page"),
199
445
  preview,
200
446
  path: pagePath,
447
+ score,
201
448
  };
202
449
  });
203
450
  }
@@ -205,23 +452,32 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
205
452
  /**
206
453
  * Search both project/primary vault and personal vault, merging results.
207
454
  * Personal results are appended after primary results, deduplicated by page ID.
455
+ *
456
+ * @param minScore - Minimum relevance score (default 0 = no filter).
457
+ * @param includePersonal - Whether to search the personal vault (default true).
458
+ * Auto-injection should pass false to avoid personal-vault contamination.
208
459
  */
209
460
  export function searchWikiLayered(
210
461
  primaryPaths: VaultPaths,
211
462
  query: string,
212
463
  maxResults = 5,
464
+ minScore = 0,
465
+ includePersonal = true,
213
466
  ): RecallResult[] {
214
467
  // Search primary vault
215
- const primaryResults = searchWiki(primaryPaths, query, maxResults);
468
+ const primaryResults = searchWiki(primaryPaths, query, maxResults, minScore);
216
469
 
217
470
  // If primary is already the personal vault, no layered search needed
218
471
  if (isPersonalVault(primaryPaths)) return primaryResults;
219
472
 
220
- // Search personal vault as secondary layer
221
- const personalPaths = getPersonalWikiPaths();
222
- if (!existsSync(join(personalPaths.dotWiki, "config.json"))) return primaryResults;
223
-
224
- const personalResults = searchWiki(personalPaths, query, maxResults);
473
+ // Search personal vault as secondary layer (only when explicitly requested)
474
+ let personalResults: RecallResult[] = [];
475
+ if (includePersonal) {
476
+ const personalPaths = getPersonalWikiPaths();
477
+ if (existsSync(join(personalPaths.dotWiki, "config.json"))) {
478
+ personalResults = searchWiki(personalPaths, query, maxResults, minScore);
479
+ }
480
+ }
225
481
 
226
482
  // Merge: personal results first (they're the user's accumulated knowledge),
227
483
  // then primary results (project-specific). Deduplicate by page ID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Self-maintaining LLM Wiki for Pi — Karpathy-pattern knowledge base with immutable source capture, automated ingestion, search, linting, and Obsidian-compatible vault. auto-updating personal & company wiki.",
5
5
  "keywords": [
6
6
  "pi",