@zosmaai/pi-llm-wiki 0.7.2 → 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.
@@ -7,6 +7,7 @@ import {
7
7
  type VaultPaths,
8
8
  getPersonalWikiPaths,
9
9
  isPersonalVault,
10
+ parseFrontmatter,
10
11
  readJson,
11
12
  resolveVaultPaths,
12
13
  } from "./utils.js";
@@ -26,64 +27,415 @@ export interface RecallResult {
26
27
  path: string;
27
28
  /** Vault source label for dual-vault results */
28
29
  vaultLabel?: string;
30
+ /** Relevance score (higher = better match). Used for filtering auto-injected results. */
31
+ score: number;
32
+ }
33
+
34
+ type Scored = {
35
+ id: string;
36
+ entry: Registry["pages"][string];
37
+ score: number;
38
+ pagePath: string;
39
+ bestChunkPreview: string;
40
+ };
41
+
42
+ /**
43
+ * Normalize text for recall matching.
44
+ *
45
+ * Wiki queries are often short and multilingual (for example: "继续学习pi").
46
+ * Normalization keeps CJK characters intact, lowercases Latin text, removes
47
+ * punctuation boundaries, and makes hyphenated page IDs match space-separated
48
+ * queries.
49
+ */
50
+ function normalizeText(value: unknown): string {
51
+ return flattenSearchValue(value)
52
+ .toLowerCase()
53
+ .normalize("NFKC")
54
+ .replace(/[\-_./\\]+/g, " ")
55
+ .replace(/[\p{P}\p{S}]+/gu, " ")
56
+ .replace(/\s+/g, " ")
57
+ .trim();
58
+ }
59
+
60
+ function compactText(value: string): string {
61
+ return value.replace(/\s+/g, "");
62
+ }
63
+
64
+ function flattenSearchValue(value: unknown): string {
65
+ if (value == null) return "";
66
+ if (Array.isArray(value)) return value.map(flattenSearchValue).join(" ");
67
+ if (typeof value === "object") return Object.values(value).map(flattenSearchValue).join(" ");
68
+ return String(value);
69
+ }
70
+
71
+ function unique(values: string[]): string[] {
72
+ return [...new Set(values.filter(Boolean))];
73
+ }
74
+
75
+ /**
76
+ * Tokenize with support for CJK short queries and English/kebab-case terms.
77
+ *
78
+ * Besides whitespace tokens, this returns Latin/digit runs ("pi", "recall")
79
+ * and overlapping CJK bigrams/trigrams. The full normalized query is also kept
80
+ * so exact short phrases still rank highest.
81
+ */
82
+ function queryTerms(query: string): string[] {
83
+ const normalized = normalizeText(query);
84
+ const compact = compactText(normalized);
85
+ const terms: string[] = [];
86
+
87
+ if (normalized) terms.push(normalized);
88
+ if (compact && compact !== normalized) terms.push(compact);
89
+
90
+ for (const part of normalized.split(/\s+/)) {
91
+ if (part.length >= 2) terms.push(part);
92
+ }
93
+
94
+ const latinRuns = normalized.match(/[a-z0-9]{2,}/g) ?? [];
95
+ terms.push(...latinRuns);
96
+
97
+ const cjkRuns =
98
+ normalized.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu) ?? [];
99
+ for (const run of cjkRuns) {
100
+ for (let size = 2; size <= 3; size++) {
101
+ if (run.length < size) continue;
102
+ for (let i = 0; i <= run.length - size; i++) {
103
+ terms.push(run.slice(i, i + size));
104
+ }
105
+ }
106
+ }
107
+
108
+ return unique(terms).slice(0, 30);
109
+ }
110
+
111
+ function includesTerm(haystack: string, term: string): boolean {
112
+ if (!haystack || !term) return false;
113
+ return haystack.includes(term) || compactText(haystack).includes(compactText(term));
114
+ }
115
+
116
+ function scoreField(value: unknown, terms: string[], weight: number): number {
117
+ const text = normalizeText(value);
118
+ if (!text) return 0;
119
+
120
+ let score = 0;
121
+ for (const term of terms) {
122
+ if (includesTerm(text, term)) score += weight;
123
+ }
124
+ return score;
125
+ }
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
+
240
+ function pagePreview(content: string): string {
241
+ const { body } = parseFrontmatter(content);
242
+ return body.trim().slice(0, 200).replace(/\n/g, " ");
243
+ }
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);
29
305
  }
30
306
 
31
307
  /**
32
308
  * Search a single vault's registry for pages matching a query.
33
309
  * Returns up to `maxResults` matches, each with a content preview.
310
+ * Results below `minScore` are excluded (default 0 = no filtering).
34
311
  */
35
- 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[] {
36
318
  const registry = readJson<Registry>(join(paths.meta, "registry.json"), {
37
319
  version: "1.0",
38
320
  last_updated: "",
39
321
  pages: {},
40
322
  });
41
323
 
42
- const q = query.toLowerCase();
43
- const terms = q
44
- .split(/\s+/)
45
- .filter((t) => t.length > 2)
46
- .slice(0, 10);
47
-
324
+ const terms = queryTerms(query);
48
325
  if (terms.length === 0) return [];
49
326
 
50
- type Scored = { id: string; entry: Registry["pages"][string]; score: number };
51
327
  const scored: Scored[] = [];
52
328
 
53
329
  for (const [id, entry] of Object.entries(registry.pages)) {
330
+ const pagePath = join(paths.wiki, `${id}.md`);
331
+ const content = existsSync(pagePath) ? readFileSync(pagePath, "utf-8") : "";
332
+ const { frontmatter, body } = parseFrontmatter(content);
333
+
54
334
  let score = 0;
55
- const title = String(entry.title || "").toLowerCase();
56
- const type = String(entry.type || "").toLowerCase();
57
335
 
58
- for (const term of terms) {
59
- if (id.toLowerCase().includes(term)) score += 3;
60
- if (title.includes(term)) score += 4;
61
- if (type.includes(term)) score += 1;
336
+ // Strong identifiers: exact command/short-query aliases should win.
337
+ score += scoreField(id, terms, 3);
338
+ score += scoreField(entry.title, terms, 5);
339
+ score += scoreField(frontmatter.title, terms, 5);
340
+ score += scoreField(entry.type, terms, 1);
341
+
342
+ // Recall-oriented metadata. Arrays are supported by parseFrontmatter and
343
+ // legacy comma/bracket strings still flatten into searchable text.
344
+ score += scoreField(entry.aliases, terms, 6);
345
+ score += scoreField(frontmatter.aliases, terms, 6);
346
+ score += scoreField(entry.recall_triggers, terms, 7);
347
+ score += scoreField(frontmatter.recall_triggers, terms, 7);
348
+ score += scoreField(entry.summary, terms, 3);
349
+ score += scoreField(frontmatter.summary, terms, 3);
350
+ score += scoreField(entry.description, terms, 3);
351
+ score += scoreField(frontmatter.description, terms, 3);
352
+
353
+ // General metadata from the registry/frontmatter.
354
+ score += scoreField(entry.tags, terms, 2);
355
+ score += scoreField(entry.category, terms, 2);
356
+ score += scoreField(entry.domain, terms, 2);
357
+ score += scoreField(frontmatter.tags, terms, 2);
358
+ score += scoreField(frontmatter.category, terms, 2);
359
+ score += scoreField(frontmatter.domain, terms, 2);
360
+
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
+ }
62
383
  }
63
384
 
64
- // Boost if query terms appear in tags/categories
65
- const tags = String(entry.tags || entry.category || entry.domain || "");
66
- for (const term of terms) {
67
- if (tags.toLowerCase().includes(term)) score += 2;
68
- }
385
+ // Add best chunk score to total page score
386
+ score += bestChunkScore;
69
387
 
70
388
  if (score > 0) {
71
- scored.push({ id, entry, score });
389
+ scored.push({
390
+ id,
391
+ entry,
392
+ score,
393
+ pagePath,
394
+ bestChunkPreview: bestChunkContent ? chunkPreview(bestChunkHeading, bestChunkContent) : "",
395
+ });
72
396
  }
73
397
  }
74
398
 
75
- scored.sort((a, b) => b.score - a.score);
76
- const top = scored.slice(0, maxResults);
399
+ scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
400
+
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
+ }
77
429
 
78
- return top.map(({ id, entry }) => {
79
- // Try to read page content for preview
80
- let preview = "";
81
- const pagePath = join(paths.wiki, `${id}.md`);
82
- if (existsSync(pagePath)) {
83
- const content = readFileSync(pagePath, "utf-8");
84
- // Strip frontmatter
85
- const body = content.replace(/^---[\s\S]*?---\n/, "").trim();
86
- preview = body.slice(0, 200).replace(/\n/g, " ");
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
438
+ preview = pagePreview(readFileSync(pagePath, "utf-8"));
87
439
  }
88
440
 
89
441
  return {
@@ -92,33 +444,40 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
92
444
  type: String(entry.type || "page"),
93
445
  preview,
94
446
  path: pagePath,
447
+ score,
95
448
  };
96
449
  });
97
450
  }
98
451
 
99
- /**
100
- * Format recall results as a compact system-prompt section.
101
- */
102
452
  /**
103
453
  * Search both project/primary vault and personal vault, merging results.
104
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.
105
459
  */
106
460
  export function searchWikiLayered(
107
461
  primaryPaths: VaultPaths,
108
462
  query: string,
109
463
  maxResults = 5,
464
+ minScore = 0,
465
+ includePersonal = true,
110
466
  ): RecallResult[] {
111
467
  // Search primary vault
112
- const primaryResults = searchWiki(primaryPaths, query, maxResults);
468
+ const primaryResults = searchWiki(primaryPaths, query, maxResults, minScore);
113
469
 
114
470
  // If primary is already the personal vault, no layered search needed
115
471
  if (isPersonalVault(primaryPaths)) return primaryResults;
116
472
 
117
- // Search personal vault as secondary layer
118
- const personalPaths = getPersonalWikiPaths();
119
- if (!existsSync(join(personalPaths.dotWiki, "config.json"))) return primaryResults;
120
-
121
- 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
+ }
122
481
 
123
482
  // Merge: personal results first (they're the user's accumulated knowledge),
124
483
  // then primary results (project-specific). Deduplicate by page ID.
@@ -241,25 +600,15 @@ export function registerWikiRecall(pi: ExtensionAPI): void {
241
600
  content: [
242
601
  {
243
602
  type: "text",
244
- text: [
245
- `🧠 **${results.length} wiki page(s) relevant** to "${params.query}"${layerTag}:`,
246
- "",
247
- ...results.map(
248
- (r) =>
249
- `- ${r.vaultLabel || "📁"} [[${r.id}]] — *${r.type}* — ${r.title}${
250
- r.preview ? `\n > ${r.preview.slice(0, 150)}` : ""
251
- }`,
252
- ),
253
- "",
254
- "Use `read` on any page for full content.",
255
- "Use `wiki_retro` to save new insights from this task.",
256
- ].join("\n"),
603
+ text: `Found ${results.length} wiki page(s) matching "${params.query}"${layerTag}:\n\n${results
604
+ .map((r) => {
605
+ const vault = r.vaultLabel ? ` ${r.vaultLabel}` : "";
606
+ return `## [[${r.id}]] — ${r.title}${vault}\nType: ${r.type}\nPath: ${r.path}\n\n${r.preview}`;
607
+ })
608
+ .join("\n\n---\n\n")}`,
257
609
  },
258
610
  ],
259
- details: { query: params.query, matches: results.map((r) => r.id) } as Record<
260
- string,
261
- unknown
262
- >,
611
+ details: { query: params.query, matches: results } as Record<string, unknown>,
263
612
  };
264
613
  },
265
614
  });
@@ -184,6 +184,22 @@ export function nextSourceId(paths: VaultPaths): string {
184
184
  return `${prefix}-${String(num + 1).padStart(3, "0")}`;
185
185
  }
186
186
 
187
+ /** Parse a small, dependency-free YAML scalar/inline-array value. */
188
+ function parseFrontmatterValue(raw: string, unquote = false): unknown {
189
+ const trimmed = raw.trim();
190
+ const unquoted = (value: string) => value.replace(/^(["'])(.*)\1$/, "$2").trim();
191
+
192
+ if (!trimmed) return "";
193
+
194
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
195
+ const inner = trimmed.slice(1, -1).trim();
196
+ if (!inner) return [];
197
+ return inner.split(",").map((item) => unquoted(item.trim()));
198
+ }
199
+
200
+ return unquote ? unquoted(trimmed) : trimmed;
201
+ }
202
+
187
203
  /** Extract frontmatter from markdown. */
188
204
  export function parseFrontmatter(content: string): {
189
205
  frontmatter: Record<string, unknown>;
@@ -194,12 +210,33 @@ export function parseFrontmatter(content: string): {
194
210
 
195
211
  const frontmatter: Record<string, unknown> = {};
196
212
  const lines = match[1].split("\n");
213
+ let currentListKey: string | null = null;
214
+
197
215
  for (const line of lines) {
216
+ const listMatch = line.match(/^\s*-\s+(.*)$/);
217
+ if (listMatch && currentListKey) {
218
+ const current = frontmatter[currentListKey];
219
+ const list = Array.isArray(current) ? current : [];
220
+ list.push(parseFrontmatterValue(listMatch[1], true));
221
+ frontmatter[currentListKey] = list;
222
+ continue;
223
+ }
224
+
198
225
  const idx = line.indexOf(":");
199
- if (idx > 0) {
200
- const key = line.slice(0, idx).trim();
201
- const val = line.slice(idx + 1).trim();
202
- frontmatter[key] = val;
226
+ if (idx <= 0) {
227
+ currentListKey = null;
228
+ continue;
229
+ }
230
+
231
+ const key = line.slice(0, idx).trim();
232
+ const val = line.slice(idx + 1).trim();
233
+
234
+ if (!val) {
235
+ frontmatter[key] = [];
236
+ currentListKey = key;
237
+ } else {
238
+ frontmatter[key] = parseFrontmatterValue(val);
239
+ currentListKey = null;
203
240
  }
204
241
  }
205
242
  return { frontmatter, body: match[2] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zosmaai/pi-llm-wiki",
3
- "version": "0.7.2",
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",