@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.
- package/README.de.md +414 -0
- package/README.es.md +414 -0
- package/README.fr.md +414 -0
- package/README.hi.md +414 -0
- package/README.ja.md +414 -0
- package/README.ko.md +414 -0
- package/README.md +23 -0
- package/README.pt.md +414 -0
- package/README.ru.md +414 -0
- package/README.zh.md +414 -0
- package/assets/thank-you-for-the-star.png +0 -0
- package/extensions/llm-wiki/index.ts +20 -4
- package/extensions/llm-wiki/lib/observation.ts +310 -0
- package/extensions/llm-wiki/lib/recall.ts +406 -57
- package/extensions/llm-wiki/lib/utils.ts +41 -4
- package/package.json +1 -1
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
preview =
|
|
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
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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.
|
|
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",
|