llm-kb 0.0.1 → 0.2.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.
@@ -0,0 +1,118 @@
1
+ // src/indexer.ts
2
+ import {
3
+ createAgentSession,
4
+ createBashTool,
5
+ createReadTool,
6
+ createWriteTool,
7
+ DefaultResourceLoader,
8
+ SessionManager,
9
+ SettingsManager
10
+ } from "@mariozechner/pi-coding-agent";
11
+ import { readdir, readFile } from "fs/promises";
12
+ import { join, dirname } from "path";
13
+ import { fileURLToPath } from "url";
14
+ var __filename = fileURLToPath(import.meta.url);
15
+ var __dirname = dirname(__filename);
16
+ function getNodeModulesPath() {
17
+ let dir = __dirname;
18
+ for (let i = 0; i < 5; i++) {
19
+ const candidate = join(dir, "node_modules");
20
+ try {
21
+ return candidate;
22
+ } catch {
23
+ dir = dirname(dir);
24
+ }
25
+ }
26
+ return join(process.cwd(), "node_modules");
27
+ }
28
+ function buildAgentsContent(sourcesDir, files) {
29
+ const sourceList = files.filter((f) => f.endsWith(".md")).map((f) => ` - ${f}`).join("\n");
30
+ return `# llm-kb Knowledge Base
31
+
32
+ ## How to access documents
33
+
34
+ ### PDFs (pre-parsed)
35
+ PDFs have been parsed to markdown with bounding boxes.
36
+ Read the markdown versions in \`.llm-kb/wiki/sources/\` instead of the raw PDFs.
37
+
38
+ Available parsed sources:
39
+ ${sourceList}
40
+
41
+ ### Other file types (Excel, Word, PowerPoint, CSV, images)
42
+ You have bash and read tools. These libraries are pre-installed and available:
43
+ - **exceljs** \u2014 for .xlsx/.xls files
44
+ - **mammoth** \u2014 for .docx files
45
+ - **officeparser** \u2014 for .pptx files
46
+ - **csv-parse** \u2014 built into Node.js, use fs + split for .csv
47
+
48
+ Write a quick Node.js script to extract content when needed.
49
+
50
+ ## Index file
51
+ Write the index to \`.llm-kb/wiki/index.md\`.
52
+
53
+ The index should be a markdown file with:
54
+ 1. A title and last-updated timestamp
55
+ 2. A summary table with columns: Source, Type, Pages/Size, Summary, Key Topics
56
+ 3. Each source gets a one-line summary (read the first ~500 chars of each file to generate it)
57
+ 4. Total word count across all sources
58
+ `;
59
+ }
60
+ async function buildIndex(folder, sourcesDir, onOutput) {
61
+ const files = await readdir(sourcesDir);
62
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
63
+ if (mdFiles.length === 0) {
64
+ throw new Error("No source files found to index");
65
+ }
66
+ const agentsContent = buildAgentsContent(sourcesDir, files);
67
+ const nodeModulesPath = getNodeModulesPath();
68
+ process.env.NODE_PATH = nodeModulesPath;
69
+ const loader = new DefaultResourceLoader({
70
+ cwd: folder,
71
+ agentsFilesOverride: (current) => ({
72
+ agentsFiles: [
73
+ ...current.agentsFiles,
74
+ { path: ".llm-kb/AGENTS.md", content: agentsContent }
75
+ ]
76
+ })
77
+ });
78
+ await loader.reload();
79
+ const { session } = await createAgentSession({
80
+ cwd: folder,
81
+ resourceLoader: loader,
82
+ tools: [
83
+ createReadTool(folder),
84
+ createBashTool(folder),
85
+ createWriteTool(folder)
86
+ ],
87
+ sessionManager: SessionManager.inMemory(),
88
+ settingsManager: SettingsManager.inMemory({
89
+ compaction: { enabled: false }
90
+ })
91
+ });
92
+ if (onOutput) {
93
+ session.subscribe((event) => {
94
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
95
+ onOutput(event.assistantMessageEvent.delta);
96
+ }
97
+ });
98
+ }
99
+ const prompt = `Read each file in .llm-kb/wiki/sources/ (one at a time, just the first 500 characters of each).
100
+ Then write .llm-kb/wiki/index.md with a summary table of all sources.
101
+
102
+ Include: Source filename, Type (PDF/Excel/Word/etc), Pages (from the JSON if available), a one-line summary, and key topics.
103
+ Add a total word count estimate at the bottom.`;
104
+ await session.prompt(prompt);
105
+ const indexPath = join(sourcesDir, "..", "index.md");
106
+ try {
107
+ const content = await readFile(indexPath, "utf-8");
108
+ session.dispose();
109
+ return content;
110
+ } catch {
111
+ session.dispose();
112
+ throw new Error("Agent did not create index.md");
113
+ }
114
+ }
115
+
116
+ export {
117
+ buildIndex
118
+ };
package/bin/cli.js ADDED
@@ -0,0 +1,409 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ buildIndex
4
+ } from "./chunk-MYQ36JJB.js";
5
+
6
+ // src/cli.ts
7
+ import { Command } from "commander";
8
+
9
+ // src/scan.ts
10
+ import { readdir } from "fs/promises";
11
+ import { resolve, extname, relative } from "path";
12
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
13
+ ".pdf",
14
+ ".xlsx",
15
+ ".xls",
16
+ ".docx",
17
+ ".pptx",
18
+ ".jpg",
19
+ ".jpeg",
20
+ ".png",
21
+ ".txt",
22
+ ".md",
23
+ ".csv"
24
+ ]);
25
+ async function scan(folder) {
26
+ const root = resolve(folder);
27
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
28
+ const files = [];
29
+ for (const entry of entries) {
30
+ if (!entry.isFile()) continue;
31
+ const fullPath = resolve(entry.parentPath, entry.name);
32
+ const rel = relative(root, fullPath);
33
+ if (rel.startsWith(".llm-kb")) continue;
34
+ const ext = extname(entry.name).toLowerCase();
35
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
36
+ files.push({ name: entry.name, path: rel, ext });
37
+ }
38
+ return files;
39
+ }
40
+ function summarize(files) {
41
+ const counts = /* @__PURE__ */ new Map();
42
+ for (const f of files) {
43
+ counts.set(f.ext, (counts.get(f.ext) || 0) + 1);
44
+ }
45
+ const parts = Array.from(counts.entries()).sort((a, b) => b[1] - a[1]).map(([ext, count]) => `${count} ${ext.toUpperCase().slice(1)}`);
46
+ return parts.join(", ");
47
+ }
48
+
49
+ // src/pdf.ts
50
+ import { LiteParse } from "@llamaindex/liteparse";
51
+ import { writeFile, mkdir, stat } from "fs/promises";
52
+ import { join, basename } from "path";
53
+ import { cpus } from "os";
54
+ async function isUpToDate(pdfPath, mdPath, jsonPath) {
55
+ try {
56
+ const [pdfStat, mdStat, jsonStat] = await Promise.all([
57
+ stat(pdfPath),
58
+ stat(mdPath),
59
+ stat(jsonPath)
60
+ ]);
61
+ return pdfStat.mtimeMs <= mdStat.mtimeMs && pdfStat.mtimeMs <= jsonStat.mtimeMs;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ function suppressStderr() {
67
+ const originalWrite = process.stderr.write.bind(process.stderr);
68
+ process.stderr.write = (() => true);
69
+ return () => {
70
+ process.stderr.write = originalWrite;
71
+ };
72
+ }
73
+ async function parsePDF(pdfPath, outputDir) {
74
+ const name = basename(pdfPath, ".pdf");
75
+ await mkdir(outputDir, { recursive: true });
76
+ const mdPath = join(outputDir, `${name}.md`);
77
+ const jsonPath = join(outputDir, `${name}.json`);
78
+ if (await isUpToDate(pdfPath, mdPath, jsonPath)) {
79
+ return { name, mdPath, jsonPath, totalPages: 0, textLength: 0, skipped: true };
80
+ }
81
+ const ocrServerUrl = process.env.OCR_SERVER_URL;
82
+ const ocrEnabled = ocrServerUrl ? true : process.env.OCR_ENABLED === "true";
83
+ const parser = new LiteParse({
84
+ ocrEnabled,
85
+ outputFormat: "json",
86
+ numWorkers: cpus().length,
87
+ ...ocrServerUrl ? { ocrServerUrl } : {}
88
+ });
89
+ const restore = suppressStderr();
90
+ let result;
91
+ try {
92
+ result = await parser.parse(pdfPath, true);
93
+ } finally {
94
+ restore();
95
+ }
96
+ const markdown = result.pages.map((p) => `# Page ${p.pageNum}
97
+
98
+ ${p.text}`).join("\n\n---\n\n");
99
+ const bboxData = {
100
+ source: basename(pdfPath),
101
+ totalPages: result.pages.length,
102
+ pages: result.pages.map((p) => ({
103
+ page: p.pageNum,
104
+ width: p.width,
105
+ height: p.height,
106
+ textItems: p.textItems.map((item) => ({
107
+ text: (item.str ?? item.text ?? "").trim(),
108
+ x: Math.round(item.x * 100) / 100,
109
+ y: Math.round(item.y * 100) / 100,
110
+ width: Math.round((item.width ?? item.w ?? 0) * 100) / 100,
111
+ height: Math.round((item.height ?? item.h ?? 0) * 100) / 100,
112
+ fontName: item.fontName,
113
+ fontSize: item.fontSize ? Math.round(item.fontSize * 100) / 100 : void 0
114
+ }))
115
+ }))
116
+ };
117
+ await writeFile(mdPath, markdown);
118
+ await writeFile(jsonPath, JSON.stringify(bboxData, null, 2));
119
+ return {
120
+ name,
121
+ mdPath,
122
+ jsonPath,
123
+ totalPages: result.pages.length,
124
+ textLength: markdown.length,
125
+ skipped: false
126
+ };
127
+ }
128
+
129
+ // src/watcher.ts
130
+ import { watch } from "chokidar";
131
+ import { extname as extname2, basename as basename2 } from "path";
132
+ import chalk from "chalk";
133
+ function startWatcher({ folder, sourcesDir, debounceMs = 2e3 }) {
134
+ let pendingFiles = [];
135
+ let debounceTimer = null;
136
+ async function processBatch() {
137
+ const files = [...pendingFiles];
138
+ pendingFiles = [];
139
+ if (files.length === 0) return;
140
+ console.log();
141
+ for (const filePath of files) {
142
+ const name = basename2(filePath);
143
+ process.stdout.write(` Parsing ${name}...`);
144
+ try {
145
+ const result = await parsePDF(filePath, sourcesDir);
146
+ if (result.skipped) {
147
+ console.log(chalk.dim(` skipped (up to date)`));
148
+ } else {
149
+ console.log(chalk.green(` \u2713 ${result.totalPages} pages`));
150
+ }
151
+ } catch (err) {
152
+ console.log(chalk.red(` \u2717 ${err.message}`));
153
+ }
154
+ }
155
+ process.stdout.write(` Re-indexing...`);
156
+ try {
157
+ await buildIndex(folder, sourcesDir);
158
+ console.log(chalk.green(` \u2713 index.md updated`));
159
+ } catch (err) {
160
+ console.log(chalk.red(` \u2717 ${err.message}`));
161
+ }
162
+ }
163
+ function queueFile(filePath) {
164
+ if (!pendingFiles.includes(filePath)) {
165
+ pendingFiles.push(filePath);
166
+ }
167
+ if (debounceTimer) clearTimeout(debounceTimer);
168
+ debounceTimer = setTimeout(processBatch, debounceMs);
169
+ }
170
+ const watcher = watch(folder, {
171
+ ignoreInitial: true,
172
+ ignored: [
173
+ "**/node_modules/**",
174
+ "**/.llm-kb/**",
175
+ "**/.git/**"
176
+ ],
177
+ depth: 10
178
+ });
179
+ watcher.on("add", (filePath) => {
180
+ const ext = extname2(filePath).toLowerCase();
181
+ if (ext === ".pdf") {
182
+ console.log(chalk.dim(`
183
+ New file: ${basename2(filePath)}`));
184
+ queueFile(filePath);
185
+ }
186
+ });
187
+ watcher.on("change", (filePath) => {
188
+ const ext = extname2(filePath).toLowerCase();
189
+ if (ext === ".pdf") {
190
+ console.log(chalk.dim(`
191
+ Changed: ${basename2(filePath)}`));
192
+ queueFile(filePath);
193
+ }
194
+ });
195
+ return watcher;
196
+ }
197
+
198
+ // src/query.ts
199
+ import {
200
+ createAgentSession,
201
+ createBashTool,
202
+ createReadTool,
203
+ createWriteTool,
204
+ DefaultResourceLoader,
205
+ SessionManager,
206
+ SettingsManager
207
+ } from "@mariozechner/pi-coding-agent";
208
+ import { readdir as readdir2, mkdir as mkdir2 } from "fs/promises";
209
+ import { join as join3, dirname } from "path";
210
+ import { fileURLToPath } from "url";
211
+ var __dirname = dirname(fileURLToPath(import.meta.url));
212
+ function getNodeModulesPath() {
213
+ let dir = __dirname;
214
+ for (let i = 0; i < 5; i++) {
215
+ const candidate = join3(dir, "node_modules");
216
+ try {
217
+ return candidate;
218
+ } catch {
219
+ dir = dirname(dir);
220
+ }
221
+ }
222
+ return join3(process.cwd(), "node_modules");
223
+ }
224
+ function buildQueryAgents(sourceFiles, save) {
225
+ const sourceList = sourceFiles.map((f) => ` - ${f}`).join("\n");
226
+ let content = `# llm-kb Knowledge Base \u2014 Query Mode
227
+
228
+ ## How to answer questions
229
+
230
+ 1. FIRST read .llm-kb/wiki/index.md to understand all available sources
231
+ 2. Based on the question, select the most relevant source files (usually 2-5)
232
+ 3. Read those source files in full from .llm-kb/wiki/sources/
233
+ 4. Answer with inline citations: (filename, page number)
234
+ 5. If the answer requires cross-referencing multiple files, read additional ones
235
+ 6. If you can't find the answer, say so \u2014 don't hallucinate
236
+
237
+ ## Available parsed sources
238
+ ${sourceList}
239
+
240
+ ## Non-PDF files
241
+ If the user's folder has Excel, Word, or PowerPoint files, these libraries are available:
242
+ - **exceljs** \u2014 for .xlsx/.xls files
243
+ - **mammoth** \u2014 for .docx files
244
+ - **officeparser** \u2014 for .pptx files
245
+ Write a quick Node.js script via bash to read them.
246
+
247
+ ## Rules
248
+ - Always cite sources with filename and page number
249
+ - Read the FULL source file, not just the beginning
250
+ - Prefer primary sources over previous analyses
251
+ `;
252
+ if (save) {
253
+ content += `
254
+ ## Research Mode
255
+ Save your analysis to .llm-kb/wiki/outputs/ with a descriptive filename (e.g., comparison-analysis.md).
256
+ Include the question at the top and all citations.
257
+ `;
258
+ }
259
+ return content;
260
+ }
261
+ async function query(folder, question, options) {
262
+ const sourcesDir = join3(folder, ".llm-kb", "wiki", "sources");
263
+ const files = await readdir2(sourcesDir);
264
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
265
+ if (mdFiles.length === 0) {
266
+ throw new Error("No sources found. Run 'llm-kb run' first to parse documents.");
267
+ }
268
+ if (options.save) {
269
+ await mkdir2(join3(folder, ".llm-kb", "wiki", "outputs"), { recursive: true });
270
+ }
271
+ process.env.NODE_PATH = getNodeModulesPath();
272
+ const agentsContent = buildQueryAgents(mdFiles, !!options.save);
273
+ const loader = new DefaultResourceLoader({
274
+ cwd: folder,
275
+ agentsFilesOverride: (current) => ({
276
+ agentsFiles: [
277
+ ...current.agentsFiles,
278
+ { path: ".llm-kb/AGENTS.md", content: agentsContent }
279
+ ]
280
+ })
281
+ });
282
+ await loader.reload();
283
+ const tools = [createReadTool(folder)];
284
+ if (options.save) {
285
+ tools.push(createBashTool(folder), createWriteTool(folder));
286
+ }
287
+ const { session } = await createAgentSession({
288
+ cwd: folder,
289
+ resourceLoader: loader,
290
+ tools,
291
+ sessionManager: SessionManager.inMemory(),
292
+ settingsManager: SettingsManager.inMemory({
293
+ compaction: { enabled: false }
294
+ })
295
+ });
296
+ session.subscribe((event) => {
297
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
298
+ process.stdout.write(event.assistantMessageEvent.delta);
299
+ }
300
+ });
301
+ await session.prompt(question);
302
+ console.log();
303
+ session.dispose();
304
+ if (options.save) {
305
+ const { buildIndex: buildIndex2 } = await import("./indexer-LSYSZXZX.js");
306
+ await buildIndex2(folder, sourcesDir);
307
+ }
308
+ }
309
+
310
+ // src/resolve-kb.ts
311
+ import { existsSync } from "fs";
312
+ import { resolve as resolve2, join as join4, dirname as dirname2 } from "path";
313
+ function resolveKnowledgeBase(startDir) {
314
+ let dir = resolve2(startDir);
315
+ while (true) {
316
+ if (existsSync(join4(dir, ".llm-kb"))) {
317
+ return dir;
318
+ }
319
+ const parent = dirname2(dir);
320
+ if (parent === dir) return null;
321
+ dir = parent;
322
+ }
323
+ }
324
+
325
+ // src/cli.ts
326
+ import { existsSync as existsSync2 } from "fs";
327
+ import { mkdir as mkdir3 } from "fs/promises";
328
+ import { resolve as resolve3, join as join5 } from "path";
329
+ import chalk2 from "chalk";
330
+ var program = new Command();
331
+ program.name("llm-kb").description("Drop files into a folder. Get a knowledge base you can query.").version("0.2.0");
332
+ program.command("run").description("Scan, parse, index, and watch a folder").argument("<folder>", "Path to your documents folder").action(async (folder) => {
333
+ console.log(`
334
+ ${chalk2.bold("llm-kb")} v0.2.0
335
+ `);
336
+ if (!existsSync2(folder)) {
337
+ console.error(chalk2.red(`Error: Folder not found: ${folder}`));
338
+ process.exit(1);
339
+ }
340
+ console.log(`Scanning ${folder}...`);
341
+ const files = await scan(folder);
342
+ if (files.length === 0) {
343
+ console.log(chalk2.yellow(" No supported files found."));
344
+ return;
345
+ }
346
+ const pdfs = files.filter((f) => f.ext === ".pdf");
347
+ console.log(` Found ${chalk2.bold(files.length.toString())} files (${summarize(files)})`);
348
+ if (pdfs.length === 0) return;
349
+ const root = resolve3(folder);
350
+ const sourcesDir = join5(root, ".llm-kb", "wiki", "sources");
351
+ await mkdir3(sourcesDir, { recursive: true });
352
+ let parsed = 0;
353
+ let skipped = 0;
354
+ let failed = 0;
355
+ const errors = [];
356
+ for (let i = 0; i < pdfs.length; i++) {
357
+ const pdf = pdfs[i];
358
+ const fullPath = join5(root, pdf.path);
359
+ const progress = ` Parsing... ${i + 1}/${pdfs.length} \u2014 ${pdf.name}`;
360
+ process.stdout.write(`\r${progress.padEnd(80)}`);
361
+ try {
362
+ const result = await parsePDF(fullPath, sourcesDir);
363
+ if (result.skipped) {
364
+ skipped++;
365
+ } else {
366
+ parsed++;
367
+ }
368
+ } catch (err) {
369
+ failed++;
370
+ errors.push({ name: pdf.name, message: err.message });
371
+ }
372
+ }
373
+ process.stdout.write(`\r${"".padEnd(80)}\r`);
374
+ const parts = [];
375
+ if (parsed > 0) parts.push(chalk2.green(`${parsed} parsed`));
376
+ if (skipped > 0) parts.push(chalk2.dim(`${skipped} skipped (up to date)`));
377
+ if (failed > 0) parts.push(chalk2.red(`${failed} failed`));
378
+ console.log(` ${parts.join(", ")}`);
379
+ for (const err of errors) {
380
+ console.log(chalk2.red(` \u2717 ${err.name} \u2014 ${err.message}`));
381
+ }
382
+ console.log(`
383
+ Building index...`);
384
+ try {
385
+ await buildIndex(root, sourcesDir);
386
+ console.log(chalk2.green(` Index built: .llm-kb/wiki/index.md`));
387
+ } catch (err) {
388
+ console.error(chalk2.red(` Index failed: ${err.message}`));
389
+ }
390
+ console.log(`
391
+ ${chalk2.dim("Output:")} ${sourcesDir}`);
392
+ console.log(chalk2.dim(`
393
+ Watching for new files... (Ctrl+C to stop)`));
394
+ startWatcher({ folder: root, sourcesDir });
395
+ });
396
+ program.command("query").description("Ask a question across your knowledge base").argument("<question>", "Your question").option("--folder <path>", "Path to document folder (auto-detects if omitted)").option("--save", "Save the answer to wiki/outputs/ (research mode)").action(async (question, options) => {
397
+ const root = resolveKnowledgeBase(options.folder || process.cwd());
398
+ if (!root) {
399
+ console.error(chalk2.red("No knowledge base found. Run 'llm-kb run <folder>' first."));
400
+ process.exit(1);
401
+ }
402
+ try {
403
+ await query(root, question, { save: options.save });
404
+ } catch (err) {
405
+ console.error(chalk2.red(err.message));
406
+ process.exit(1);
407
+ }
408
+ });
409
+ program.parse();
@@ -0,0 +1,6 @@
1
+ import {
2
+ buildIndex
3
+ } from "./chunk-MYQ36JJB.js";
4
+ export {
5
+ buildIndex
6
+ };
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "llm-kb",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "LLM-powered knowledge base. Drop documents, build a wiki, ask questions. Inspired by Karpathy.",
5
5
  "bin": {
6
- "llm-kb": "./bin/cli.mjs"
6
+ "llm-kb": "./bin/cli.js"
7
7
  },
8
8
  "type": "module",
9
+ "scripts": {
10
+ "build": "tsup src/cli.ts --format esm --out-dir bin --clean",
11
+ "dev": "tsup src/cli.ts --format esm --out-dir bin --watch"
12
+ },
9
13
  "keywords": [
10
14
  "llm",
11
15
  "knowledge-base",
@@ -20,5 +24,20 @@
20
24
  "repository": {
21
25
  "type": "git",
22
26
  "url": "https://github.com/satish860/llm-kb"
27
+ },
28
+ "dependencies": {
29
+ "@llamaindex/liteparse": "^1.4.4",
30
+ "@mariozechner/pi-coding-agent": "^0.65.0",
31
+ "chalk": "^5.6.2",
32
+ "chokidar": "^5.0.0",
33
+ "commander": "^13.1.0",
34
+ "exceljs": "^4.4.0",
35
+ "mammoth": "^1.12.0",
36
+ "officeparser": "^6.0.7",
37
+ "ora": "^9.3.0"
38
+ },
39
+ "devDependencies": {
40
+ "tsup": "^8.4.0",
41
+ "typescript": "^5.8.3"
23
42
  }
24
43
  }
package/plan.md ADDED
@@ -0,0 +1,55 @@
1
+ # llm-kb — Phase 1 Build Plan
2
+
3
+ > Emergent design. Each slice is a thin vertical slice that works end-to-end, is demoable, and informs the next step. Decisions are made at the last responsible moment.
4
+
5
+ ## Key Learnings
6
+ - **PDF is the only adapter we build.** Everything else (Excel, Word, PPT, CSV, images) handled dynamically by Pi SDK agent at query time.
7
+ - **`@llamaindex/liteparse`** proven (from parser-study). Extracts text + bounding boxes locally.
8
+ - **Two-output pattern**: `.md` (spatial text) + `.json` (bounding boxes for citations).
9
+ - **OCR off by default.** Most PDFs have native text. Enable via `OCR_SERVER_URL` or `OCR_ENABLED=true`.
10
+ - **Pi SDK `createAgentSession()`** with defaults — no auth/model config needed. Uses Pi's existing auth.
11
+ - **AGENTS.md injected via `agentsFilesOverride`** — user's folder stays clean.
12
+ - **NODE_PATH** set so agent's bash scripts can use bundled libraries (exceljs, mammoth, officeparser).
13
+ - **Config file skipped** — nothing reads it yet. Add when Phase 2/3 needs it.
14
+
15
+ ---
16
+
17
+ ## Slice 1: "Hello World" CLI ✅
18
+ Commander CLI with `run <folder>`. Scans folder, lists files by extension.
19
+
20
+ ## Slice 2: PDF → markdown + bounding boxes ✅
21
+ LiteParse parses PDFs → `.md` + `.json` in `.llm-kb/wiki/sources/`. Tested on 9 real PDFs (1000+ pages).
22
+
23
+ ## Slice 3: Scanned PDF handling (OCR) ✅
24
+ LiteParse has Tesseract.js built-in. `ocrEnabled` + `ocrServerUrl` config. OCR off by default. Azure OCR bridge tested on 16 legal PDFs (3000+ pages).
25
+
26
+ ## Slice 4: Progress + error handling ✅
27
+ Inline progress. Stderr suppression. Corrupt file skip + warning. Mtime check — re-runs instant.
28
+
29
+ ## Slice 5: Indexer (Pi SDK) ✅
30
+ `createAgentSession` with cwd = user's folder. AGENTS.md injected. Agent reads sources, writes `index.md` with summary table.
31
+
32
+ ## Slice 6: File watcher ✅
33
+ chokidar watches folder. New/changed PDFs → parse → re-index. 2s debounce for batch drops.
34
+
35
+ ## Slice 7: Config + polish → Skipped
36
+ Config file has no readers yet. Deferred to Phase 2/3. README updated instead.
37
+
38
+ ---
39
+
40
+ ## Phase 1 Complete ✅
41
+
42
+ **What ships:**
43
+ - `llm-kb run ./folder` — scan, parse PDFs, build index, watch for new files
44
+ - Pre-bundled libraries for agent to handle Excel, Word, PowerPoint at query time
45
+ - OCR via env var (local Tesseract or remote Azure bridge)
46
+ - Auth via Pi SDK (zero config)
47
+
48
+ **Phase 2 complete ✅:**
49
+ - `llm-kb query "question"` — auto-detects KB, streams cited answers
50
+ - `--save` flag — research mode, saves to `outputs/`, re-indexes
51
+ - Query mode is read-only (read tool only). Research mode adds bash + write.
52
+
53
+ **Deferred to Phase 4:**
54
+ - Trace logging (JSON per query: question, filesRead, citations, tokens, duration)
55
+ - Needed for eval, but no eval system yet to consume traces