llm-kb 0.2.0 → 0.4.0

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/src/config.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ export const DEFAULT_INDEX_MODEL = "claude-haiku-4-5";
6
+ export const DEFAULT_QUERY_MODEL = "claude-sonnet-4-6";
7
+
8
+ export interface KBConfig {
9
+ indexModel: string;
10
+ queryModel: string;
11
+ }
12
+
13
+ const DEFAULTS: KBConfig = {
14
+ indexModel: DEFAULT_INDEX_MODEL,
15
+ queryModel: DEFAULT_QUERY_MODEL,
16
+ };
17
+
18
+ function configPath(kbRoot: string): string {
19
+ return join(kbRoot, ".llm-kb", "config.json");
20
+ }
21
+
22
+ /**
23
+ * Load config from .llm-kb/config.json, applying env var overrides.
24
+ *
25
+ * Priority: env var > config file > defaults
26
+ */
27
+ export async function loadConfig(kbRoot: string): Promise<KBConfig> {
28
+ let base: KBConfig = { ...DEFAULTS };
29
+
30
+ const path = configPath(kbRoot);
31
+ if (existsSync(path)) {
32
+ try {
33
+ const raw = await readFile(path, "utf-8");
34
+ const parsed = JSON.parse(raw);
35
+ if (parsed.indexModel) base.indexModel = parsed.indexModel;
36
+ if (parsed.queryModel) base.queryModel = parsed.queryModel;
37
+ } catch {
38
+ // Ignore malformed config — fall back to defaults
39
+ }
40
+ }
41
+
42
+ if (process.env.LLM_KB_INDEX_MODEL) base.indexModel = process.env.LLM_KB_INDEX_MODEL;
43
+ if (process.env.LLM_KB_QUERY_MODEL) base.queryModel = process.env.LLM_KB_QUERY_MODEL;
44
+
45
+ return base;
46
+ }
47
+
48
+ /**
49
+ * Create .llm-kb/config.json with defaults if it doesn't exist yet.
50
+ */
51
+ export async function ensureConfig(kbRoot: string): Promise<KBConfig> {
52
+ const path = configPath(kbRoot);
53
+
54
+ if (!existsSync(path)) {
55
+ await mkdir(join(kbRoot, ".llm-kb"), { recursive: true });
56
+ await writeFile(path, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8");
57
+ return { ...DEFAULTS };
58
+ }
59
+
60
+ return loadConfig(kbRoot);
61
+ }
package/src/eval.ts ADDED
@@ -0,0 +1,548 @@
1
+ import { getModels, completeSimple } from "@mariozechner/pi-ai";
2
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
3
+ import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { join, basename } from "node:path";
6
+ import { homedir } from "node:os";
7
+
8
+ // ── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ interface SessionQA {
11
+ sessionFile: string;
12
+ question: string;
13
+ thinking: string;
14
+ filesRead: { path: string; content: string }[]; // what agent actually saw
15
+ filesAvailable: string[]; // all sources
16
+ filesSkipped: string[];
17
+ answer: string;
18
+ model: string;
19
+ durationMs: number;
20
+ }
21
+
22
+ interface EvalIssue {
23
+ type: "citation" | "contradiction" | "wiki-gap" | "wasted-read" | "index-issue";
24
+ severity: "error" | "warning" | "info";
25
+ sessionFile: string;
26
+ question: string;
27
+ detail: string;
28
+ recommendation: string;
29
+ }
30
+
31
+ interface EvalMetrics {
32
+ totalSessions: number;
33
+ totalQAs: number;
34
+ avgDurationMs: number;
35
+ wikiHits: number; // answered without reading source files
36
+ sourceReads: number; // needed source files
37
+ totalFilesRead: number;
38
+ uniqueFilesRead: Map<string, number>; // file → read count
39
+ wastedReads: number; // files read but not cited in answer
40
+ }
41
+
42
+ export interface EvalResult {
43
+ metrics: EvalMetrics;
44
+ issues: EvalIssue[];
45
+ wikiGaps: string[];
46
+ timestamp: string;
47
+ agentsInsights: string; // markdown block to inject into AGENTS.md
48
+ }
49
+
50
+ // ── Session parser ──────────────────────────────────────────────────────────
51
+
52
+ async function parseSessionsForEval(sessionsDir: string, sourcesDir: string, limit?: number): Promise<SessionQA[]> {
53
+ if (!existsSync(sessionsDir)) return [];
54
+
55
+ const sessionFiles = (await readdir(sessionsDir))
56
+ .filter((f) => f.endsWith(".jsonl"))
57
+ .sort()
58
+ .reverse(); // newest first
59
+
60
+ const files = limit ? sessionFiles.slice(0, limit) : sessionFiles;
61
+ const qas: SessionQA[] = [];
62
+
63
+ // Get available source files
64
+ let filesAvailable: string[] = [];
65
+ try {
66
+ filesAvailable = (await readdir(sourcesDir)).filter((f) => f.endsWith(".md"));
67
+ } catch {}
68
+
69
+ for (const file of files) {
70
+ try {
71
+ const raw = await readFile(join(sessionsDir, file), "utf-8");
72
+ const lines = raw.trim().split("\n").filter(Boolean);
73
+
74
+ const entries: any[] = [];
75
+ for (const line of lines) {
76
+ try { entries.push(JSON.parse(line)); } catch {}
77
+ }
78
+
79
+ const messages = entries.filter((e) => e.type === "message");
80
+
81
+ // Check if this is a query session
82
+ const sessionInfo = entries.find((e) => e.type === "session_info");
83
+ const name: string = sessionInfo?.name ?? "";
84
+ if (!name.startsWith("query:")) continue;
85
+
86
+ // Find user questions and their corresponding answers
87
+ let currentQuestion = "";
88
+ let currentThinking = "";
89
+ let currentFilesRead: { path: string; content: string }[] = [];
90
+ let currentAnswer = "";
91
+ let currentModel = "";
92
+ let startTs = 0;
93
+ let endTs = 0;
94
+
95
+ for (const entry of messages) {
96
+ const msg = entry.message;
97
+ if (!msg) continue;
98
+
99
+ if (msg.role === "user") {
100
+ // If we had a previous Q&A, save it
101
+ if (currentQuestion && currentAnswer) {
102
+ qas.push({
103
+ sessionFile: file,
104
+ question: currentQuestion,
105
+ thinking: currentThinking,
106
+ filesRead: currentFilesRead,
107
+ filesAvailable,
108
+ filesSkipped: filesAvailable.filter(
109
+ (f) => !currentFilesRead.some((r) => r.path.endsWith(f))
110
+ ),
111
+ answer: currentAnswer,
112
+ model: currentModel,
113
+ durationMs: endTs - startTs,
114
+ });
115
+ }
116
+
117
+ // Start new Q&A
118
+ currentQuestion = extractText(msg.content);
119
+ currentThinking = "";
120
+ currentFilesRead = [];
121
+ currentAnswer = "";
122
+ startTs = new Date(entry.timestamp).getTime();
123
+ }
124
+
125
+ if (msg.role === "assistant") {
126
+ currentModel = msg.model ?? "";
127
+ endTs = new Date(entry.timestamp).getTime();
128
+
129
+ for (const block of msg.content ?? []) {
130
+ if (block.type === "thinking") currentThinking += block.thinking;
131
+ if (block.type === "text") currentAnswer += block.text;
132
+ }
133
+ }
134
+
135
+ if (msg.role === "toolResult" && !msg.isError) {
136
+ // Find the corresponding tool call to get the path
137
+ const toolCallId = msg.toolCallId;
138
+ // Look back for the assistant message with this tool call
139
+ for (const prev of messages) {
140
+ if (prev.message?.role !== "assistant") continue;
141
+ for (const block of prev.message?.content ?? []) {
142
+ if (block.type === "toolCall" && block.id === toolCallId && block.name === "read") {
143
+ const path = block.arguments?.path ?? "";
144
+ const content = extractText(msg.content);
145
+ if (path && content) {
146
+ currentFilesRead.push({ path, content: content.slice(0, 2000) }); // cap content size
147
+ }
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // Save the last Q&A
155
+ if (currentQuestion && currentAnswer) {
156
+ qas.push({
157
+ sessionFile: file,
158
+ question: currentQuestion,
159
+ thinking: currentThinking,
160
+ filesRead: currentFilesRead,
161
+ filesAvailable,
162
+ filesSkipped: filesAvailable.filter(
163
+ (f) => !currentFilesRead.some((r) => r.path.endsWith(f))
164
+ ),
165
+ answer: currentAnswer,
166
+ model: currentModel,
167
+ durationMs: endTs - startTs,
168
+ });
169
+ }
170
+ } catch {
171
+ // Skip malformed session files
172
+ }
173
+ }
174
+
175
+ return qas;
176
+ }
177
+
178
+ // ── Metrics calculator ──────────────────────────────────────────────────────
179
+
180
+ function calculateMetrics(qas: SessionQA[]): EvalMetrics {
181
+ const uniqueFiles = new Map<string, number>();
182
+ let totalFilesRead = 0;
183
+ let wikiHits = 0;
184
+ let sourceReads = 0;
185
+ let wastedReads = 0;
186
+ let totalDuration = 0;
187
+
188
+ const uniqueSessions = new Set(qas.map((q) => q.sessionFile));
189
+
190
+ for (const qa of qas) {
191
+ totalDuration += qa.durationMs;
192
+
193
+ const sourceFilesRead = qa.filesRead.filter(
194
+ (f) => !f.path.includes("index.md") && !f.path.includes("wiki.md")
195
+ );
196
+
197
+ if (sourceFilesRead.length === 0) {
198
+ wikiHits++;
199
+ } else {
200
+ sourceReads++;
201
+ }
202
+
203
+ for (const f of sourceFilesRead) {
204
+ totalFilesRead++;
205
+ const name = basename(f.path);
206
+ uniqueFiles.set(name, (uniqueFiles.get(name) ?? 0) + 1);
207
+
208
+ // Check if this file was actually cited in the answer
209
+ if (!qa.answer.includes(name) && !qa.answer.includes(name.replace(".md", ""))) {
210
+ wastedReads++;
211
+ }
212
+ }
213
+ }
214
+
215
+ return {
216
+ totalSessions: uniqueSessions.size,
217
+ totalQAs: qas.length,
218
+ avgDurationMs: qas.length > 0 ? totalDuration / qas.length : 0,
219
+ wikiHits,
220
+ sourceReads,
221
+ totalFilesRead,
222
+ uniqueFilesRead: uniqueFiles,
223
+ wastedReads,
224
+ };
225
+ }
226
+
227
+ // ── LLM judge ───────────────────────────────────────────────────────────────
228
+
229
+ async function resolveApiKey(authStorage?: AuthStorage): Promise<string | undefined> {
230
+ if (authStorage) return authStorage.getApiKey("anthropic");
231
+ const piAuthPath = join(homedir(), ".pi", "agent", "auth.json");
232
+ if (existsSync(piAuthPath)) {
233
+ const storage = AuthStorage.create(piAuthPath);
234
+ return storage.getApiKey("anthropic");
235
+ }
236
+ return process.env.ANTHROPIC_API_KEY;
237
+ }
238
+
239
+ async function judgeQA(
240
+ qa: SessionQA,
241
+ apiKey: string,
242
+ modelId: string
243
+ ): Promise<EvalIssue[]> {
244
+ const issues: EvalIssue[] = [];
245
+ const model = getModels("anthropic").find((m) => m.id === modelId);
246
+ if (!model) return issues;
247
+
248
+ // Build context for the judge
249
+ const filesSummary = qa.filesRead
250
+ .map((f) => `File: ${basename(f.path)}\nContent (first 2000 chars):\n${f.content}`)
251
+ .join("\n\n---\n\n");
252
+
253
+ const skippedList = qa.filesSkipped.join(", ") || "none";
254
+
255
+ const prompt = `You are an eval judge for a knowledge base Q&A system.
256
+
257
+ QUESTION: ${qa.question}
258
+
259
+ ANSWER:
260
+ ${qa.answer.slice(0, 3000)}
261
+
262
+ FILES READ BY AGENT:
263
+ ${filesSummary || "None — answered from wiki cache"}
264
+
265
+ FILES AVAILABLE BUT SKIPPED: ${skippedList}
266
+
267
+ ---
268
+
269
+ Check for these issues and return a JSON array of findings. Each finding has:
270
+ - "type": one of "citation", "contradiction", "wiki-gap", "wasted-read"
271
+ - "severity": "error" or "warning"
272
+ - "detail": what's wrong (one sentence)
273
+ - "recommendation": what to fix (one sentence)
274
+
275
+ Checks:
276
+ 1. CITATION: Does the answer cite specific sources? If so, does the file content support the claims?
277
+ 2. CONTRADICTION: Does the answer say anything that contradicts the file content?
278
+ 3. WIKI-GAP: If the agent read source files (not just wiki), what topic should be added to the wiki so next time it can answer without reading files?
279
+ 4. WASTED-READ: Were any files read but not actually used in the answer?
280
+
281
+ Return ONLY a JSON array. If no issues found, return [].
282
+ Example: [{"type":"wiki-gap","severity":"warning","detail":"Electronic evidence topic not in wiki","recommendation":"Add electronic evidence section to wiki"}]`;
283
+
284
+ try {
285
+ const result = await completeSimple(
286
+ model,
287
+ {
288
+ systemPrompt: "You are a precise QA evaluator. Return only valid JSON arrays. No explanation.",
289
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
290
+ },
291
+ { apiKey }
292
+ );
293
+
294
+ const text = result.content
295
+ .filter((b) => b.type === "text")
296
+ .map((b) => (b as any).text)
297
+ .join("")
298
+ .trim();
299
+
300
+ // Parse JSON response
301
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
302
+ if (jsonMatch) {
303
+ const findings = JSON.parse(jsonMatch[0]);
304
+ for (const f of findings) {
305
+ issues.push({
306
+ type: f.type ?? "citation",
307
+ severity: f.severity ?? "warning",
308
+ sessionFile: qa.sessionFile,
309
+ question: qa.question,
310
+ detail: f.detail ?? "",
311
+ recommendation: f.recommendation ?? "",
312
+ });
313
+ }
314
+ }
315
+ } catch {
316
+ // Judge call failed — non-fatal
317
+ }
318
+
319
+ return issues;
320
+ }
321
+
322
+ // ── Report writer ───────────────────────────────────────────────────────────
323
+
324
+ function buildReport(result: EvalResult): string {
325
+ const { metrics, issues, wikiGaps } = result;
326
+ const lines: string[] = [];
327
+
328
+ lines.push(`# Eval Report`);
329
+ lines.push(``);
330
+ lines.push(`> ${metrics.totalQAs} queries across ${metrics.totalSessions} sessions · ${result.timestamp}`);
331
+ lines.push(``);
332
+
333
+ // Performance
334
+ lines.push(`## Performance`);
335
+ lines.push(``);
336
+ lines.push(`| Metric | Value |`);
337
+ lines.push(`|---|---|`);
338
+ lines.push(`| Total queries | ${metrics.totalQAs} |`);
339
+ lines.push(`| Avg duration | ${(metrics.avgDurationMs / 1000).toFixed(1)}s |`);
340
+ lines.push(`| Wiki hits (no file reads) | ${metrics.wikiHits} (${metrics.totalQAs > 0 ? Math.round(metrics.wikiHits / metrics.totalQAs * 100) : 0}%) |`);
341
+ lines.push(`| Needed source files | ${metrics.sourceReads} |`);
342
+ lines.push(`| Total file reads | ${metrics.totalFilesRead} |`);
343
+ lines.push(`| Wasted reads | ${metrics.wastedReads} |`);
344
+ lines.push(``);
345
+
346
+ // Most read files
347
+ if (metrics.uniqueFilesRead.size > 0) {
348
+ lines.push(`### Most Read Files`);
349
+ lines.push(``);
350
+ const sorted = [...metrics.uniqueFilesRead.entries()].sort((a, b) => b[1] - a[1]);
351
+ lines.push(`| File | Times Read |`);
352
+ lines.push(`|---|---|`);
353
+ for (const [file, count] of sorted.slice(0, 10)) {
354
+ lines.push(`| ${file} | ${count} |`);
355
+ }
356
+ lines.push(``);
357
+ }
358
+
359
+ // Issues
360
+ const errors = issues.filter((i) => i.severity === "error");
361
+ const warnings = issues.filter((i) => i.severity === "warning");
362
+
363
+ if (errors.length > 0) {
364
+ lines.push(`## 🔴 Errors (${errors.length})`);
365
+ lines.push(``);
366
+ for (const issue of errors) {
367
+ lines.push(`### ${issue.type}: ${issue.detail}`);
368
+ lines.push(`- **Query:** ${issue.question}`);
369
+ lines.push(`- **Recommendation:** ${issue.recommendation}`);
370
+ lines.push(``);
371
+ }
372
+ }
373
+
374
+ if (warnings.length > 0) {
375
+ lines.push(`## 🟡 Warnings (${warnings.length})`);
376
+ lines.push(``);
377
+ for (const issue of warnings) {
378
+ lines.push(`### ${issue.type}: ${issue.detail}`);
379
+ lines.push(`- **Query:** ${issue.question}`);
380
+ lines.push(`- **Recommendation:** ${issue.recommendation}`);
381
+ lines.push(``);
382
+ }
383
+ }
384
+
385
+ // Wiki gaps
386
+ if (wikiGaps.length > 0) {
387
+ lines.push(`## 📝 Wiki Gaps (auto-fixable)`);
388
+ lines.push(``);
389
+ for (const gap of wikiGaps) {
390
+ lines.push(`- ${gap}`);
391
+ }
392
+ lines.push(``);
393
+ }
394
+
395
+ if (errors.length === 0 && warnings.length === 0 && wikiGaps.length === 0) {
396
+ lines.push(`## ✅ No issues found`);
397
+ lines.push(``);
398
+ }
399
+
400
+ return lines.join("\n");
401
+ }
402
+
403
+ // ── Agents insights (injected into AGENTS.md) ──────────────────────────────
404
+
405
+ function buildAgentsInsights(result: EvalResult): string {
406
+ const { metrics, issues, wikiGaps } = result;
407
+ const lines: string[] = [];
408
+
409
+ lines.push(`## Eval Insights (auto-generated ${result.timestamp.slice(0, 10)})`);
410
+ lines.push(``);
411
+
412
+ // Wiki gaps — tell the agent to fill these
413
+ if (wikiGaps.length > 0) {
414
+ lines.push(`### Wiki Gaps — add to wiki when users ask about these topics`);
415
+ for (const gap of wikiGaps.slice(0, 15)) { // cap at 15 to avoid context bloat
416
+ lines.push(`- ${gap}`);
417
+ }
418
+ lines.push(``);
419
+ }
420
+
421
+ // Behaviour fixes from errors
422
+ const citationErrors = issues.filter((i) => i.type === "citation" && i.severity === "error");
423
+ const contradictions = issues.filter((i) => i.type === "contradiction");
424
+ if (citationErrors.length > 0 || contradictions.length > 0) {
425
+ lines.push(`### Behaviour Fixes`);
426
+ if (citationErrors.some((i) => i.detail.includes("wiki cache"))) {
427
+ lines.push(`- Do NOT claim "I read the actual document" when answering from wiki. Say "Based on the knowledge wiki" instead.`);
428
+ }
429
+ if (contradictions.length > 0) {
430
+ lines.push(`- Double-check claims against source text before stating them as fact.`);
431
+ }
432
+ if (metrics.wastedReads > 10) {
433
+ lines.push(`- Be more selective with file reads. Last eval found ${metrics.wastedReads} wasted reads (files read but not cited).`);
434
+ }
435
+ lines.push(``);
436
+ }
437
+
438
+ // Most-read files — tell agent to prefer wiki for these
439
+ if (metrics.uniqueFilesRead.size > 0) {
440
+ const sorted = [...metrics.uniqueFilesRead.entries()].sort((a, b) => b[1] - a[1]);
441
+ const heavy = sorted.filter(([, count]) => count >= 3);
442
+ if (heavy.length > 0) {
443
+ lines.push(`### Heavily-Read Files — prefer wiki knowledge over re-reading these`);
444
+ for (const [file, count] of heavy.slice(0, 5)) {
445
+ lines.push(`- ${file} (read ${count} times)`);
446
+ }
447
+ lines.push(``);
448
+ }
449
+ }
450
+
451
+ // Performance note
452
+ const hitRate = metrics.totalQAs > 0 ? Math.round(metrics.wikiHits / metrics.totalQAs * 100) : 0;
453
+ lines.push(`### Performance`);
454
+ lines.push(`- Wiki hit rate: ${hitRate}% (target: 80%+)`);
455
+ lines.push(`- Avg query time: ${(metrics.avgDurationMs / 1000).toFixed(1)}s`);
456
+ lines.push(``);
457
+
458
+ return lines.join("\n");
459
+ }
460
+
461
+ // ── Main eval function ──────────────────────────────────────────────────────
462
+
463
+ export async function runEval(
464
+ kbRoot: string,
465
+ options: { authStorage?: AuthStorage; last?: number; onProgress?: (msg: string) => void }
466
+ ): Promise<EvalResult> {
467
+ const sessionsDir = join(kbRoot, ".llm-kb", "sessions");
468
+ const sourcesDir = join(kbRoot, ".llm-kb", "wiki", "sources");
469
+ const log = options.onProgress ?? (() => {});
470
+
471
+ // 1. Parse sessions
472
+ log("Reading sessions...");
473
+ const qas = await parseSessionsForEval(sessionsDir, sourcesDir, options.last);
474
+ log(`Found ${qas.length} Q&A exchanges across sessions`);
475
+
476
+ if (qas.length === 0) {
477
+ return {
478
+ metrics: { totalSessions: 0, totalQAs: 0, avgDurationMs: 0, wikiHits: 0, sourceReads: 0, totalFilesRead: 0, uniqueFilesRead: new Map(), wastedReads: 0 },
479
+ issues: [],
480
+ wikiGaps: [],
481
+ timestamp: new Date().toISOString(),
482
+ };
483
+ }
484
+
485
+ // 2. Calculate metrics
486
+ log("Calculating metrics...");
487
+ const metrics = calculateMetrics(qas);
488
+
489
+ // 3. Run LLM judge on each Q&A
490
+ const apiKey = await resolveApiKey(options.authStorage);
491
+ const allIssues: EvalIssue[] = [];
492
+
493
+ if (apiKey) {
494
+ const modelId = "claude-haiku-4-5";
495
+ for (let i = 0; i < qas.length; i++) {
496
+ log(`Judging ${i + 1}/${qas.length}: "${qas[i].question.slice(0, 50)}..."`);
497
+ const issues = await judgeQA(qas[i], apiKey, modelId);
498
+ allIssues.push(...issues);
499
+ }
500
+ } else {
501
+ log("No API key — skipping LLM judge checks");
502
+ }
503
+
504
+ // 4. Extract wiki gaps
505
+ const wikiGaps = allIssues
506
+ .filter((i) => i.type === "wiki-gap")
507
+ .map((i) => i.detail);
508
+
509
+ const result: EvalResult = {
510
+ metrics,
511
+ issues: allIssues.filter((i) => i.type !== "wiki-gap"),
512
+ wikiGaps,
513
+ timestamp: new Date().toISOString(),
514
+ agentsInsights: "",
515
+ };
516
+
517
+ // 5. Build agents insights (injected into AGENTS.md on next query)
518
+ result.agentsInsights = buildAgentsInsights(result);
519
+
520
+ // 6. Write report + insights file
521
+ log("Writing eval report + insights...");
522
+ const outputsDir = join(kbRoot, ".llm-kb", "wiki", "outputs");
523
+ await mkdir(outputsDir, { recursive: true });
524
+
525
+ const report = buildReport(result);
526
+ await writeFile(join(outputsDir, "eval-report.md"), report, "utf-8");
527
+
528
+ // Save insights to a file that query.ts reads and injects into AGENTS.md
529
+ await writeFile(join(kbRoot, ".llm-kb", "eval-insights.md"), result.agentsInsights, "utf-8");
530
+ log("Insights saved to .llm-kb/eval-insights.md (injected into next query)");
531
+
532
+ return result;
533
+ }
534
+
535
+ // ── Helpers ─────────────────────────────────────────────────────────────────
536
+
537
+ function extractText(content: any): string {
538
+ if (!content) return "";
539
+ if (typeof content === "string") return content;
540
+ if (Array.isArray(content)) {
541
+ return content
542
+ .filter((b) => b.type === "text")
543
+ .map((b) => b.text ?? "")
544
+ .join("")
545
+ .trim();
546
+ }
547
+ return "";
548
+ }