llm-kb 0.4.2 → 0.6.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/bin/cli.js CHANGED
@@ -1,16 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- buildIndex,
4
- completeSimple,
5
- continueKBSession,
6
- createKBSession,
7
- getNodeModulesPath
8
- } from "./chunk-DHOXVEIR.js";
3
+ buildIndex
4
+ } from "./chunk-EZ7LPPEP.js";
5
+ import {
6
+ appendToQueryLog,
7
+ buildTrace,
8
+ completeWithFallback,
9
+ createChat,
10
+ parseCitations,
11
+ query,
12
+ saveTrace,
13
+ updateWiki
14
+ } from "./chunk-Y2764FFH.js";
15
+ import "./chunk-3WBSKCCH.js";
9
16
  import "./chunk-3YMNGUZZ.js";
10
17
  import "./chunk-LDHOKBJA.js";
11
- import {
12
- getModels
13
- } from "./chunk-5PYKQQLA.js";
18
+ import "./chunk-5PYKQQLA.js";
14
19
  import "./chunk-EAQYK3U2.js";
15
20
 
16
21
  // src/cli.ts
@@ -126,6 +131,19 @@ ${p.text}`).join("\n\n---\n\n");
126
131
  };
127
132
  await writeFile(mdPath, markdown);
128
133
  await writeFile(jsonPath, JSON.stringify(bboxData, null, 2));
134
+ const pagesDir = join(outputDir, `${name}.pages`);
135
+ await mkdir(pagesDir, { recursive: true });
136
+ for (const pageData of bboxData.pages) {
137
+ const pageFile = join(pagesDir, `${pageData.page}.json`);
138
+ await writeFile(pageFile, JSON.stringify({
139
+ source: bboxData.source,
140
+ totalPages: bboxData.totalPages,
141
+ page: pageData.page,
142
+ width: pageData.width,
143
+ height: pageData.height,
144
+ textItems: pageData.textItems
145
+ }));
146
+ }
129
147
  return {
130
148
  name,
131
149
  mdPath,
@@ -140,7 +158,7 @@ ${p.text}`).join("\n\n---\n\n");
140
158
  import { watch } from "chokidar";
141
159
  import { extname as extname2, basename as basename2 } from "path";
142
160
  import chalk from "chalk";
143
- function startWatcher({ folder, sourcesDir, debounceMs = 2e3, authStorage, indexModel }) {
161
+ function startWatcher({ folder, sourcesDir, debounceMs = 2e3, authStorage, indexModel, onSourcesChanged }) {
144
162
  let pendingFiles = [];
145
163
  let debounceTimer = null;
146
164
  async function processBatch() {
@@ -166,6 +184,12 @@ function startWatcher({ folder, sourcesDir, debounceMs = 2e3, authStorage, index
166
184
  try {
167
185
  await buildIndex(folder, sourcesDir, void 0, authStorage, indexModel);
168
186
  console.log(chalk.green(` \u2713 index.md updated`));
187
+ if (onSourcesChanged) {
188
+ try {
189
+ await onSourcesChanged();
190
+ } catch {
191
+ }
192
+ }
169
193
  } catch (err) {
170
194
  console.log(chalk.red(` \u2717 ${err.message}`));
171
195
  }
@@ -207,250 +231,28 @@ function startWatcher({ folder, sourcesDir, debounceMs = 2e3, authStorage, index
207
231
 
208
232
  // src/session-watcher.ts
209
233
  import { watch as watch2 } from "chokidar";
210
- import { join as join5, basename as basename3 } from "path";
211
- import { readdir as readdir3, readFile as readFile3, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
212
- import { existsSync as existsSync3 } from "fs";
213
-
214
- // src/trace-builder.ts
215
- import { readFile, writeFile as writeFile2, mkdir as mkdir2, readdir as readdir2 } from "fs/promises";
234
+ import { join as join3, basename as basename3 } from "path";
235
+ import { readdir as readdir2, readFile, writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
216
236
  import { existsSync } from "fs";
217
- import { join as join3, basename as pathBasename } from "path";
218
- async function buildTrace(sessionFile, sourcesDir) {
219
- const raw = await readFile(sessionFile, "utf-8");
220
- const lines = raw.trim().split("\n").filter(Boolean);
221
- if (lines.length < 2) return null;
222
- const entries = [];
223
- let header = null;
224
- for (const line of lines) {
225
- try {
226
- const obj = JSON.parse(line);
227
- if (obj.type === "session") header = obj;
228
- else entries.push(obj);
229
- } catch {
230
- }
231
- }
232
- if (!header) return null;
233
- const messages = entries.filter((e) => e.type === "message");
234
- const lastAssistant = [...messages].reverse().find(
235
- (e) => e.message?.role === "assistant" && e.message?.stopReason === "stop"
236
- );
237
- if (!lastAssistant) return null;
238
- const modelChange = entries.find((e) => e.type === "model_change");
239
- const model = modelChange?.modelId ?? lastAssistant.message?.model ?? void 0;
240
- const firstUser = messages.find((e) => e.message?.role === "user");
241
- const question = extractText(firstUser?.message?.content);
242
- const sessionInfo = entries.find((e) => e.type === "session_info");
243
- const sessionName = sessionInfo?.name ?? "";
244
- const mode = sessionName.startsWith("index:") ? "index" : sessionName.startsWith("query:") || question ? "query" : "unknown";
245
- const answer = extractText(lastAssistant.message?.content);
246
- const filesRead = [];
247
- for (const entry of messages) {
248
- if (entry.message?.role !== "assistant") continue;
249
- for (const block of entry.message?.content ?? []) {
250
- if (block.type === "toolCall" && block.name === "read") {
251
- const p = block.arguments?.path ?? "";
252
- if (p && !filesRead.includes(p)) filesRead.push(p);
253
- }
254
- }
255
- }
256
- let filesAvailable = [];
257
- try {
258
- const all = await readdir2(sourcesDir);
259
- filesAvailable = all.filter((f) => f.endsWith(".md"));
260
- } catch {
261
- }
262
- const filesSkipped = filesAvailable.filter(
263
- (f) => !filesRead.some((r) => r.endsWith(f))
264
- );
265
- const firstMsg = messages[0];
266
- const lastMsg = messages[messages.length - 1];
267
- let durationMs;
268
- if (firstMsg?.timestamp && lastMsg?.timestamp) {
269
- durationMs = new Date(lastMsg.timestamp).getTime() - new Date(firstMsg.timestamp).getTime();
270
- }
271
- return {
272
- sessionId: header.id,
273
- sessionFile: pathBasename(sessionFile),
274
- timestamp: header.timestamp,
275
- mode,
276
- question: question || void 0,
277
- answer: answer || void 0,
278
- filesRead,
279
- filesAvailable,
280
- filesSkipped,
281
- model,
282
- durationMs
283
- };
284
- }
285
- async function saveTrace(kbRoot, trace) {
286
- const tracesDir = join3(kbRoot, ".llm-kb", "traces");
287
- await mkdir2(tracesDir, { recursive: true });
288
- const outPath = join3(tracesDir, `${trace.sessionId}.json`);
289
- await writeFile2(outPath, JSON.stringify(trace, null, 2) + "\n", "utf-8");
290
- }
291
- async function appendToQueryLog(kbRoot, trace) {
292
- if (trace.mode !== "query" || !trace.question) return;
293
- const wikiDir = join3(kbRoot, ".llm-kb", "wiki");
294
- await mkdir2(wikiDir, { recursive: true });
295
- const logPath = join3(wikiDir, "queries.md");
296
- const date = new Date(trace.timestamp).toISOString().replace("T", " ").slice(0, 19);
297
- const durationSec = trace.durationMs ? `${(trace.durationMs / 1e3).toFixed(1)}s` : "?";
298
- const filesLine = trace.filesRead.length > 0 ? trace.filesRead.map((f) => pathBasename(f)).join(", ") : "_none_";
299
- let header = "";
300
- if (!existsSync(logPath)) {
301
- header = `# Query Log
302
-
303
- All queries run against this knowledge base.
304
-
305
- ---
306
-
307
- `;
308
- }
309
- const entry = [
310
- `## ${trace.question}`,
311
- ``,
312
- `- **Date:** ${date}`,
313
- `- **Model:** ${trace.model ?? "unknown"}`,
314
- `- **Duration:** ${durationSec}`,
315
- `- **Files read:** ${filesLine}`,
316
- trace.filesSkipped.length > 0 ? `- **Files skipped:** ${trace.filesSkipped.join(", ")}` : null,
317
- ``,
318
- trace.answer ? `### Answer
319
-
320
- ${trace.answer}` : null,
321
- ``,
322
- `---`,
323
- ``
324
- ].filter((l) => l !== null).join("\n");
325
- const existing = existsSync(logPath) ? await readFile(logPath, "utf-8") : "";
326
- await writeFile2(logPath, header + entry + existing, "utf-8");
327
- }
328
- function extractText(content) {
329
- if (!content) return "";
330
- if (typeof content === "string") return content;
331
- if (Array.isArray(content)) {
332
- return content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("").trim();
333
- }
334
- return "";
335
- }
336
-
337
- // src/wiki-updater.ts
338
- import { AuthStorage } from "@mariozechner/pi-coding-agent";
339
- import { existsSync as existsSync2 } from "fs";
340
- import { readFile as readFile2, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
341
- import { join as join4 } from "path";
342
- import { homedir } from "os";
343
- async function resolveApiKey(authStorage) {
344
- if (authStorage) {
345
- return authStorage.getApiKey("anthropic");
346
- }
347
- const piAuthPath = join4(homedir(), ".pi", "agent", "auth.json");
348
- if (existsSync2(piAuthPath)) {
349
- const storage = AuthStorage.create(piAuthPath);
350
- return storage.getApiKey("anthropic");
351
- }
352
- return process.env.ANTHROPIC_API_KEY;
353
- }
354
- function buildPrompt(question, answer, sources, date, currentWiki) {
355
- const rules = `Rules for wiki structure:
356
- - Use ## for CONCEPTS and TOPICS \u2014 NOT source file names
357
- Good: "## Electronic Evidence", "## Mob Lynching", "## Burden of Proof"
358
- Bad: "## Indian Evidence Act.md", "## indian penal code - new.md"
359
- - Use ### for subtopics within a concept
360
- - A concept can draw from MULTIPLE source files \u2014 synthesize, don't separate by file
361
- - If knowledge from this Q&A fits an existing concept, ADD to it \u2014 never duplicate
362
- - If it's a genuinely new concept, create a new ## section
363
- - Be concise: bullet points for lists, short prose for explanations
364
- - Include source citations inline: (Source: filename, p.X)
365
- - Add cross-references where concepts relate: See also: [[Other Concept]]
366
- - End each ## section with: *Sources: file1, file2 \xB7 date*
367
- - Separate ## sections with: ---`;
368
- if (currentWiki.trim()) {
369
- return `You are maintaining a concept-organized knowledge wiki.
370
-
371
- ## Current wiki
372
- ${currentWiki}
373
-
374
- ## New Q&A to integrate
375
- **Question:** ${question}
376
- **Sources used:** ${sources}
377
- **Date:** ${date}
378
-
379
- **Answer:**
380
- ${answer}
381
-
382
- ---
383
-
384
- Update the wiki to integrate this new knowledge.
385
- ${rules}
386
-
387
- Return ONLY the complete updated wiki markdown. No explanation.`;
388
- }
389
- return `You are creating a concept-organized knowledge wiki.
390
-
391
- ## First Q&A to add
392
- **Question:** ${question}
393
- **Sources used:** ${sources}
394
- **Date:** ${date}
395
-
396
- **Answer:**
397
- ${answer}
398
-
399
- ---
400
-
401
- Create a clean wiki from this Q&A.
402
- - Start with: # Knowledge Wiki\\n\\n> Concept-organized knowledge base. Updated after each query.\\n\\n---
403
- ${rules}
404
-
405
- Return ONLY the wiki markdown. No explanation.`;
406
- }
407
- async function updateWiki(kbRoot, trace, authStorage, indexModelId = "claude-haiku-4-5") {
408
- if (trace.mode !== "query" || !trace.question || !trace.answer) return;
409
- const wikiDir = join4(kbRoot, ".llm-kb", "wiki");
410
- await mkdir3(wikiDir, { recursive: true });
411
- const wikiPath = join4(wikiDir, "wiki.md");
412
- const currentWiki = existsSync2(wikiPath) ? await readFile2(wikiPath, "utf-8").catch(() => "") : "";
413
- const sources = trace.filesRead.map((f) => f.split(/[\\/]/).pop() ?? f).filter((f) => f.endsWith(".md") && f !== "index.md" && f !== "wiki.md").join(", ") || "unknown";
414
- const date = new Date(trace.timestamp).toISOString().slice(0, 10);
415
- const prompt = buildPrompt(trace.question, trace.answer, sources, date, currentWiki);
416
- const apiKey = await resolveApiKey(authStorage);
417
- if (!apiKey) return;
418
- const model = getModels("anthropic").find((m) => m.id === indexModelId);
419
- if (!model) return;
420
- const result = await completeSimple(
421
- model,
422
- {
423
- systemPrompt: "You are a precise knowledge librarian. Organize information by CONCEPT, not by source file. Synthesize knowledge from multiple sources into unified topic articles. Return only clean markdown.",
424
- messages: [{ role: "user", content: prompt, timestamp: Date.now() }]
425
- },
426
- { apiKey }
427
- );
428
- const text = result.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
429
- if (text) {
430
- await writeFile3(wikiPath, text + "\n", "utf-8");
431
- }
432
- }
433
-
434
- // src/session-watcher.ts
435
237
  var PROCESSED_LOG = ".llm-kb/traces/.processed";
436
238
  async function loadProcessed(kbRoot) {
437
- const path3 = join5(kbRoot, PROCESSED_LOG);
438
- if (!existsSync3(path3)) return /* @__PURE__ */ new Set();
239
+ const path3 = join3(kbRoot, PROCESSED_LOG);
240
+ if (!existsSync(path3)) return /* @__PURE__ */ new Set();
439
241
  try {
440
- const lines = (await readFile3(path3, "utf-8")).split("\n").filter(Boolean);
242
+ const lines = (await readFile(path3, "utf-8")).split("\n").filter(Boolean);
441
243
  return new Set(lines);
442
244
  } catch {
443
245
  return /* @__PURE__ */ new Set();
444
246
  }
445
247
  }
446
248
  async function markProcessed(kbRoot, sessionId) {
447
- const path3 = join5(kbRoot, PROCESSED_LOG);
448
- await mkdir4(join5(kbRoot, ".llm-kb", "traces"), { recursive: true });
449
- await writeFile4(path3, sessionId + "\n", { flag: "a" });
249
+ const path3 = join3(kbRoot, PROCESSED_LOG);
250
+ await mkdir2(join3(kbRoot, ".llm-kb", "traces"), { recursive: true });
251
+ await writeFile2(path3, sessionId + "\n", { flag: "a" });
450
252
  }
451
253
  async function startSessionWatcher(kbRoot) {
452
- const sessionsDir = join5(kbRoot, ".llm-kb", "sessions");
453
- const sourcesDir = join5(kbRoot, ".llm-kb", "wiki", "sources");
254
+ const sessionsDir = join3(kbRoot, ".llm-kb", "sessions");
255
+ const sourcesDir = join3(kbRoot, ".llm-kb", "wiki", "sources");
454
256
  const processed = await loadProcessed(kbRoot);
455
257
  const timers = /* @__PURE__ */ new Map();
456
258
  async function processSession(filePath) {
@@ -478,11 +280,11 @@ async function startSessionWatcher(kbRoot) {
478
280
  }, 1500);
479
281
  timers.set(filePath, timer);
480
282
  }
481
- if (existsSync3(sessionsDir)) {
283
+ if (existsSync(sessionsDir)) {
482
284
  try {
483
- const files = (await readdir3(sessionsDir)).filter((f) => f.endsWith(".jsonl"));
285
+ const files = (await readdir2(sessionsDir)).filter((f) => f.endsWith(".jsonl"));
484
286
  for (const f of files) {
485
- await processSession(join5(sessionsDir, f));
287
+ await processSession(join3(sessionsDir, f));
486
288
  }
487
289
  } catch {
488
290
  }
@@ -500,482 +302,23 @@ async function startSessionWatcher(kbRoot) {
500
302
  });
501
303
  }
502
304
 
503
- // src/query.ts
504
- import {
505
- createAgentSession,
506
- createBashTool,
507
- createReadTool,
508
- createWriteTool,
509
- DefaultResourceLoader,
510
- SettingsManager
511
- } from "@mariozechner/pi-coding-agent";
512
- import { readdir as readdir4, mkdir as mkdir5, readFile as readFile4 } from "fs/promises";
513
- import { existsSync as existsSync4 } from "fs";
514
- import { join as join6, basename as basename4 } from "path";
515
- import chalk3 from "chalk";
516
-
517
- // src/md-stream.ts
518
- import chalk2 from "chalk";
519
- var MarkdownStream = class {
520
- buffer = "";
521
- isTTY;
522
- constructor(isTTY = false) {
523
- this.isTTY = isTTY;
524
- }
525
- /** Feed a text_delta chunk. Returns styled string ready for stdout. */
526
- push(chunk) {
527
- if (!this.isTTY) return chunk;
528
- this.buffer += chunk;
529
- return this.drain(false);
530
- }
531
- /** Flush remaining buffer (call on text_end). */
532
- end() {
533
- if (!this.isTTY) return "";
534
- const out = this.drain(true);
535
- this.buffer = "";
536
- return out;
537
- }
538
- drain(final) {
539
- let out = "";
540
- while (true) {
541
- const nlIdx = this.buffer.indexOf("\n");
542
- if (nlIdx === -1) {
543
- if (final && this.buffer.length > 0) {
544
- out += this.renderLine(this.buffer);
545
- this.buffer = "";
546
- }
547
- break;
548
- }
549
- const line = this.buffer.slice(0, nlIdx);
550
- this.buffer = this.buffer.slice(nlIdx + 1);
551
- out += this.renderLine(line) + "\n";
552
- }
553
- return out;
554
- }
555
- /** Render a single complete line with block + inline styling. */
556
- renderLine(line) {
557
- const trimmed = line.trimStart();
558
- if (/^-{3,}\s*$/.test(trimmed) || /^\*{3,}\s*$/.test(trimmed)) {
559
- const cols = process.stdout.columns || 80;
560
- return chalk2.dim("\u2500".repeat(Math.min(cols, 60)));
561
- }
562
- const headerMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
563
- if (headerMatch) {
564
- const text = this.inline(headerMatch[2]);
565
- return "\n" + chalk2.bold(text);
566
- }
567
- const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
568
- if (bulletMatch) {
569
- const indent = line.length - trimmed.length;
570
- return " ".repeat(indent) + chalk2.dim("\u2022") + " " + this.inline(bulletMatch[1]);
571
- }
572
- const numMatch = trimmed.match(/^(\d+)[.)]\s+(.*)$/);
573
- if (numMatch) {
574
- const indent = line.length - trimmed.length;
575
- return " ".repeat(indent) + chalk2.dim(numMatch[1] + ".") + " " + this.inline(numMatch[2]);
576
- }
577
- if (/^\|[\s\-:|]+\|$/.test(trimmed)) {
578
- return chalk2.dim(trimmed);
579
- }
580
- if (trimmed.startsWith("|") && trimmed.endsWith("|")) {
581
- return this.inline(line);
582
- }
583
- if (trimmed.startsWith(">")) {
584
- const content = trimmed.replace(/^>+\s*/, "");
585
- return chalk2.dim("\u2502 ") + chalk2.italic(this.inline(content));
586
- }
587
- return this.inline(line);
588
- }
589
- /** Apply inline markdown styling to text. */
590
- inline(text) {
591
- text = text.replace(/`([^`]+)`/g, (_, c) => chalk2.cyan(c));
592
- text = text.replace(/\*\*\*(.+?)\*\*\*/g, (_, t) => chalk2.bold.italic(t));
593
- text = text.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk2.bold(t));
594
- text = text.replace(/(?<!\*)\*(.+?)\*(?!\*)/g, (_, t) => chalk2.italic(t));
595
- text = text.replace(/~~(.+?)~~/g, (_, t) => chalk2.strikethrough(t));
596
- text = text.replace(
597
- /\[([^\]]+)\]\(([^)]+)\)/g,
598
- (_, label, url) => `${label} ${chalk2.dim(`(${url})`)}`
599
- );
600
- return text;
601
- }
602
- };
603
-
604
- // src/query.ts
605
- function extractAnswerText(content) {
606
- return (content ?? []).filter((b) => b.type === "text").map((b) => b.text ?? "").join("").trim();
607
- }
608
- function extractFilesRead(messages) {
609
- const paths = [];
610
- for (const msg of messages) {
611
- if (msg.role !== "assistant") continue;
612
- for (const block of msg.content ?? []) {
613
- if (block.type === "toolCall" && block.name === "read") {
614
- const p = block.arguments?.path ?? "";
615
- if (p && !paths.includes(p)) paths.push(p);
616
- }
617
- }
618
- }
619
- return paths;
620
- }
621
- function getToolLabel(toolName, args) {
622
- if (toolName === "read" || toolName === "write" || toolName === "edit") {
623
- const file = basename4(args?.path ?? "");
624
- if (!file || !/\.[a-z0-9]{1,6}$/i.test(file)) return null;
625
- const verb = toolName === "read" ? "Reading" : toolName === "write" ? "Writing" : "Editing";
626
- return `${verb} ${file}`;
627
- }
628
- if (toolName === "bash" && args?.command) {
629
- return `Running bash`;
630
- }
631
- return null;
632
- }
633
- function buildQueryAgents(sourceFiles, save, wikiContent) {
634
- const sourceList = sourceFiles.map((f) => ` - ${f}`).join("\n");
635
- const wikiSection = wikiContent ? `## Knowledge Wiki (use this first)
636
-
637
- The wiki below contains knowledge already extracted from this knowledge base.
638
- If the user's question is covered here, answer directly from it \u2014 no need to re-read source files.
639
- Always cite the original source files mentioned in the wiki.
640
-
641
- ${wikiContent}
642
-
643
- ---
644
-
645
- ` : "";
646
- const sourceStep = wikiContent ? "If not covered in the wiki above: read the sources" : "How to answer";
647
- const lines = [
648
- `# llm-kb Knowledge Base \u2014 Query Mode`,
649
- ``,
650
- wikiSection,
651
- `## ${sourceStep}`,
652
- ``,
653
- `1. Read .llm-kb/wiki/index.md to understand all available sources`,
654
- `2. Select the most relevant source files (usually 2-5) and read them in full`,
655
- `3. Answer with inline citations: (filename, page number)`,
656
- `4. If you can't find the answer, say so \u2014 don't hallucinate`,
657
- ``,
658
- `## Available parsed sources`,
659
- sourceList,
660
- ``,
661
- `## Non-PDF files (docx, xlsx, pptx)`,
662
- `Use bash to run Node.js scripts. Libraries are pre-installed via require().`,
663
- ``,
664
- `### Word (.docx) \u2014 structured XML`,
665
- `.docx files are ZIP archives containing word/document.xml.`,
666
- `Read them SELECTIVELY \u2014 extract only what is relevant to the question:`,
667
- ``,
668
- "```javascript",
669
- `const AdmZip = require('adm-zip');`,
670
- `const zip = new AdmZip('file.docx');`,
671
- `const xml = zip.readAsText('word/document.xml');`,
672
- `// Parse XML to find specific paragraphs, headings, tables`,
673
- "```",
674
- ``,
675
- `Strategy for large .docx files:`,
676
- `1. First: extract headings/structure to understand the document layout`,
677
- `2. Then: extract only the sections relevant to the user's question`,
678
- `NEVER dump the entire document.`,
679
- ``,
680
- `### Excel (.xlsx) \u2014 use exceljs`,
681
- `Read specific sheets and ranges, not the whole workbook:`,
682
- ``,
683
- "```javascript",
684
- `const ExcelJS = require('exceljs');`,
685
- `const wb = new ExcelJS.Workbook();`,
686
- `await wb.xlsx.readFile('file.xlsx');`,
687
- `const sheet = wb.getWorksheet(1);`,
688
- `// Read specific rows/columns relevant to the question`,
689
- "```",
690
- ``,
691
- `### PowerPoint (.pptx) \u2014 use officeparser`,
692
- ``,
693
- "```javascript",
694
- `const officeparser = require('officeparser');`,
695
- `const text = await officeparser.parseOfficeAsync('file.pptx');`,
696
- "```",
697
- ``,
698
- `## Rules`,
699
- `- Always cite sources with filename and page number`,
700
- `- Read the FULL source file, not just the beginning (for .md sources)`,
701
- `- For non-PDF files, extract ONLY relevant sections \u2014 never dump entire files`,
702
- `- Prefer primary sources over previous analyses`,
703
- ``,
704
- `## Guidelines`,
705
- `A guidelines file may exist at .llm-kb/guidelines.md with learned rules from`,
706
- `past evaluations and user preferences. Read it when:`,
707
- `- You're unsure about citation accuracy or format`,
708
- `- You're about to read source files (guidelines may suggest using wiki instead)`,
709
- `- The question touches a topic that may have had issues in past evaluations`
710
- ];
711
- if (save) {
712
- lines.push(``, `## Research Mode`, `Save your analysis to .llm-kb/wiki/outputs/ with a descriptive filename.`, `Include the question at the top and all citations.`);
713
- }
714
- return lines.join("\n");
715
- }
716
- var WikiUpdateScheduler = class {
717
- constructor(everyN, everyMin) {
718
- this.everyN = everyN;
719
- this.everyMin = everyMin;
720
- }
721
- everyN;
722
- everyMin;
723
- stopMsgCount = 0;
724
- lastUpdateAt = 0;
725
- chain = Promise.resolve();
726
- shouldUpdate() {
727
- return this.stopMsgCount > 0 && this.stopMsgCount % this.everyN === 0 || this.lastUpdateAt > 0 && Date.now() - this.lastUpdateAt > this.everyMin * 6e4;
728
- }
729
- enqueue(work) {
730
- this.chain = this.chain.then(() => work().catch(() => {
731
- }));
732
- }
733
- onMessageEnd(msg, snap, doUpdate) {
734
- if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
735
- this.stopMsgCount++;
736
- if (this.shouldUpdate()) {
737
- this.lastUpdateAt = Date.now();
738
- this.enqueue(() => doUpdate(snap().messages));
739
- }
740
- }
741
- onAgentEnd(msgs, doUpdate) {
742
- this.lastUpdateAt = Date.now();
743
- this.enqueue(() => doUpdate(msgs));
744
- }
745
- flush() {
746
- return this.chain;
747
- }
748
- };
749
- function subscribeDisplay(session, opts) {
750
- const ui = opts.tuiDisplay;
751
- const dim = (s) => process.stdout.isTTY ? chalk3.dim(s) : s;
752
- const thinLine = () => dim("\u2500".repeat(process.stdout.columns || 80));
753
- let phase = "idle";
754
- let filesReadCount = 0;
755
- let shownToolCalls = /* @__PURE__ */ new Set();
756
- let startTime = Date.now();
757
- let md = new MarkdownStream(process.stdout.isTTY ?? false);
758
- let lastQuestion = "";
759
- const scheduler = new WikiUpdateScheduler(5, 3);
760
- const buildTrace2 = (messages) => {
761
- const last = [...messages].reverse().find((m) => m.role === "assistant" && m.stopReason === "stop");
762
- if (!last) return null;
763
- const filesRead = extractFilesRead(messages);
764
- return {
765
- sessionId: session.sessionId,
766
- sessionFile: session.sessionFile ?? "",
767
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
768
- mode: "query",
769
- question: lastQuestion,
770
- answer: extractAnswerText(last.content),
771
- filesRead,
772
- filesAvailable: opts.mdFiles,
773
- filesSkipped: opts.mdFiles.filter((f) => !filesRead.some((r) => r.endsWith(f))),
774
- model: last.model
775
- };
776
- };
777
- const doUpdate = async (messages) => {
778
- const trace = buildTrace2(messages);
779
- if (!trace) return;
780
- await saveTrace(opts.folder, trace);
781
- await appendToQueryLog(opts.folder, trace);
782
- await updateWiki(opts.folder, trace, opts.authStorage);
783
- };
784
- session.subscribe((event) => {
785
- if (event.type === "agent_start") {
786
- phase = "idle";
787
- filesReadCount = 0;
788
- shownToolCalls = /* @__PURE__ */ new Set();
789
- startTime = Date.now();
790
- md = new MarkdownStream(process.stdout.isTTY ?? false);
791
- const modelName = opts.modelId ?? "claude-sonnet-4-6";
792
- if (ui) {
793
- ui.disableInput();
794
- ui.beginResponse(modelName);
795
- } else process.stdout.write(dim(`\u27E1 ${modelName}`) + "\n");
796
- }
797
- if (event.type === "message_update") {
798
- const ae = event.assistantMessageEvent;
799
- if (ae.type === "thinking_start") {
800
- if (!ui) process.stdout.write(dim("\n\u25B8 Thinking\n"));
801
- phase = "thinking";
802
- }
803
- if (ae.type === "thinking_delta") {
804
- if (ui) ui.appendThinking(ae.delta);
805
- else process.stdout.write(dim(` ${ae.delta}`));
806
- }
807
- if (ae.type === "thinking_end") {
808
- if (ui) ui.endThinking();
809
- else process.stdout.write("\n");
810
- }
811
- }
812
- if (event.type === "message_update") {
813
- const ae = event.assistantMessageEvent;
814
- if (ae.type === "toolcall_end" && ae.toolCall) {
815
- const label = getToolLabel(ae.toolCall.name, ae.toolCall.arguments);
816
- if (label) {
817
- if (!ui && phase !== "tools") process.stdout.write("\n");
818
- phase = "tools";
819
- if (ui) {
820
- ui.addToolCall(ae.toolCall.id, label, ae.toolCall.name);
821
- if (ae.toolCall.name === "bash" && ae.toolCall.arguments?.command) {
822
- ui.addCodeBlock(ae.toolCall.arguments.command);
823
- }
824
- } else {
825
- process.stdout.write(dim(` \u25B8 ${label}`) + "\n");
826
- if (ae.toolCall.name === "bash" && ae.toolCall.arguments?.command) {
827
- const code = ae.toolCall.arguments.command;
828
- process.stdout.write(dim(code.split("\n").map((l) => ` ${l}`).join("\n")) + "\n");
829
- }
830
- shownToolCalls.add(ae.toolCall.id);
831
- if (ae.toolCall.name === "read") filesReadCount++;
832
- }
833
- }
834
- }
835
- }
836
- if (event.type === "tool_execution_start") {
837
- const { toolCallId, toolName, args } = event;
838
- if (ui) {
839
- const label = getToolLabel(toolName, args);
840
- if (label) ui.addToolCall(toolCallId, label, toolName);
841
- } else if (!shownToolCalls.has(toolCallId)) {
842
- const label = getToolLabel(toolName, args);
843
- if (label) {
844
- if (phase !== "tools") process.stdout.write("\n");
845
- phase = "tools";
846
- process.stdout.write(dim(` \u25B8 ${label}`) + "\n");
847
- shownToolCalls.add(toolCallId);
848
- if (toolName === "read") filesReadCount++;
849
- }
850
- }
851
- }
852
- if (event.type === "tool_execution_end") {
853
- const { toolCallId, isError } = event;
854
- if (ui) ui.addToolResult(toolCallId, isError);
855
- }
856
- if (event.type === "message_update") {
857
- const ae = event.assistantMessageEvent;
858
- if (ae.type === "text_start" && phase !== "answer") {
859
- if (ui) ui.beginAnswer();
860
- else if (phase === "thinking" || phase === "tools") {
861
- process.stdout.write(`
862
- ${thinLine()}
863
-
864
- `);
865
- }
866
- phase = "answer";
867
- }
868
- if (ae.type === "text_delta") {
869
- if (ui) ui.appendAnswer(ae.delta);
870
- else process.stdout.write(md.push(ae.delta));
871
- }
872
- if (ae.type === "text_end" && !ui) process.stdout.write(md.end());
873
- }
874
- if (event.type === "agent_end") {
875
- if (ui) {
876
- ui.showCompletion();
877
- ui.enableInput();
878
- } else {
879
- const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
880
- const source = filesReadCount > 0 ? `${filesReadCount} file${filesReadCount !== 1 ? "s" : ""} read` : "wiki";
881
- const stats = `${elapsed}s \xB7 ${source}`;
882
- const cols = process.stdout.columns || 80;
883
- const pad = Math.max(0, cols - stats.length - 4);
884
- process.stdout.write(`
885
-
886
- ${dim("\u2500\u2500 " + stats + " " + "\u2500".repeat(pad))}
887
- `);
888
- }
889
- scheduler.onAgentEnd(event.messages, doUpdate);
890
- }
891
- if (event.type === "message_end") {
892
- scheduler.onMessageEnd(event.message, () => ({ messages: session.state.messages }), doUpdate);
893
- }
894
- });
895
- return {
896
- setQuestion(q) {
897
- lastQuestion = q;
898
- },
899
- flush() {
900
- return scheduler.flush();
901
- }
902
- };
903
- }
904
- async function createChat(folder, options) {
905
- const sourcesDir = join6(folder, ".llm-kb", "wiki", "sources");
906
- const files = await readdir4(sourcesDir);
907
- const mdFiles = files.filter((f) => f.endsWith(".md"));
908
- if (mdFiles.length === 0) throw new Error("No sources found. Run 'llm-kb run' first.");
909
- if (options.save) await mkdir5(join6(folder, ".llm-kb", "wiki", "outputs"), { recursive: true });
910
- process.env.NODE_PATH = getNodeModulesPath();
911
- const wikiPath = join6(folder, ".llm-kb", "wiki", "wiki.md");
912
- const wikiContent = existsSync4(wikiPath) ? await readFile4(wikiPath, "utf-8").catch(() => "") : "";
913
- const agentsContent = buildQueryAgents(mdFiles, !!options.save, wikiContent);
914
- const loader = new DefaultResourceLoader({
915
- cwd: folder,
916
- agentsFilesOverride: (current) => ({
917
- agentsFiles: [...current.agentsFiles, { path: ".llm-kb/AGENTS.md", content: agentsContent }]
918
- })
919
- });
920
- await loader.reload();
921
- const tools = [
922
- createReadTool(folder),
923
- createBashTool(folder),
924
- createWriteTool(folder)
925
- ];
926
- const model = options.modelId ? getModels("anthropic").find((m) => m.id === options.modelId) : void 0;
927
- const { session } = await createAgentSession({
928
- cwd: folder,
929
- resourceLoader: loader,
930
- tools,
931
- sessionManager: options.save ? await createKBSession(folder) : await continueKBSession(folder),
932
- settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
933
- thinkingLevel: "low",
934
- ...options.authStorage ? { authStorage: options.authStorage } : {},
935
- ...model ? { model } : {}
936
- });
937
- const display = subscribeDisplay(session, {
938
- modelId: options.modelId,
939
- authStorage: options.authStorage,
940
- folder,
941
- mdFiles,
942
- tuiDisplay: options.tuiDisplay
943
- });
944
- return { session, display };
945
- }
946
- async function query(folder, question, options) {
947
- const { session, display } = await createChat(folder, options);
948
- session.setSessionName(`query: ${question}`);
949
- display.setQuestion(question);
950
- await session.prompt(question);
951
- await display.flush();
952
- session.dispose();
953
- if (options.save) {
954
- const sourcesDir = join6(folder, ".llm-kb", "wiki", "sources");
955
- const { buildIndex: buildIndex2 } = await import("./indexer-KSYRIVVN.js");
956
- await buildIndex2(folder, sourcesDir, void 0, options.authStorage);
957
- }
958
- }
959
-
960
305
  // src/eval.ts
961
- import { AuthStorage as AuthStorage3 } from "@mariozechner/pi-coding-agent";
962
- import { readFile as readFile5, readdir as readdir5, writeFile as writeFile5, mkdir as mkdir6 } from "fs/promises";
963
- import { existsSync as existsSync5 } from "fs";
964
- import { join as join7, basename as basename5 } from "path";
965
- import { homedir as homedir2 } from "os";
306
+ import { readFile as readFile2, readdir as readdir3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
307
+ import { existsSync as existsSync2 } from "fs";
308
+ import { join as join4, basename as basename4 } from "path";
966
309
  async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
967
- if (!existsSync5(sessionsDir)) return [];
968
- const sessionFiles = (await readdir5(sessionsDir)).filter((f) => f.endsWith(".jsonl")).sort().reverse();
310
+ if (!existsSync2(sessionsDir)) return [];
311
+ const sessionFiles = (await readdir3(sessionsDir)).filter((f) => f.endsWith(".jsonl")).sort().reverse();
969
312
  const files = limit ? sessionFiles.slice(0, limit) : sessionFiles;
970
313
  const qas = [];
971
314
  let filesAvailable = [];
972
315
  try {
973
- filesAvailable = (await readdir5(sourcesDir)).filter((f) => f.endsWith(".md"));
316
+ filesAvailable = (await readdir3(sourcesDir)).filter((f) => f.endsWith(".md"));
974
317
  } catch {
975
318
  }
976
319
  for (const file of files) {
977
320
  try {
978
- const raw = await readFile5(join7(sessionsDir, file), "utf-8");
321
+ const raw = await readFile2(join4(sessionsDir, file), "utf-8");
979
322
  const lines = raw.trim().split("\n").filter(Boolean);
980
323
  const entries = [];
981
324
  for (const line of lines) {
@@ -1000,6 +343,7 @@ async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
1000
343
  if (!msg) continue;
1001
344
  if (msg.role === "user") {
1002
345
  if (currentQuestion && currentAnswer) {
346
+ const parsed = parseCitations(currentAnswer);
1003
347
  qas.push({
1004
348
  sessionFile: file,
1005
349
  question: currentQuestion,
@@ -1011,10 +355,11 @@ async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
1011
355
  ),
1012
356
  answer: currentAnswer,
1013
357
  model: currentModel,
1014
- durationMs: endTs - startTs
358
+ durationMs: endTs - startTs,
359
+ citations: parsed.citations
1015
360
  });
1016
361
  }
1017
- currentQuestion = extractText2(msg.content);
362
+ currentQuestion = extractText(msg.content);
1018
363
  currentThinking = "";
1019
364
  currentFilesRead = [];
1020
365
  currentAnswer = "";
@@ -1035,7 +380,7 @@ async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
1035
380
  for (const block of prev.message?.content ?? []) {
1036
381
  if (block.type === "toolCall" && block.id === toolCallId && block.name === "read") {
1037
382
  const path3 = block.arguments?.path ?? "";
1038
- const content = extractText2(msg.content);
383
+ const content = extractText(msg.content);
1039
384
  if (path3 && content) {
1040
385
  currentFilesRead.push({ path: path3, content: content.slice(0, 2e3) });
1041
386
  }
@@ -1045,6 +390,7 @@ async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
1045
390
  }
1046
391
  }
1047
392
  if (currentQuestion && currentAnswer) {
393
+ const parsed = parseCitations(currentAnswer);
1048
394
  qas.push({
1049
395
  sessionFile: file,
1050
396
  question: currentQuestion,
@@ -1056,7 +402,8 @@ async function parseSessionsForEval(sessionsDir, sourcesDir, limit) {
1056
402
  ),
1057
403
  answer: currentAnswer,
1058
404
  model: currentModel,
1059
- durationMs: endTs - startTs
405
+ durationMs: endTs - startTs,
406
+ citations: parsed.citations
1060
407
  });
1061
408
  }
1062
409
  } catch {
@@ -1084,13 +431,37 @@ function calculateMetrics(qas) {
1084
431
  }
1085
432
  for (const f of sourceFilesRead) {
1086
433
  totalFilesRead++;
1087
- const name = basename5(f.path);
434
+ const name = basename4(f.path);
1088
435
  uniqueFiles.set(name, (uniqueFiles.get(name) ?? 0) + 1);
1089
436
  if (!qa.answer.includes(name) && !qa.answer.includes(name.replace(".md", ""))) {
1090
437
  wastedReads++;
1091
438
  }
1092
439
  }
1093
440
  }
441
+ let totalCitations = 0;
442
+ let withBbox = 0;
443
+ let withoutBbox = 0;
444
+ let multiPage = 0;
445
+ let answersWithCitations = 0;
446
+ let answersWithoutCitations = 0;
447
+ for (const qa of qas) {
448
+ if (qa.citations.length > 0) {
449
+ answersWithCitations++;
450
+ for (const c of qa.citations) {
451
+ totalCitations++;
452
+ if (c.bbox || c.pages && c.pages.length > 0) {
453
+ withBbox++;
454
+ } else {
455
+ withoutBbox++;
456
+ }
457
+ if (c.pages && c.pages.length > 0) {
458
+ multiPage++;
459
+ }
460
+ }
461
+ } else {
462
+ answersWithoutCitations++;
463
+ }
464
+ }
1094
465
  return {
1095
466
  totalSessions: uniqueSessions.size,
1096
467
  totalQAs: qas.length,
@@ -1099,23 +470,21 @@ function calculateMetrics(qas) {
1099
470
  sourceReads,
1100
471
  totalFilesRead,
1101
472
  uniqueFilesRead: uniqueFiles,
1102
- wastedReads
473
+ wastedReads,
474
+ citations: {
475
+ totalCitations,
476
+ withBbox,
477
+ withoutBbox,
478
+ multiPage,
479
+ avgPerAnswer: qas.length > 0 ? totalCitations / qas.length : 0,
480
+ answersWithCitations,
481
+ answersWithoutCitations
482
+ }
1103
483
  };
1104
484
  }
1105
- async function resolveApiKey2(authStorage) {
1106
- if (authStorage) return authStorage.getApiKey("anthropic");
1107
- const piAuthPath = join7(homedir2(), ".pi", "agent", "auth.json");
1108
- if (existsSync5(piAuthPath)) {
1109
- const storage = AuthStorage3.create(piAuthPath);
1110
- return storage.getApiKey("anthropic");
1111
- }
1112
- return process.env.ANTHROPIC_API_KEY;
1113
- }
1114
- async function judgeQA(qa, apiKey, modelId) {
485
+ async function judgeQA(qa, modelId, authStorage) {
1115
486
  const issues = [];
1116
- const model = getModels("anthropic").find((m) => m.id === modelId);
1117
- if (!model) return issues;
1118
- const filesSummary = qa.filesRead.map((f) => `File: ${basename5(f.path)}
487
+ const filesSummary = qa.filesRead.map((f) => `File: ${basename4(f.path)}
1119
488
  Content (first 2000 chars):
1120
489
  ${f.content}`).join("\n\n---\n\n");
1121
490
  const skippedList = qa.filesSkipped.join(", ") || "none";
@@ -1148,13 +517,14 @@ Checks:
1148
517
  Return ONLY a JSON array. If no issues found, return [].
1149
518
  Example: [{"type":"wiki-gap","severity":"warning","detail":"Electronic evidence topic not in wiki","recommendation":"Add electronic evidence section to wiki"}]`;
1150
519
  try {
1151
- const result = await completeSimple(
1152
- model,
520
+ const result = await completeWithFallback(
521
+ modelId,
522
+ authStorage,
523
+ "eval",
1153
524
  {
1154
525
  systemPrompt: "You are a precise QA evaluator. Return only valid JSON arrays. No explanation.",
1155
526
  messages: [{ role: "user", content: prompt, timestamp: Date.now() }]
1156
- },
1157
- { apiKey }
527
+ }
1158
528
  );
1159
529
  const text = result.content.filter((b) => b.type === "text").map((b) => b.text).join("").trim();
1160
530
  const jsonMatch = text.match(/\[[\s\S]*\]/);
@@ -1193,6 +563,18 @@ function buildReport(result) {
1193
563
  lines.push(`| Total file reads | ${metrics.totalFilesRead} |`);
1194
564
  lines.push(`| Wasted reads | ${metrics.wastedReads} |`);
1195
565
  lines.push(``);
566
+ const cm = metrics.citations;
567
+ lines.push(`## Citations`);
568
+ lines.push(``);
569
+ lines.push(`| Metric | Value |`);
570
+ lines.push(`|---|---|`);
571
+ lines.push(`| Total citations | ${cm.totalCitations} |`);
572
+ lines.push(`| Avg per answer | ${cm.avgPerAnswer.toFixed(1)} |`);
573
+ lines.push(`| With bbox | ${cm.withBbox} (${cm.totalCitations > 0 ? Math.round(cm.withBbox / cm.totalCitations * 100) : 0}%) |`);
574
+ lines.push(`| Without bbox | ${cm.withoutBbox} |`);
575
+ lines.push(`| Multi-page | ${cm.multiPage} |`);
576
+ lines.push(`| Answers with citations | ${cm.answersWithCitations}/${metrics.totalQAs} (${metrics.totalQAs > 0 ? Math.round(cm.answersWithCitations / metrics.totalQAs * 100) : 0}%) |`);
577
+ lines.push(``);
1196
578
  if (metrics.uniqueFilesRead.size > 0) {
1197
579
  lines.push(`### Most Read Files`);
1198
580
  lines.push(``);
@@ -1278,6 +660,20 @@ function buildAgentsInsights(result) {
1278
660
  lines.push(``);
1279
661
  }
1280
662
  }
663
+ const cm = metrics.citations;
664
+ if (cm.totalCitations > 0) {
665
+ const bboxRate = Math.round(cm.withBbox / cm.totalCitations * 100);
666
+ lines.push(`### Citation Quality`);
667
+ lines.push(`- Bbox coverage: ${bboxRate}% (target: 100%)`);
668
+ lines.push(`- Avg citations per answer: ${cm.avgPerAnswer.toFixed(1)}`);
669
+ if (cm.withoutBbox > 0) {
670
+ lines.push(`- ${cm.withoutBbox} citations missing bbox \u2014 agent should always read .json files`);
671
+ }
672
+ if (cm.answersWithoutCitations > 0) {
673
+ lines.push(`- ${cm.answersWithoutCitations} answers had no citations \u2014 every answer must cite sources`);
674
+ }
675
+ lines.push(``);
676
+ }
1281
677
  const hitRate = metrics.totalQAs > 0 ? Math.round(metrics.wikiHits / metrics.totalQAs * 100) : 0;
1282
678
  lines.push(`### Performance`);
1283
679
  lines.push(`- Wiki hit rate: ${hitRate}% (target: 80%+)`);
@@ -1286,8 +682,8 @@ function buildAgentsInsights(result) {
1286
682
  return lines.join("\n");
1287
683
  }
1288
684
  async function runEval(kbRoot, options) {
1289
- const sessionsDir = join7(kbRoot, ".llm-kb", "sessions");
1290
- const sourcesDir = join7(kbRoot, ".llm-kb", "wiki", "sources");
685
+ const sessionsDir = join4(kbRoot, ".llm-kb", "sessions");
686
+ const sourcesDir = join4(kbRoot, ".llm-kb", "wiki", "sources");
1291
687
  const log = options.onProgress ?? (() => {
1292
688
  });
1293
689
  log("Reading sessions...");
@@ -1295,7 +691,7 @@ async function runEval(kbRoot, options) {
1295
691
  log(`Found ${qas.length} Q&A exchanges across sessions`);
1296
692
  if (qas.length === 0) {
1297
693
  return {
1298
- metrics: { totalSessions: 0, totalQAs: 0, avgDurationMs: 0, wikiHits: 0, sourceReads: 0, totalFilesRead: 0, uniqueFilesRead: /* @__PURE__ */ new Map(), wastedReads: 0 },
694
+ metrics: { totalSessions: 0, totalQAs: 0, avgDurationMs: 0, wikiHits: 0, sourceReads: 0, totalFilesRead: 0, uniqueFilesRead: /* @__PURE__ */ new Map(), wastedReads: 0, citations: { totalCitations: 0, withBbox: 0, withoutBbox: 0, multiPage: 0, avgPerAnswer: 0, answersWithCitations: 0, answersWithoutCitations: 0 } },
1299
695
  issues: [],
1300
696
  wikiGaps: [],
1301
697
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -1303,17 +699,16 @@ async function runEval(kbRoot, options) {
1303
699
  }
1304
700
  log("Calculating metrics...");
1305
701
  const metrics = calculateMetrics(qas);
1306
- const apiKey = await resolveApiKey2(options.authStorage);
1307
702
  const allIssues = [];
1308
- if (apiKey) {
1309
- const modelId = "claude-haiku-4-5";
703
+ const modelId = "claude-haiku-4-5";
704
+ try {
1310
705
  for (let i = 0; i < qas.length; i++) {
1311
706
  log(`Judging ${i + 1}/${qas.length}: "${qas[i].question.slice(0, 50)}..."`);
1312
- const issues = await judgeQA(qas[i], apiKey, modelId);
707
+ const issues = await judgeQA(qas[i], modelId, options.authStorage);
1313
708
  allIssues.push(...issues);
1314
709
  }
1315
- } else {
1316
- log("No API key \u2014 skipping LLM judge checks");
710
+ } catch (err) {
711
+ log(`LLM judge unavailable \u2014 skipping (${err instanceof Error ? err.message : String(err)})`);
1317
712
  }
1318
713
  const wikiGaps = allIssues.filter((i) => i.type === "wiki-gap").map((i) => i.detail);
1319
714
  const result = {
@@ -1325,11 +720,11 @@ async function runEval(kbRoot, options) {
1325
720
  };
1326
721
  result.agentsInsights = buildAgentsInsights(result);
1327
722
  log("Writing eval report + insights...");
1328
- const outputsDir = join7(kbRoot, ".llm-kb", "wiki", "outputs");
1329
- await mkdir6(outputsDir, { recursive: true });
723
+ const outputsDir = join4(kbRoot, ".llm-kb", "wiki", "outputs");
724
+ await mkdir3(outputsDir, { recursive: true });
1330
725
  const report = buildReport(result);
1331
- await writeFile5(join7(outputsDir, "eval-report.md"), report, "utf-8");
1332
- const guidelinesPath = join7(kbRoot, ".llm-kb", "guidelines.md");
726
+ await writeFile3(join4(outputsDir, "eval-report.md"), report, "utf-8");
727
+ const guidelinesPath = join4(kbRoot, ".llm-kb", "guidelines.md");
1333
728
  await writeGuidelines(guidelinesPath, result.agentsInsights);
1334
729
  log("Insights saved to .llm-kb/guidelines.md (agent reads on-demand)");
1335
730
  return result;
@@ -1338,20 +733,20 @@ var EVAL_SECTION_RE = /## Eval Insights[\s\S]*?(?=\n## |$)/;
1338
733
  async function writeGuidelines(path3, evalSection) {
1339
734
  let existing = "";
1340
735
  try {
1341
- existing = await readFile5(path3, "utf-8");
736
+ existing = await readFile2(path3, "utf-8");
1342
737
  } catch {
1343
738
  }
1344
739
  if (!existing) {
1345
- await writeFile5(path3, evalSection, "utf-8");
740
+ await writeFile3(path3, evalSection, "utf-8");
1346
741
  return;
1347
742
  }
1348
743
  if (EVAL_SECTION_RE.test(existing)) {
1349
- await writeFile5(path3, existing.replace(EVAL_SECTION_RE, evalSection.trim()), "utf-8");
744
+ await writeFile3(path3, existing.replace(EVAL_SECTION_RE, evalSection.trim()), "utf-8");
1350
745
  } else {
1351
- await writeFile5(path3, evalSection + "\n\n" + existing, "utf-8");
746
+ await writeFile3(path3, evalSection + "\n\n" + existing, "utf-8");
1352
747
  }
1353
748
  }
1354
- function extractText2(content) {
749
+ function extractText(content) {
1355
750
  if (!content) return "";
1356
751
  if (typeof content === "string") return content;
1357
752
  if (Array.isArray(content)) {
@@ -5247,33 +4642,33 @@ var ProcessTerminal = class {
5247
4642
  };
5248
4643
 
5249
4644
  // src/tui-display.ts
5250
- import chalk4 from "chalk";
4645
+ import chalk2 from "chalk";
5251
4646
  function createMarkdownTheme() {
5252
4647
  return {
5253
- heading: (t) => chalk4.bold(t),
5254
- link: (t) => chalk4.cyan(t),
5255
- linkUrl: (t) => chalk4.dim(t),
5256
- code: (t) => chalk4.cyan(t),
5257
- codeBlock: (t) => chalk4.dim(t),
5258
- codeBlockBorder: (t) => chalk4.dim(t),
5259
- quote: (t) => chalk4.italic(t),
5260
- quoteBorder: (t) => chalk4.dim(t),
5261
- hr: (t) => chalk4.dim(t),
5262
- listBullet: (t) => chalk4.dim(t),
5263
- bold: (t) => chalk4.bold(t),
5264
- italic: (t) => chalk4.italic(t),
5265
- underline: (t) => chalk4.underline(t),
5266
- strikethrough: (t) => chalk4.strikethrough(t)
4648
+ heading: (t) => chalk2.bold(t),
4649
+ link: (t) => chalk2.cyan(t),
4650
+ linkUrl: (t) => chalk2.dim(t),
4651
+ code: (t) => chalk2.cyan(t),
4652
+ codeBlock: (t) => chalk2.dim(t),
4653
+ codeBlockBorder: (t) => chalk2.dim(t),
4654
+ quote: (t) => chalk2.italic(t),
4655
+ quoteBorder: (t) => chalk2.dim(t),
4656
+ hr: (t) => chalk2.dim(t),
4657
+ listBullet: (t) => chalk2.dim(t),
4658
+ bold: (t) => chalk2.bold(t),
4659
+ italic: (t) => chalk2.italic(t),
4660
+ underline: (t) => chalk2.underline(t),
4661
+ strikethrough: (t) => chalk2.strikethrough(t)
5267
4662
  };
5268
4663
  }
5269
4664
  var mdTheme = createMarkdownTheme();
5270
4665
  function dimText(text, px = 1, py = 0) {
5271
- return new Text(chalk4.dim(text), px, py);
4666
+ return new Text(chalk2.dim(text), px, py);
5272
4667
  }
5273
4668
  var HRule = class {
5274
4669
  colorFn;
5275
4670
  constructor(colorFn) {
5276
- this.colorFn = colorFn ?? chalk4.dim;
4671
+ this.colorFn = colorFn ?? chalk2.dim;
5277
4672
  }
5278
4673
  invalidate() {
5279
4674
  }
@@ -5295,6 +4690,8 @@ var ChatDisplay = class {
5295
4690
  // active thinking block
5296
4691
  hadSeparator = false;
5297
4692
  // has a ─── line been drawn?
4693
+ accumulatedAnswer = "";
4694
+ // full answer text for citation stripping
5298
4695
  filesReadCount = 0;
5299
4696
  shownToolCalls = /* @__PURE__ */ new Set();
5300
4697
  startTime = Date.now();
@@ -5306,7 +4703,7 @@ var ChatDisplay = class {
5306
4703
  this.messageArea = new Container();
5307
4704
  this.tui.addChild(this.messageArea);
5308
4705
  this.inputArea = new Container();
5309
- this.inputArea.addChild(new HRule((s) => chalk4.hex("#c678dd")(s)));
4706
+ this.inputArea.addChild(new HRule((s) => chalk2.hex("#c678dd")(s)));
5310
4707
  this.input = new Input();
5311
4708
  this.input.onSubmit = (text) => {
5312
4709
  if (text.trim() && this.onSubmit) {
@@ -5316,7 +4713,7 @@ var ChatDisplay = class {
5316
4713
  this.input.setValue("");
5317
4714
  };
5318
4715
  this.inputArea.addChild(this.input);
5319
- this.inputArea.addChild(new HRule((s) => chalk4.hex("#c678dd")(s)));
4716
+ this.inputArea.addChild(new HRule((s) => chalk2.hex("#c678dd")(s)));
5320
4717
  this.tui.addChild(this.inputArea);
5321
4718
  this.tui.setFocus(this.input);
5322
4719
  }
@@ -5338,7 +4735,7 @@ var ChatDisplay = class {
5338
4735
  }
5339
4736
  addUserMessage(text) {
5340
4737
  this.messageArea.addChild(new Spacer(1));
5341
- this.messageArea.addChild(new Text(chalk4.bold(text), 1, 0));
4738
+ this.messageArea.addChild(new Text(chalk2.bold(text), 1, 0));
5342
4739
  this.tui.requestRender();
5343
4740
  }
5344
4741
  // ── Per-prompt lifecycle (events arrive in any order) ───────────────────
@@ -5349,6 +4746,7 @@ var ChatDisplay = class {
5349
4746
  this.currentMd = null;
5350
4747
  this.currentThinking = null;
5351
4748
  this.hadSeparator = false;
4749
+ this.accumulatedAnswer = "";
5352
4750
  this.currentResponse = new Container();
5353
4751
  this.currentResponse.addChild(new Spacer(1));
5354
4752
  this.currentResponse.addChild(dimText(`\u27E1 ${modelName}`));
@@ -5362,12 +4760,12 @@ var ChatDisplay = class {
5362
4760
  if (!this.currentThinking) {
5363
4761
  this.currentResponse.addChild(new Spacer(1));
5364
4762
  this.currentResponse.addChild(dimText("\u25B8 Thinking"));
5365
- this.currentThinking = new Text(chalk4.dim(chalk4.italic(text)), 2, 0);
4763
+ this.currentThinking = new Text(chalk2.dim(chalk2.italic(text)), 2, 0);
5366
4764
  this.currentResponse.addChild(this.currentThinking);
5367
4765
  } else {
5368
4766
  const prev = this.currentThinking.text ?? "";
5369
4767
  this.currentThinking.setText(
5370
- chalk4.dim(chalk4.italic(prev.replace(/\x1b\[[0-9;]*m/g, "") + text))
4768
+ chalk2.dim(chalk2.italic(prev.replace(/\x1b\[[0-9;]*m/g, "") + text))
5371
4769
  );
5372
4770
  }
5373
4771
  this.tui.requestRender();
@@ -5396,7 +4794,7 @@ var ChatDisplay = class {
5396
4794
  addToolResult(toolCallId, isError) {
5397
4795
  if (!this.currentResponse) return;
5398
4796
  if (isError) {
5399
- this.currentResponse.addChild(new Text(chalk4.red(" \u2717 failed"), 0, 0));
4797
+ this.currentResponse.addChild(new Text(chalk2.red(" \u2717 failed"), 0, 0));
5400
4798
  this.tui.requestRender();
5401
4799
  }
5402
4800
  }
@@ -5427,19 +4825,61 @@ var ChatDisplay = class {
5427
4825
  }
5428
4826
  const prev = this.currentMd.text ?? "";
5429
4827
  this.currentMd.setText(prev + text);
4828
+ this.accumulatedAnswer += text;
5430
4829
  this.tui.requestRender();
5431
4830
  }
5432
- showCompletion() {
4831
+ /** Strip CITATIONS block from answer and show formatted citation footer */
4832
+ showCitations(citations) {
4833
+ if (!this.currentResponse || citations.length === 0) return;
4834
+ if (this.currentMd) {
4835
+ const citIdx = this.accumulatedAnswer.search(/^CITATIONS:\s*$/im);
4836
+ if (citIdx >= 0) {
4837
+ const clean = this.accumulatedAnswer.slice(0, citIdx).trimEnd();
4838
+ this.currentMd.setText(clean);
4839
+ }
4840
+ }
4841
+ this.currentResponse.addChild(new Spacer(1));
4842
+ this.currentResponse.addChild(new HRule());
4843
+ for (let i = 0; i < citations.length; i++) {
4844
+ const c = citations[i];
4845
+ const num = chalk2.bold(` [${i + 1}]`);
4846
+ const file = chalk2.cyan(c.file);
4847
+ const pageStr = c.pages && c.pages.length > 0 ? `p.${c.pages.map((p) => p.page).join("-")}` : `p.${c.page}`;
4848
+ let status;
4849
+ if (c.bbox || c.pages && c.pages.length > 0) {
4850
+ const bboxInfo = c.pages && c.pages.length > 0 ? `(${c.pages.length} pages)` : c.bbox ? `(${c.bbox.x},${c.bbox.y} \u2192 ${Math.round(c.bbox.x + c.bbox.width)},${Math.round(c.bbox.y + c.bbox.height)})` : "";
4851
+ status = chalk2.green(`\u2705 bbox ${bboxInfo}`);
4852
+ } else {
4853
+ status = chalk2.yellow(`\u26A0\uFE0F no bbox`);
4854
+ }
4855
+ const quote = c.quote.length > 60 ? c.quote.slice(0, 57) + "..." : c.quote;
4856
+ this.currentResponse.addChild(
4857
+ new Text(`${num} \u{1F4C4} ${file}, ${pageStr}`, 0, 0)
4858
+ );
4859
+ this.currentResponse.addChild(
4860
+ new Text(chalk2.dim(` "${quote}"`), 0, 0)
4861
+ );
4862
+ this.currentResponse.addChild(
4863
+ new Text(` ${status}`, 0, 0)
4864
+ );
4865
+ }
4866
+ this.tui.requestRender();
4867
+ }
4868
+ showCompletion(citations) {
5433
4869
  if (!this.currentResponse) return;
4870
+ if (citations && citations.length > 0) {
4871
+ this.showCitations(citations);
4872
+ }
5434
4873
  const elapsed = ((Date.now() - this.startTime) / 1e3).toFixed(1);
5435
4874
  const source = this.filesReadCount > 0 ? `${this.filesReadCount} file${this.filesReadCount !== 1 ? "s" : ""} read` : "wiki";
5436
- const stats = `\u2500\u2500 ${elapsed}s \xB7 ${source} `;
4875
+ const citCount = citations && citations.length > 0 ? ` \xB7 ${citations.length} citation${citations.length !== 1 ? "s" : ""}` : "";
4876
+ const stats = `\u2500\u2500 ${elapsed}s \xB7 ${source}${citCount} `;
5437
4877
  const completion = {
5438
4878
  invalidate() {
5439
4879
  },
5440
4880
  render(width) {
5441
4881
  const pad = Math.max(0, width - stats.length);
5442
- return [chalk4.dim(stats + "\u2500".repeat(pad))];
4882
+ return [chalk2.dim(stats + "\u2500".repeat(pad))];
5443
4883
  }
5444
4884
  };
5445
4885
  this.currentResponse.addChild(new Spacer(1));
@@ -5459,12 +4899,12 @@ var ChatDisplay = class {
5459
4899
  };
5460
4900
 
5461
4901
  // src/resolve-kb.ts
5462
- import { existsSync as existsSync6 } from "fs";
5463
- import { resolve as resolve2, join as join10, dirname as dirname2 } from "path";
4902
+ import { existsSync as existsSync3 } from "fs";
4903
+ import { resolve as resolve2, join as join7, dirname as dirname2 } from "path";
5464
4904
  function resolveKnowledgeBase(startDir) {
5465
4905
  let dir = resolve2(startDir);
5466
4906
  while (true) {
5467
- if (existsSync6(join10(dir, ".llm-kb"))) {
4907
+ if (existsSync3(join7(dir, ".llm-kb"))) {
5468
4908
  return dir;
5469
4909
  }
5470
4910
  const parent = dirname2(dir);
@@ -5474,39 +4914,53 @@ function resolveKnowledgeBase(startDir) {
5474
4914
  }
5475
4915
 
5476
4916
  // src/auth.ts
5477
- import { existsSync as existsSync7 } from "fs";
5478
- import { join as join11 } from "path";
5479
- import { homedir as homedir4 } from "os";
5480
- import { AuthStorage as AuthStorage4 } from "@mariozechner/pi-coding-agent";
5481
- import chalk5 from "chalk";
4917
+ import { existsSync as existsSync4 } from "fs";
4918
+ import { join as join8 } from "path";
4919
+ import { homedir as homedir2 } from "os";
4920
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
4921
+ import chalk3 from "chalk";
5482
4922
  function checkAuth() {
5483
- const piAuthPath = join11(homedir4(), ".pi", "agent", "auth.json");
5484
- if (existsSync7(piAuthPath)) {
5485
- const authStorage = AuthStorage4.create();
5486
- return { ok: true, method: "pi-sdk", authStorage };
4923
+ const piAuthPath = join8(homedir2(), ".pi", "agent", "auth.json");
4924
+ if (existsSync4(piAuthPath)) {
4925
+ const authStorage = AuthStorage.create();
4926
+ return { ok: true, method: "pi-sdk", authStorage, providers: ["pi-sdk"] };
5487
4927
  }
4928
+ const providers = {};
4929
+ const names = [];
5488
4930
  if (process.env.ANTHROPIC_API_KEY) {
5489
- const authStorage = AuthStorage4.inMemory({
5490
- anthropic: { type: "api_key", key: process.env.ANTHROPIC_API_KEY }
5491
- });
5492
- return { ok: true, method: "api-key", authStorage };
4931
+ providers.anthropic = { type: "api_key", key: process.env.ANTHROPIC_API_KEY };
4932
+ names.push("anthropic");
4933
+ }
4934
+ if (process.env.OPENROUTER_API_KEY) {
4935
+ providers.openrouter = { type: "api_key", key: process.env.OPENROUTER_API_KEY };
4936
+ names.push("openrouter");
4937
+ }
4938
+ if (process.env.OPENAI_API_KEY) {
4939
+ providers.openai = { type: "api_key", key: process.env.OPENAI_API_KEY };
4940
+ names.push("openai");
4941
+ }
4942
+ if (names.length > 0) {
4943
+ const authStorage = AuthStorage.inMemory(providers);
4944
+ return { ok: true, method: "env", authStorage, providers: names };
5493
4945
  }
5494
4946
  return { ok: false };
5495
4947
  }
5496
4948
  function exitWithAuthError() {
5497
- console.error(chalk5.red("\n No LLM authentication found.\n"));
5498
- console.error(` ${chalk5.bold("Option 1:")} Install Pi SDK ${chalk5.dim("(recommended)")}`);
5499
- console.error(chalk5.dim(" npm install -g @mariozechner/pi-coding-agent"));
5500
- console.error(chalk5.dim(" pi\n"));
5501
- console.error(` ${chalk5.bold("Option 2:")} Set your Anthropic API key`);
5502
- console.error(chalk5.dim(" export ANTHROPIC_API_KEY=sk-ant-...\n"));
4949
+ console.error(chalk3.red("\n No LLM authentication found.\n"));
4950
+ console.error(` ${chalk3.bold("Option 1:")} Install Pi SDK ${chalk3.dim("(recommended)")}`);
4951
+ console.error(chalk3.dim(" npm install -g @mariozechner/pi-coding-agent"));
4952
+ console.error(chalk3.dim(" pi\n"));
4953
+ console.error(` ${chalk3.bold("Option 2:")} Set one or more provider API keys`);
4954
+ console.error(chalk3.dim(" export ANTHROPIC_API_KEY=sk-ant-..."));
4955
+ console.error(chalk3.dim(" export OPENROUTER_API_KEY=sk-or-..."));
4956
+ console.error(chalk3.dim(" export OPENAI_API_KEY=sk-...\n"));
5503
4957
  process.exit(1);
5504
4958
  }
5505
4959
 
5506
4960
  // src/config.ts
5507
- import { existsSync as existsSync8 } from "fs";
5508
- import { readFile as readFile6, writeFile as writeFile6, mkdir as mkdir7 } from "fs/promises";
5509
- import { join as join12 } from "path";
4961
+ import { existsSync as existsSync5 } from "fs";
4962
+ import { readFile as readFile3, writeFile as writeFile4, mkdir as mkdir4 } from "fs/promises";
4963
+ import { join as join9 } from "path";
5510
4964
  var DEFAULT_INDEX_MODEL = "claude-haiku-4-5";
5511
4965
  var DEFAULT_QUERY_MODEL = "claude-sonnet-4-6";
5512
4966
  var DEFAULTS = {
@@ -5514,14 +4968,14 @@ var DEFAULTS = {
5514
4968
  queryModel: DEFAULT_QUERY_MODEL
5515
4969
  };
5516
4970
  function configPath(kbRoot) {
5517
- return join12(kbRoot, ".llm-kb", "config.json");
4971
+ return join9(kbRoot, ".llm-kb", "config.json");
5518
4972
  }
5519
4973
  async function loadConfig(kbRoot) {
5520
4974
  let base = { ...DEFAULTS };
5521
4975
  const path3 = configPath(kbRoot);
5522
- if (existsSync8(path3)) {
4976
+ if (existsSync5(path3)) {
5523
4977
  try {
5524
- const raw = await readFile6(path3, "utf-8");
4978
+ const raw = await readFile3(path3, "utf-8");
5525
4979
  const parsed = JSON.parse(raw);
5526
4980
  if (parsed.indexModel) base.indexModel = parsed.indexModel;
5527
4981
  if (parsed.queryModel) base.queryModel = parsed.queryModel;
@@ -5534,29 +4988,35 @@ async function loadConfig(kbRoot) {
5534
4988
  }
5535
4989
  async function ensureConfig(kbRoot) {
5536
4990
  const path3 = configPath(kbRoot);
5537
- if (!existsSync8(path3)) {
5538
- await mkdir7(join12(kbRoot, ".llm-kb"), { recursive: true });
5539
- await writeFile6(path3, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8");
4991
+ if (!existsSync5(path3)) {
4992
+ await mkdir4(join9(kbRoot, ".llm-kb"), { recursive: true });
4993
+ await writeFile4(path3, JSON.stringify(DEFAULTS, null, 2) + "\n", "utf-8");
5540
4994
  return { ...DEFAULTS };
5541
4995
  }
5542
4996
  return loadConfig(kbRoot);
5543
4997
  }
5544
4998
 
5545
4999
  // src/cli.ts
5546
- import { existsSync as existsSync9 } from "fs";
5547
- import { mkdir as mkdir8, readdir as readdir6, stat as stat2 } from "fs/promises";
5548
- import { resolve as resolve3, join as join13 } from "path";
5549
- import chalk6 from "chalk";
5000
+ import { existsSync as existsSync6 } from "fs";
5001
+ import { readFileSync } from "fs";
5002
+ import { mkdir as mkdir5, readdir as readdir4, stat as stat2 } from "fs/promises";
5003
+ import { resolve as resolve3, join as join10, dirname as dirname3 } from "path";
5004
+ import { fileURLToPath } from "url";
5005
+ import chalk4 from "chalk";
5006
+ var __filename = fileURLToPath(import.meta.url);
5007
+ var __dirname = dirname3(__filename);
5008
+ var pkg = JSON.parse(readFileSync(join10(__dirname, "..", "package.json"), "utf-8"));
5009
+ var VERSION = pkg.version;
5550
5010
  var program = new Command();
5551
- program.name("llm-kb").description("Drop files into a folder. Get a knowledge base you can query.").version("0.4.0");
5011
+ program.name("llm-kb").description("Drop files into a folder. Get a knowledge base you can query.").version(VERSION);
5552
5012
  program.command("run").description("Scan, parse, index, and watch a folder").argument("<folder>", "Path to your documents folder").action(async (folder) => {
5553
5013
  console.log(`
5554
- ${chalk6.bold("llm-kb")} v0.4.0
5014
+ ${chalk4.bold("llm-kb")} v${VERSION}
5555
5015
  `);
5556
5016
  const auth = checkAuth();
5557
5017
  if (!auth.ok) exitWithAuthError();
5558
- if (!existsSync9(folder)) {
5559
- console.error(chalk6.red(`Error: Folder not found: ${folder}`));
5018
+ if (!existsSync6(folder)) {
5019
+ console.error(chalk4.red(`Error: Folder not found: ${folder}`));
5560
5020
  process.exit(1);
5561
5021
  }
5562
5022
  const root = resolve3(folder);
@@ -5564,72 +5024,95 @@ ${chalk6.bold("llm-kb")} v0.4.0
5564
5024
  console.log(`Scanning ${folder}...`);
5565
5025
  const files = await scan(folder);
5566
5026
  if (files.length === 0) {
5567
- console.log(chalk6.yellow(" No supported files found."));
5027
+ console.log(chalk4.yellow(" No supported files found."));
5568
5028
  return;
5569
5029
  }
5570
5030
  const pdfs = files.filter((f) => f.ext === ".pdf");
5571
- console.log(` Found ${chalk6.bold(files.length.toString())} files (${summarize(files)})`);
5031
+ console.log(` Found ${chalk4.bold(files.length.toString())} files (${summarize(files)})`);
5572
5032
  if (pdfs.length === 0) return;
5573
- const sourcesDir = join13(root, ".llm-kb", "wiki", "sources");
5574
- await mkdir8(sourcesDir, { recursive: true });
5033
+ const sourcesDir = join10(root, ".llm-kb", "wiki", "sources");
5034
+ await mkdir5(sourcesDir, { recursive: true });
5575
5035
  let parsed = 0;
5576
5036
  let skipped = 0;
5577
5037
  let failed = 0;
5578
5038
  const errors = [];
5579
- for (let i = 0; i < pdfs.length; i++) {
5580
- const pdf = pdfs[i];
5581
- const progress = ` Parsing... ${i + 1}/${pdfs.length} \u2014 ${pdf.name}`;
5582
- process.stdout.write(`\r${progress.padEnd(process.stdout.columns || 80)}`);
5583
- try {
5584
- const result = await parsePDF(join13(root, pdf.path), sourcesDir);
5585
- if (result.skipped) skipped++;
5586
- else parsed++;
5587
- } catch (err) {
5588
- failed++;
5589
- errors.push({ name: pdf.name, message: err.message });
5039
+ const CONCURRENCY = Math.min(8, pdfs.length);
5040
+ let nextIdx = 0;
5041
+ let completed = 0;
5042
+ const parseStart = Date.now();
5043
+ let activeCount = 0;
5044
+ function renderProgress(currentFile) {
5045
+ const cols = process.stdout.columns || 80;
5046
+ const pct = Math.round(completed / pdfs.length * 100);
5047
+ const barWidth = Math.min(30, cols - 40);
5048
+ const filled = Math.round(completed / pdfs.length * barWidth);
5049
+ const active = Math.min(activeCount, barWidth - filled);
5050
+ const bar = chalk4.green("\u2588".repeat(filled)) + chalk4.yellow("\u2593".repeat(active)) + chalk4.dim("\u2591".repeat(barWidth - filled - active));
5051
+ const elapsed = ((Date.now() - parseStart) / 1e3).toFixed(0);
5052
+ const line = ` ${bar} ${chalk4.bold(`${completed}/${pdfs.length}`)} ${chalk4.cyan(`(${pct}%)`)} ${chalk4.dim(`${elapsed}s`)} ${chalk4.yellow(`${activeCount} active`)}`;
5053
+ process.stdout.write(`\r${line.padEnd(cols)}`);
5054
+ }
5055
+ async function worker() {
5056
+ while (nextIdx < pdfs.length) {
5057
+ const i = nextIdx++;
5058
+ const pdf = pdfs[i];
5059
+ activeCount++;
5060
+ renderProgress(pdf.name);
5061
+ try {
5062
+ const result = await parsePDF(join10(root, pdf.path), sourcesDir);
5063
+ if (result.skipped) skipped++;
5064
+ else parsed++;
5065
+ } catch (err) {
5066
+ failed++;
5067
+ errors.push({ name: pdf.name, message: err.message });
5068
+ }
5069
+ activeCount--;
5070
+ completed++;
5071
+ renderProgress(pdf.name);
5590
5072
  }
5591
5073
  }
5074
+ await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()));
5592
5075
  process.stdout.write(`\r${"".padEnd(process.stdout.columns || 80)}\r`);
5593
5076
  const parts = [];
5594
- if (parsed > 0) parts.push(chalk6.green(`${parsed} parsed`));
5595
- if (skipped > 0) parts.push(chalk6.dim(`${skipped} skipped (up to date)`));
5596
- if (failed > 0) parts.push(chalk6.red(`${failed} failed`));
5077
+ if (parsed > 0) parts.push(chalk4.green(`${parsed} parsed`));
5078
+ if (skipped > 0) parts.push(chalk4.dim(`${skipped} skipped (up to date)`));
5079
+ if (failed > 0) parts.push(chalk4.red(`${failed} failed`));
5597
5080
  console.log(` ${parts.join(", ")}`);
5598
- for (const err of errors) console.log(chalk6.red(` \u2717 ${err.name} \u2014 ${err.message}`));
5599
- const indexFile = join13(root, ".llm-kb", "wiki", "index.md");
5081
+ for (const err of errors) console.log(chalk4.red(` \u2717 ${err.name} \u2014 ${err.message}`));
5082
+ const indexFile = join10(root, ".llm-kb", "wiki", "index.md");
5600
5083
  let indexUpToDate = false;
5601
- if (parsed === 0 && existsSync9(indexFile)) {
5084
+ if (parsed === 0 && existsSync6(indexFile)) {
5602
5085
  try {
5603
5086
  const indexMtime = (await stat2(indexFile)).mtimeMs;
5604
- const sourceFiles = await readdir6(sourcesDir);
5605
- const mtimes = await Promise.all(sourceFiles.map((f) => stat2(join13(sourcesDir, f)).then((s) => s.mtimeMs)));
5087
+ const sourceFiles = await readdir4(sourcesDir);
5088
+ const mtimes = await Promise.all(sourceFiles.map((f) => stat2(join10(sourcesDir, f)).then((s) => s.mtimeMs)));
5606
5089
  indexUpToDate = mtimes.every((mt) => indexMtime >= mt);
5607
5090
  } catch {
5608
5091
  }
5609
5092
  }
5610
5093
  if (indexUpToDate) {
5611
- console.log(chalk6.dim(`
5094
+ console.log(chalk4.dim(`
5612
5095
  Index up to date.`));
5613
5096
  } else {
5614
5097
  console.log(`
5615
- Building index... ${chalk6.dim(`(${config.indexModel})`)}`);
5098
+ Building index... ${chalk4.dim(`(${config.indexModel})`)}`);
5616
5099
  try {
5617
5100
  await buildIndex(root, sourcesDir, void 0, auth.authStorage, config.indexModel);
5618
- console.log(chalk6.green(` Index built: .llm-kb/wiki/index.md`));
5101
+ console.log(chalk4.green(` Index built: .llm-kb/wiki/index.md`));
5619
5102
  } catch (err) {
5620
- console.error(chalk6.red(` Index failed: ${err.message}`));
5103
+ console.error(chalk4.red(` Index failed: ${err.message}`));
5621
5104
  }
5622
5105
  }
5623
5106
  console.log(`
5624
- ${chalk6.dim("Output:")} ${sourcesDir}`);
5625
- startWatcher({ folder: root, sourcesDir, authStorage: auth.authStorage, indexModel: config.indexModel });
5626
- startSessionWatcher(root);
5107
+ ${chalk4.dim("Output:")} ${sourcesDir}`);
5627
5108
  const chatUI = new ChatDisplay();
5628
- const { session, display } = await createChat(root, {
5109
+ const { session, display, reloadSources } = await createChat(root, {
5629
5110
  authStorage: auth.authStorage,
5630
5111
  modelId: config.queryModel,
5631
5112
  tuiDisplay: chatUI
5632
5113
  });
5114
+ startWatcher({ folder: root, sourcesDir, authStorage: auth.authStorage, indexModel: config.indexModel, onSourcesChanged: reloadSources });
5115
+ startSessionWatcher(root);
5633
5116
  chatUI.onSubmit = (text) => {
5634
5117
  display.setQuestion(text);
5635
5118
  session.prompt(text).catch(() => {
@@ -5642,7 +5125,7 @@ ${chalk6.bold("llm-kb")} v0.4.0
5642
5125
  });
5643
5126
  };
5644
5127
  console.log(`
5645
- ${chalk6.bold("Ready.")} Ask a question or drop files in to re-index.
5128
+ ${chalk4.bold("Ready.")} Ask a question or drop files in to re-index.
5646
5129
  `);
5647
5130
  chatUI.start();
5648
5131
  });
@@ -5651,7 +5134,7 @@ program.command("query").description("Ask a single question (non-interactive, st
5651
5134
  if (!auth.ok) exitWithAuthError();
5652
5135
  const root = resolveKnowledgeBase(options.folder || process.cwd());
5653
5136
  if (!root) {
5654
- console.error(chalk6.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5137
+ console.error(chalk4.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5655
5138
  process.exit(1);
5656
5139
  }
5657
5140
  const config = await loadConfig(root);
@@ -5662,7 +5145,7 @@ program.command("query").description("Ask a single question (non-interactive, st
5662
5145
  modelId: config.queryModel
5663
5146
  });
5664
5147
  } catch (err) {
5665
- console.error(chalk6.red(err.message));
5148
+ console.error(chalk4.red(err.message));
5666
5149
  process.exit(1);
5667
5150
  }
5668
5151
  });
@@ -5671,46 +5154,49 @@ program.command("eval").description("Analyze sessions for quality issues, wiki g
5671
5154
  if (!auth.ok) exitWithAuthError();
5672
5155
  const root = resolveKnowledgeBase(options.folder || process.cwd());
5673
5156
  if (!root) {
5674
- console.error(chalk6.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5157
+ console.error(chalk4.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5675
5158
  process.exit(1);
5676
5159
  }
5677
5160
  console.log(`
5678
- ${chalk6.bold("llm-kb eval")}
5161
+ ${chalk4.bold("llm-kb eval")}
5679
5162
  `);
5680
5163
  const result = await runEval(root, {
5681
5164
  authStorage: auth.authStorage,
5682
5165
  last: options.last,
5683
- onProgress: (msg) => console.log(chalk6.dim(` ${msg}`))
5166
+ onProgress: (msg) => console.log(chalk4.dim(` ${msg}`))
5684
5167
  });
5685
5168
  const { metrics, issues, wikiGaps } = result;
5686
5169
  const errors = issues.filter((i) => i.severity === "error").length;
5687
5170
  const warnings = issues.filter((i) => i.severity === "warning").length;
5688
5171
  console.log();
5689
- console.log(` ${chalk6.bold("Results:")}`);
5172
+ console.log(` ${chalk4.bold("Results:")}`);
5690
5173
  console.log(` Queries analyzed: ${metrics.totalQAs}`);
5691
5174
  console.log(` Wiki hit rate: ${metrics.totalQAs > 0 ? Math.round(metrics.wikiHits / metrics.totalQAs * 100) : 0}%`);
5692
5175
  console.log(` Wasted reads: ${metrics.wastedReads}`);
5693
- console.log(` Issues: ${errors > 0 ? chalk6.red(`${errors} errors`) : chalk6.green("0 errors")} ${warnings > 0 ? chalk6.yellow(`${warnings} warnings`) : chalk6.dim("0 warnings")}`);
5694
- console.log(` Wiki gaps: ${wikiGaps.length > 0 ? chalk6.yellow(String(wikiGaps.length)) : chalk6.green("0")}`);
5176
+ const cm = metrics.citations;
5177
+ const bboxPct = cm.totalCitations > 0 ? Math.round(cm.withBbox / cm.totalCitations * 100) : 0;
5178
+ console.log(` Citations: ${cm.totalCitations} total, ${chalk4.green(`${cm.withBbox} with bbox`)}${cm.withoutBbox > 0 ? chalk4.yellow(` ${cm.withoutBbox} without`) : ""} (${bboxPct}%)`);
5179
+ console.log(` Issues: ${errors > 0 ? chalk4.red(`${errors} errors`) : chalk4.green("0 errors")} ${warnings > 0 ? chalk4.yellow(`${warnings} warnings`) : chalk4.dim("0 warnings")}`);
5180
+ console.log(` Wiki gaps: ${wikiGaps.length > 0 ? chalk4.yellow(String(wikiGaps.length)) : chalk4.green("0")}`);
5695
5181
  console.log();
5696
- console.log(chalk6.green(` Report: .llm-kb/wiki/outputs/eval-report.md`));
5182
+ console.log(chalk4.green(` Report: .llm-kb/wiki/outputs/eval-report.md`));
5697
5183
  console.log();
5698
5184
  });
5699
5185
  program.command("status").description("Show knowledge base stats and current config").option("--folder <path>", "Path to document folder (auto-detects if omitted)").action(async (options) => {
5700
5186
  const root = resolveKnowledgeBase(options.folder || process.cwd());
5701
5187
  if (!root) {
5702
- console.error(chalk6.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5188
+ console.error(chalk4.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5703
5189
  process.exit(1);
5704
5190
  }
5705
5191
  const auth = checkAuth();
5706
5192
  const config = await loadConfig(root);
5707
- const sourcesDir = join13(root, ".llm-kb", "wiki", "sources");
5708
- const indexFile = join13(root, ".llm-kb", "wiki", "index.md");
5709
- const articlesDir = join13(root, ".llm-kb", "wiki", "articles");
5710
- const outputsDir = join13(root, ".llm-kb", "wiki", "outputs");
5193
+ const sourcesDir = join10(root, ".llm-kb", "wiki", "sources");
5194
+ const indexFile = join10(root, ".llm-kb", "wiki", "index.md");
5195
+ const articlesDir = join10(root, ".llm-kb", "wiki", "articles");
5196
+ const outputsDir = join10(root, ".llm-kb", "wiki", "outputs");
5711
5197
  let sourceCount = 0;
5712
5198
  try {
5713
- sourceCount = (await readdir6(sourcesDir)).filter((f) => f.endsWith(".md")).length;
5199
+ sourceCount = (await readdir4(sourcesDir)).filter((f) => f.endsWith(".md")).length;
5714
5200
  } catch {
5715
5201
  }
5716
5202
  let indexAge = "not built yet";
@@ -5721,23 +5207,48 @@ program.command("status").description("Show knowledge base stats and current con
5721
5207
  }
5722
5208
  let outputCount = 0;
5723
5209
  try {
5724
- outputCount = (await readdir6(outputsDir)).filter((f) => f.endsWith(".md")).length;
5210
+ outputCount = (await readdir4(outputsDir)).filter((f) => f.endsWith(".md")).length;
5725
5211
  } catch {
5726
5212
  }
5727
5213
  console.log(`
5728
- ${chalk6.bold("Knowledge Base Status")}`);
5729
- console.log(` ${chalk6.dim("Folder:")} ${root}`);
5730
- console.log(` ${chalk6.dim("Sources:")} ${sourceCount > 0 ? `${sourceCount} parsed source${sourceCount !== 1 ? "s" : ""}` : chalk6.yellow("none yet")}`);
5731
- console.log(` ${chalk6.dim("Index:")} ${indexAge}`);
5214
+ ${chalk4.bold("Knowledge Base Status")}`);
5215
+ console.log(` ${chalk4.dim("Folder:")} ${root}`);
5216
+ console.log(` ${chalk4.dim("Sources:")} ${sourceCount > 0 ? `${sourceCount} parsed source${sourceCount !== 1 ? "s" : ""}` : chalk4.yellow("none yet")}`);
5217
+ console.log(` ${chalk4.dim("Index:")} ${indexAge}`);
5732
5218
  let articleCount = 0;
5733
5219
  try {
5734
- articleCount = (await readdir6(articlesDir)).filter((f) => f.endsWith(".md") && f !== "index.md").length;
5220
+ articleCount = (await readdir4(articlesDir)).filter((f) => f.endsWith(".md") && f !== "index.md").length;
5735
5221
  } catch {
5736
5222
  }
5737
- if (articleCount > 0) console.log(` ${chalk6.dim("Articles:")} ${articleCount} compiled`);
5738
- if (outputCount > 0) console.log(` ${chalk6.dim("Outputs:")} ${outputCount} saved answer${outputCount !== 1 ? "s" : ""}`);
5739
- console.log(` ${chalk6.dim("Models:")} ${chalk6.cyan(config.queryModel)} ${chalk6.dim("(query)")} ${chalk6.cyan(config.indexModel)} ${chalk6.dim("(index)")}`);
5740
- console.log(` ${chalk6.dim("Auth:")} ${auth.ok ? auth.method === "pi-sdk" ? "Pi SDK" : "ANTHROPIC_API_KEY" : chalk6.red("not configured")}`);
5223
+ if (articleCount > 0) console.log(` ${chalk4.dim("Articles:")} ${articleCount} compiled`);
5224
+ if (outputCount > 0) console.log(` ${chalk4.dim("Outputs:")} ${outputCount} saved answer${outputCount !== 1 ? "s" : ""}`);
5225
+ console.log(` ${chalk4.dim("Models:")} ${chalk4.cyan(config.queryModel)} ${chalk4.dim("(query)")} ${chalk4.cyan(config.indexModel)} ${chalk4.dim("(index)")}`);
5226
+ console.log(` ${chalk4.dim("Auth:")} ${auth.ok ? auth.method === "pi-sdk" ? "Pi SDK" : `env (${auth.providers.join(", ")})` : chalk4.red("not configured")}`);
5741
5227
  console.log();
5742
5228
  });
5229
+ program.command("ui").description("Open the web UI with chat, citations, and source viewer").argument("<folder>", "Path to your documents folder").option("--port <n>", "Port number", parseInt, 3947).option("--no-open", "Don't auto-open the browser").action(async (folder, options) => {
5230
+ console.log(`
5231
+ ${chalk4.bold("llm-kb")} web UI
5232
+ `);
5233
+ const auth = checkAuth();
5234
+ if (!auth.ok) exitWithAuthError();
5235
+ if (!existsSync6(folder)) {
5236
+ console.error(chalk4.red(`Error: Folder not found: ${folder}`));
5237
+ process.exit(1);
5238
+ }
5239
+ const root = resolve3(folder);
5240
+ const config = await loadConfig(root);
5241
+ if (!existsSync6(join10(root, ".llm-kb", "wiki", "sources"))) {
5242
+ console.error(chalk4.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
5243
+ process.exit(1);
5244
+ }
5245
+ const { startWebUI } = await import("./server-QC5SN6T4.js");
5246
+ await startWebUI({
5247
+ folder: root,
5248
+ port: options.port,
5249
+ open: options.open,
5250
+ authStorage: auth.authStorage,
5251
+ modelId: config.queryModel
5252
+ });
5253
+ });
5743
5254
  program.parse();