@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 q = query.toLowerCase();
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
- let score = 0;
55
- const title = String(entry.title || "").toLowerCase();
56
- const type = String(entry.type || "").toLowerCase();
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
- 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;
62
- }
145
+ let score = 0;
63
146
 
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
- }
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
- 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, " ");
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
- `🧠 **${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"),
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.map((r) => r.id) } as Record<
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 > 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.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",