@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.
- 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 +277 -21
- package/package.json +1 -1
|
@@ -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 = {
|
|
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(
|
|
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
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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({
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
"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",
|