@zosmaai/pi-llm-wiki 0.7.2 → 0.7.3
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";
|
|
@@ -28,6 +29,98 @@ export interface RecallResult {
|
|
|
28
29
|
vaultLabel?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
type Scored = { id: string; entry: Registry["pages"][string]; score: number; pagePath: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize text for recall matching.
|
|
36
|
+
*
|
|
37
|
+
* Wiki queries are often short and multilingual (for example: "继续学习pi").
|
|
38
|
+
* Normalization keeps CJK characters intact, lowercases Latin text, removes
|
|
39
|
+
* punctuation boundaries, and makes hyphenated page IDs match space-separated
|
|
40
|
+
* queries.
|
|
41
|
+
*/
|
|
42
|
+
function normalizeText(value: unknown): string {
|
|
43
|
+
return flattenSearchValue(value)
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.normalize("NFKC")
|
|
46
|
+
.replace(/[\-_./\\]+/g, " ")
|
|
47
|
+
.replace(/[\p{P}\p{S}]+/gu, " ")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function compactText(value: string): string {
|
|
53
|
+
return value.replace(/\s+/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function flattenSearchValue(value: unknown): string {
|
|
57
|
+
if (value == null) return "";
|
|
58
|
+
if (Array.isArray(value)) return value.map(flattenSearchValue).join(" ");
|
|
59
|
+
if (typeof value === "object") return Object.values(value).map(flattenSearchValue).join(" ");
|
|
60
|
+
return String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function unique(values: string[]): string[] {
|
|
64
|
+
return [...new Set(values.filter(Boolean))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tokenize with support for CJK short queries and English/kebab-case terms.
|
|
69
|
+
*
|
|
70
|
+
* Besides whitespace tokens, this returns Latin/digit runs ("pi", "recall")
|
|
71
|
+
* and overlapping CJK bigrams/trigrams. The full normalized query is also kept
|
|
72
|
+
* so exact short phrases still rank highest.
|
|
73
|
+
*/
|
|
74
|
+
function queryTerms(query: string): string[] {
|
|
75
|
+
const normalized = normalizeText(query);
|
|
76
|
+
const compact = compactText(normalized);
|
|
77
|
+
const terms: string[] = [];
|
|
78
|
+
|
|
79
|
+
if (normalized) terms.push(normalized);
|
|
80
|
+
if (compact && compact !== normalized) terms.push(compact);
|
|
81
|
+
|
|
82
|
+
for (const part of normalized.split(/\s+/)) {
|
|
83
|
+
if (part.length >= 2) terms.push(part);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const latinRuns = normalized.match(/[a-z0-9]{2,}/g) ?? [];
|
|
87
|
+
terms.push(...latinRuns);
|
|
88
|
+
|
|
89
|
+
const cjkRuns =
|
|
90
|
+
normalized.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]+/gu) ?? [];
|
|
91
|
+
for (const run of cjkRuns) {
|
|
92
|
+
for (let size = 2; size <= 3; size++) {
|
|
93
|
+
if (run.length < size) continue;
|
|
94
|
+
for (let i = 0; i <= run.length - size; i++) {
|
|
95
|
+
terms.push(run.slice(i, i + size));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return unique(terms).slice(0, 30);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function includesTerm(haystack: string, term: string): boolean {
|
|
104
|
+
if (!haystack || !term) return false;
|
|
105
|
+
return haystack.includes(term) || compactText(haystack).includes(compactText(term));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function scoreField(value: unknown, terms: string[], weight: number): number {
|
|
109
|
+
const text = normalizeText(value);
|
|
110
|
+
if (!text) return 0;
|
|
111
|
+
|
|
112
|
+
let score = 0;
|
|
113
|
+
for (const term of terms) {
|
|
114
|
+
if (includesTerm(text, term)) score += weight;
|
|
115
|
+
}
|
|
116
|
+
return score;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pagePreview(content: string): string {
|
|
120
|
+
const { body } = parseFrontmatter(content);
|
|
121
|
+
return body.trim().slice(0, 200).replace(/\n/g, " ");
|
|
122
|
+
}
|
|
123
|
+
|
|
31
124
|
/**
|
|
32
125
|
* Search a single vault's registry for pages matching a query.
|
|
33
126
|
* Returns up to `maxResults` matches, each with a content preview.
|
|
@@ -39,51 +132,64 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
|
|
|
39
132
|
pages: {},
|
|
40
133
|
});
|
|
41
134
|
|
|
42
|
-
const
|
|
43
|
-
const terms = q
|
|
44
|
-
.split(/\s+/)
|
|
45
|
-
.filter((t) => t.length > 2)
|
|
46
|
-
.slice(0, 10);
|
|
47
|
-
|
|
135
|
+
const terms = queryTerms(query);
|
|
48
136
|
if (terms.length === 0) return [];
|
|
49
137
|
|
|
50
|
-
type Scored = { id: string; entry: Registry["pages"][string]; score: number };
|
|
51
138
|
const scored: Scored[] = [];
|
|
52
139
|
|
|
53
140
|
for (const [id, entry] of Object.entries(registry.pages)) {
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const
|
|
141
|
+
const pagePath = join(paths.wiki, `${id}.md`);
|
|
142
|
+
const content = existsSync(pagePath) ? readFileSync(pagePath, "utf-8") : "";
|
|
143
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
57
144
|
|
|
58
|
-
|
|
59
|
-
if (id.toLowerCase().includes(term)) score += 3;
|
|
60
|
-
if (title.includes(term)) score += 4;
|
|
61
|
-
if (type.includes(term)) score += 1;
|
|
62
|
-
}
|
|
145
|
+
let score = 0;
|
|
63
146
|
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
147
|
+
// Strong identifiers: exact command/short-query aliases should win.
|
|
148
|
+
score += scoreField(id, terms, 3);
|
|
149
|
+
score += scoreField(entry.title, terms, 5);
|
|
150
|
+
score += scoreField(frontmatter.title, terms, 5);
|
|
151
|
+
score += scoreField(entry.type, terms, 1);
|
|
152
|
+
|
|
153
|
+
// Recall-oriented metadata. Arrays are supported by parseFrontmatter and
|
|
154
|
+
// legacy comma/bracket strings still flatten into searchable text.
|
|
155
|
+
score += scoreField(entry.aliases, terms, 6);
|
|
156
|
+
score += scoreField(frontmatter.aliases, terms, 6);
|
|
157
|
+
score += scoreField(entry.recall_triggers, terms, 7);
|
|
158
|
+
score += scoreField(frontmatter.recall_triggers, terms, 7);
|
|
159
|
+
score += scoreField(entry.summary, terms, 3);
|
|
160
|
+
score += scoreField(frontmatter.summary, terms, 3);
|
|
161
|
+
score += scoreField(entry.description, terms, 3);
|
|
162
|
+
score += scoreField(frontmatter.description, terms, 3);
|
|
163
|
+
|
|
164
|
+
// General metadata from the registry/frontmatter.
|
|
165
|
+
score += scoreField(entry.tags, terms, 2);
|
|
166
|
+
score += scoreField(entry.category, terms, 2);
|
|
167
|
+
score += scoreField(entry.domain, terms, 2);
|
|
168
|
+
score += scoreField(frontmatter.tags, terms, 2);
|
|
169
|
+
score += scoreField(frontmatter.category, terms, 2);
|
|
170
|
+
score += scoreField(frontmatter.domain, terms, 2);
|
|
171
|
+
|
|
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);
|
|
69
180
|
|
|
70
181
|
if (score > 0) {
|
|
71
|
-
scored.push({ id, entry, score });
|
|
182
|
+
scored.push({ id, entry, score, pagePath });
|
|
72
183
|
}
|
|
73
184
|
}
|
|
74
185
|
|
|
75
|
-
scored.sort((a, b) => b.score - a.score);
|
|
186
|
+
scored.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
76
187
|
const top = scored.slice(0, maxResults);
|
|
77
188
|
|
|
78
|
-
return top.map(({ id, entry }) => {
|
|
79
|
-
// Try to read page content for preview
|
|
189
|
+
return top.map(({ id, entry, pagePath }) => {
|
|
80
190
|
let preview = "";
|
|
81
|
-
const pagePath = join(paths.wiki, `${id}.md`);
|
|
82
191
|
if (existsSync(pagePath)) {
|
|
83
|
-
|
|
84
|
-
// Strip frontmatter
|
|
85
|
-
const body = content.replace(/^---[\s\S]*?---\n/, "").trim();
|
|
86
|
-
preview = body.slice(0, 200).replace(/\n/g, " ");
|
|
192
|
+
preview = pagePreview(readFileSync(pagePath, "utf-8"));
|
|
87
193
|
}
|
|
88
194
|
|
|
89
195
|
return {
|
|
@@ -96,9 +202,6 @@ export function searchWiki(paths: VaultPaths, query: string, maxResults = 5): Re
|
|
|
96
202
|
});
|
|
97
203
|
}
|
|
98
204
|
|
|
99
|
-
/**
|
|
100
|
-
* Format recall results as a compact system-prompt section.
|
|
101
|
-
*/
|
|
102
205
|
/**
|
|
103
206
|
* Search both project/primary vault and personal vault, merging results.
|
|
104
207
|
* Personal results are appended after primary results, deduplicated by page ID.
|
|
@@ -241,25 +344,15 @@ export function registerWikiRecall(pi: ExtensionAPI): void {
|
|
|
241
344
|
content: [
|
|
242
345
|
{
|
|
243
346
|
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"),
|
|
347
|
+
text: `Found ${results.length} wiki page(s) matching "${params.query}"${layerTag}:\n\n${results
|
|
348
|
+
.map((r) => {
|
|
349
|
+
const vault = r.vaultLabel ? ` ${r.vaultLabel}` : "";
|
|
350
|
+
return `## [[${r.id}]] — ${r.title}${vault}\nType: ${r.type}\nPath: ${r.path}\n\n${r.preview}`;
|
|
351
|
+
})
|
|
352
|
+
.join("\n\n---\n\n")}`,
|
|
257
353
|
},
|
|
258
354
|
],
|
|
259
|
-
details: { query: params.query, matches: results
|
|
260
|
-
string,
|
|
261
|
-
unknown
|
|
262
|
-
>,
|
|
355
|
+
details: { query: params.query, matches: results } as Record<string, unknown>,
|
|
263
356
|
};
|
|
264
357
|
},
|
|
265
358
|
});
|
|
@@ -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.3",
|
|
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",
|