llm-kb 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts CHANGED
@@ -1,132 +1,408 @@
1
- import {
2
- createAgentSession,
3
- createBashTool,
4
- createReadTool,
5
- createWriteTool,
6
- DefaultResourceLoader,
7
- SessionManager,
8
- SettingsManager,
9
- } from "@mariozechner/pi-coding-agent";
10
- import { readdir, mkdir } from "node:fs/promises";
11
- import { join, dirname } from "node:path";
12
- import { fileURLToPath } from "node:url";
13
-
14
- const __dirname = dirname(fileURLToPath(import.meta.url));
15
-
16
- function getNodeModulesPath(): string {
17
- let dir = __dirname;
18
- for (let i = 0; i < 5; i++) {
19
- const candidate = join(dir, "node_modules");
20
- try { return candidate; } catch { dir = dirname(dir); }
21
- }
22
- return join(process.cwd(), "node_modules");
23
- }
24
-
25
- function buildQueryAgents(sourceFiles: string[], save: boolean): string {
26
- const sourceList = sourceFiles.map((f) => ` - ${f}`).join("\n");
27
-
28
- let content = `# llm-kb Knowledge Base — Query Mode
29
-
30
- ## How to answer questions
31
-
32
- 1. FIRST read .llm-kb/wiki/index.md to understand all available sources
33
- 2. Based on the question, select the most relevant source files (usually 2-5)
34
- 3. Read those source files in full from .llm-kb/wiki/sources/
35
- 4. Answer with inline citations: (filename, page number)
36
- 5. If the answer requires cross-referencing multiple files, read additional ones
37
- 6. If you can't find the answer, say so — don't hallucinate
38
-
39
- ## Available parsed sources
40
- ${sourceList}
41
-
42
- ## Non-PDF files
43
- If the user's folder has Excel, Word, or PowerPoint files, these libraries are available:
44
- - **exceljs** — for .xlsx/.xls files
45
- - **mammoth** — for .docx files
46
- - **officeparser** — for .pptx files
47
- Write a quick Node.js script via bash to read them.
48
-
49
- ## Rules
50
- - Always cite sources with filename and page number
51
- - Read the FULL source file, not just the beginning
52
- - Prefer primary sources over previous analyses
53
- `;
54
-
55
- if (save) {
56
- content += `
57
- ## Research Mode
58
- Save your analysis to .llm-kb/wiki/outputs/ with a descriptive filename (e.g., comparison-analysis.md).
59
- Include the question at the top and all citations.
60
- `;
61
- }
62
-
63
- return content;
64
- }
65
-
66
- export async function query(
67
- folder: string,
68
- question: string,
69
- options: { save?: boolean }
70
- ): Promise<void> {
71
- const sourcesDir = join(folder, ".llm-kb", "wiki", "sources");
72
-
73
- const files = await readdir(sourcesDir);
74
- const mdFiles = files.filter((f) => f.endsWith(".md"));
75
-
76
- if (mdFiles.length === 0) {
77
- throw new Error("No sources found. Run 'llm-kb run' first to parse documents.");
78
- }
79
-
80
- if (options.save) {
81
- await mkdir(join(folder, ".llm-kb", "wiki", "outputs"), { recursive: true });
82
- }
83
-
84
- process.env.NODE_PATH = getNodeModulesPath();
85
-
86
- const agentsContent = buildQueryAgents(mdFiles, !!options.save);
87
-
88
- const loader = new DefaultResourceLoader({
89
- cwd: folder,
90
- agentsFilesOverride: (current) => ({
91
- agentsFiles: [
92
- ...current.agentsFiles,
93
- { path: ".llm-kb/AGENTS.md", content: agentsContent },
94
- ],
95
- }),
96
- });
97
- await loader.reload();
98
-
99
- const tools = [createReadTool(folder)];
100
- if (options.save) {
101
- tools.push(createBashTool(folder), createWriteTool(folder));
102
- }
103
-
104
- const { session } = await createAgentSession({
105
- cwd: folder,
106
- resourceLoader: loader,
107
- tools,
108
- sessionManager: SessionManager.inMemory(),
109
- settingsManager: SettingsManager.inMemory({
110
- compaction: { enabled: false },
111
- }),
112
- });
113
-
114
- session.subscribe((event) => {
115
- if (
116
- event.type === "message_update" &&
117
- event.assistantMessageEvent.type === "text_delta"
118
- ) {
119
- process.stdout.write(event.assistantMessageEvent.delta);
120
- }
121
- });
122
-
123
- await session.prompt(question);
124
- console.log();
125
- session.dispose();
126
-
127
- // Re-index after save so the compounding loop works
128
- if (options.save) {
129
- const { buildIndex } = await import("./indexer.js");
130
- await buildIndex(folder, sourcesDir);
131
- }
132
- }
1
+ import {
2
+ createAgentSession,
3
+ createBashTool,
4
+ createReadTool,
5
+ createWriteTool,
6
+ DefaultResourceLoader,
7
+ SettingsManager,
8
+ AuthStorage,
9
+ } from "@mariozechner/pi-coding-agent";
10
+ import type { AgentSession } from "@mariozechner/pi-coding-agent";
11
+ import { getModels } from "@mariozechner/pi-ai";
12
+ import { readdir, mkdir, readFile } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { createKBSession, continueKBSession } from "./session-store.js";
15
+ import { saveTrace, appendToQueryLog, KBTrace } from "./trace-builder.js";
16
+ import { updateWiki } from "./wiki-updater.js";
17
+ import { join, basename } from "node:path";
18
+ import chalk from "chalk";
19
+ import { getNodeModulesPath } from "./utils.js";
20
+ import { MarkdownStream } from "./md-stream.js";
21
+ import type { ChatDisplay } from "./tui-display.js";
22
+
23
+ // ── Helpers ─────────────────────────────────────────────────────────────────
24
+
25
+ function extractAnswerText(content: any[]): string {
26
+ return (content ?? [])
27
+ .filter((b: any) => b.type === "text")
28
+ .map((b: any) => b.text ?? "")
29
+ .join("")
30
+ .trim();
31
+ }
32
+
33
+ function extractFilesRead(messages: any[]): string[] {
34
+ const paths: string[] = [];
35
+ for (const msg of messages) {
36
+ if (msg.role !== "assistant") continue;
37
+ for (const block of msg.content ?? []) {
38
+ if (block.type === "toolCall" && block.name === "read") {
39
+ const p: string = block.arguments?.path ?? "";
40
+ if (p && !paths.includes(p)) paths.push(p);
41
+ }
42
+ }
43
+ }
44
+ return paths;
45
+ }
46
+
47
+ function getToolLabel(toolName: string, args: any): string | null {
48
+ if (toolName === "read" || toolName === "write" || toolName === "edit") {
49
+ const file = basename((args?.path as string) ?? "");
50
+ if (!file || !/\.[a-z0-9]{1,6}$/i.test(file)) return null;
51
+ const verb = toolName === "read" ? "Reading" : toolName === "write" ? "Writing" : "Editing";
52
+ return `${verb} ${file}`;
53
+ }
54
+ if (toolName === "bash" && args?.command) {
55
+ return `Running bash`;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // ── AGENTS.md ───────────────────────────────────────────────────────────────
61
+
62
+ function buildQueryAgents(sourceFiles: string[], save: boolean, wikiContent: string): string {
63
+ const sourceList = sourceFiles.map((f) => ` - ${f}`).join("\n");
64
+ const wikiSection = wikiContent
65
+ ? `## Knowledge Wiki (use this first)\n\nThe wiki below contains knowledge already extracted from this knowledge base.\nIf the user's question is covered here, answer directly from it — no need to re-read source files.\nAlways cite the original source files mentioned in the wiki.\n\n${wikiContent}\n\n---\n\n`
66
+ : "";
67
+ const sourceStep = wikiContent ? "If not covered in the wiki above: read the sources" : "How to answer";
68
+
69
+ const lines = [
70
+ `# llm-kb Knowledge Base — Query Mode`,
71
+ ``,
72
+ wikiSection,
73
+ `## ${sourceStep}`,
74
+ ``,
75
+ `1. Read .llm-kb/wiki/index.md to understand all available sources`,
76
+ `2. Select the most relevant source files (usually 2-5) and read them in full`,
77
+ `3. Answer with inline citations: (filename, page number)`,
78
+ `4. If you can't find the answer, say so — don't hallucinate`,
79
+ ``,
80
+ `## Available parsed sources`,
81
+ sourceList,
82
+ ``,
83
+ `## Non-PDF files (docx, xlsx, pptx)`,
84
+ `Use bash to run Node.js scripts. Libraries are pre-installed via require().`,
85
+ ``,
86
+ `### Word (.docx) — structured XML`,
87
+ `.docx files are ZIP archives containing word/document.xml.`,
88
+ `Read them SELECTIVELY extract only what is relevant to the question:`,
89
+ ``,
90
+ "```javascript",
91
+ `const AdmZip = require('adm-zip');`,
92
+ `const zip = new AdmZip('file.docx');`,
93
+ `const xml = zip.readAsText('word/document.xml');`,
94
+ `// Parse XML to find specific paragraphs, headings, tables`,
95
+ "```",
96
+ ``,
97
+ `Strategy for large .docx files:`,
98
+ `1. First: extract headings/structure to understand the document layout`,
99
+ `2. Then: extract only the sections relevant to the user's question`,
100
+ `NEVER dump the entire document.`,
101
+ ``,
102
+ `### Excel (.xlsx) — use exceljs`,
103
+ `Read specific sheets and ranges, not the whole workbook:`,
104
+ ``,
105
+ "```javascript",
106
+ `const ExcelJS = require('exceljs');`,
107
+ `const wb = new ExcelJS.Workbook();`,
108
+ `await wb.xlsx.readFile('file.xlsx');`,
109
+ `const sheet = wb.getWorksheet(1);`,
110
+ `// Read specific rows/columns relevant to the question`,
111
+ "```",
112
+ ``,
113
+ `### PowerPoint (.pptx) — use officeparser`,
114
+ ``,
115
+ "```javascript",
116
+ `const officeparser = require('officeparser');`,
117
+ `const text = await officeparser.parseOfficeAsync('file.pptx');`,
118
+ "```",
119
+ ``,
120
+ `## Rules`,
121
+ `- Always cite sources with filename and page number`,
122
+ `- Read the FULL source file, not just the beginning (for .md sources)`,
123
+ `- For non-PDF files, extract ONLY relevant sections — never dump entire files`,
124
+ `- Prefer primary sources over previous analyses`,
125
+ ];
126
+
127
+ if (save) {
128
+ 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.`);
129
+ }
130
+
131
+ return lines.join("\n");
132
+ }
133
+
134
+ // ── Wiki update scheduler ───────────────────────────────────────────────────
135
+
136
+ class WikiUpdateScheduler {
137
+ private stopMsgCount = 0;
138
+ private lastUpdateAt = 0;
139
+ private chain: Promise<void> = Promise.resolve();
140
+ constructor(private readonly everyN: number, private readonly everyMin: number) {}
141
+ private shouldUpdate() {
142
+ return (this.stopMsgCount > 0 && this.stopMsgCount % this.everyN === 0) ||
143
+ (this.lastUpdateAt > 0 && Date.now() - this.lastUpdateAt > this.everyMin * 60_000);
144
+ }
145
+ private enqueue(work: () => Promise<void>) { this.chain = this.chain.then(() => work().catch(() => {})); }
146
+ onMessageEnd(msg: any, snap: () => { messages: any[] }, doUpdate: (m: any[]) => Promise<void>) {
147
+ if (msg.role !== "assistant" || msg.stopReason !== "stop") return;
148
+ this.stopMsgCount++;
149
+ if (this.shouldUpdate()) { this.lastUpdateAt = Date.now(); this.enqueue(() => doUpdate(snap().messages)); }
150
+ }
151
+ onAgentEnd(msgs: any[], doUpdate: (m: any[]) => Promise<void>) {
152
+ this.lastUpdateAt = Date.now(); this.enqueue(() => doUpdate(msgs));
153
+ }
154
+ flush() { return this.chain; }
155
+ }
156
+
157
+ // ── Display subscriber ──────────────────────────────────────────────────────
158
+ // Routes events to either TUI components (interactive) or stdout (one-shot)
159
+
160
+ function subscribeDisplay(
161
+ session: AgentSession,
162
+ opts: {
163
+ modelId?: string;
164
+ authStorage?: AuthStorage;
165
+ folder: string;
166
+ mdFiles: string[];
167
+ tuiDisplay?: ChatDisplay;
168
+ }
169
+ ) {
170
+ const ui = opts.tuiDisplay;
171
+ const dim = (s: string) => process.stdout.isTTY ? chalk.dim(s) : s;
172
+ const thinLine = () => dim("\u2500".repeat(process.stdout.columns || 80));
173
+
174
+ let phase: "idle" | "thinking" | "tools" | "answer" = "idle";
175
+ let filesReadCount = 0;
176
+ let shownToolCalls = new Set<string>();
177
+ let startTime = Date.now();
178
+ let md = new MarkdownStream(process.stdout.isTTY ?? false);
179
+ let lastQuestion = "";
180
+
181
+ const scheduler = new WikiUpdateScheduler(5, 3);
182
+
183
+ const buildTrace = (messages: any[]): KBTrace | null => {
184
+ const last = [...messages].reverse().find((m) => m.role === "assistant" && m.stopReason === "stop");
185
+ if (!last) return null;
186
+ const filesRead = extractFilesRead(messages);
187
+ return {
188
+ sessionId: session.sessionId, sessionFile: session.sessionFile ?? "",
189
+ timestamp: new Date().toISOString(), mode: "query", question: lastQuestion,
190
+ answer: extractAnswerText(last.content), filesRead,
191
+ filesAvailable: opts.mdFiles,
192
+ filesSkipped: opts.mdFiles.filter((f) => !filesRead.some((r) => r.endsWith(f))),
193
+ model: last.model,
194
+ };
195
+ };
196
+
197
+ const doUpdate = async (messages: any[]) => {
198
+ const trace = buildTrace(messages);
199
+ if (!trace) return;
200
+ await saveTrace(opts.folder, trace);
201
+ await appendToQueryLog(opts.folder, trace);
202
+ await updateWiki(opts.folder, trace, opts.authStorage);
203
+ };
204
+
205
+ session.subscribe((event) => {
206
+
207
+ // ── Reset ────────────────────────────────────────────────────────────
208
+ if (event.type === "agent_start") {
209
+ phase = "idle";
210
+ filesReadCount = 0;
211
+ shownToolCalls = new Set();
212
+ startTime = Date.now();
213
+ md = new MarkdownStream(process.stdout.isTTY ?? false);
214
+ const modelName = opts.modelId ?? "claude-sonnet-4-6";
215
+ if (ui) { ui.disableInput(); ui.beginResponse(modelName); }
216
+ else process.stdout.write(dim(`\u27e1 ${modelName}`) + "\n");
217
+ }
218
+
219
+ // ── Thinking ─────────────────────────────────────────────────────────
220
+ if (event.type === "message_update") {
221
+ const ae = event.assistantMessageEvent;
222
+ if (ae.type === "thinking_start") {
223
+ if (!ui) process.stdout.write(dim("\n\u25b8 Thinking\n"));
224
+ phase = "thinking";
225
+ }
226
+ if (ae.type === "thinking_delta") {
227
+ if (ui) ui.appendThinking(ae.delta);
228
+ else process.stdout.write(dim(` ${ae.delta}`));
229
+ }
230
+ if (ae.type === "thinking_end") {
231
+ if (ui) ui.endThinking();
232
+ else process.stdout.write("\n");
233
+ }
234
+ }
235
+
236
+ // ── Tool calls ───────────────────────────────────────────────────────
237
+ if (event.type === "message_update") {
238
+ const ae = event.assistantMessageEvent as any;
239
+ if (ae.type === "toolcall_end" && ae.toolCall) {
240
+ const label = getToolLabel(ae.toolCall.name, ae.toolCall.arguments);
241
+ if (label) {
242
+ if (!ui && phase !== "tools") process.stdout.write("\n");
243
+ phase = "tools";
244
+ if (ui) {
245
+ ui.addToolCall(ae.toolCall.id, label, ae.toolCall.name);
246
+ // Show the actual bash code the agent wrote
247
+ if (ae.toolCall.name === "bash" && ae.toolCall.arguments?.command) {
248
+ ui.addCodeBlock(ae.toolCall.arguments.command);
249
+ }
250
+ } else {
251
+ process.stdout.write(dim(` \u25b8 ${label}`) + "\n");
252
+ // Show bash code in stdout mode too
253
+ if (ae.toolCall.name === "bash" && ae.toolCall.arguments?.command) {
254
+ const code = ae.toolCall.arguments.command as string;
255
+ process.stdout.write(dim(code.split("\n").map(l => ` ${l}`).join("\n")) + "\n");
256
+ }
257
+ shownToolCalls.add(ae.toolCall.id);
258
+ if (ae.toolCall.name === "read") filesReadCount++;
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ if (event.type === "tool_execution_start") {
265
+ const { toolCallId, toolName, args } = event as any;
266
+ if (ui) {
267
+ const label = getToolLabel(toolName, args);
268
+ if (label) ui.addToolCall(toolCallId, label, toolName);
269
+ } else if (!shownToolCalls.has(toolCallId)) {
270
+ const label = getToolLabel(toolName, args);
271
+ if (label) {
272
+ if (phase !== "tools") process.stdout.write("\n");
273
+ phase = "tools";
274
+ process.stdout.write(dim(` \u25b8 ${label}`) + "\n");
275
+ shownToolCalls.add(toolCallId);
276
+ if (toolName === "read") filesReadCount++;
277
+ }
278
+ }
279
+ }
280
+
281
+ // tool result (show errors)
282
+ if (event.type === "tool_execution_end") {
283
+ const { toolCallId, isError } = event as any;
284
+ if (ui) ui.addToolResult(toolCallId, isError);
285
+ }
286
+
287
+ // ── Answer ───────────────────────────────────────────────────────────
288
+ if (event.type === "message_update") {
289
+ const ae = event.assistantMessageEvent;
290
+ if (ae.type === "text_start" && phase !== "answer") {
291
+ if (ui) ui.beginAnswer();
292
+ else if (phase === "thinking" || phase === "tools") {
293
+ process.stdout.write(`\n${thinLine()}\n\n`);
294
+ }
295
+ phase = "answer";
296
+ }
297
+ if (ae.type === "text_delta") {
298
+ if (ui) ui.appendAnswer(ae.delta);
299
+ else process.stdout.write(md.push(ae.delta));
300
+ }
301
+ if (ae.type === "text_end" && !ui) process.stdout.write(md.end());
302
+ }
303
+
304
+ // ── Completion ───────────────────────────────────────────────────────
305
+ if (event.type === "agent_end") {
306
+ if (ui) { ui.showCompletion(); ui.enableInput(); }
307
+ else {
308
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
309
+ const source = filesReadCount > 0
310
+ ? `${filesReadCount} file${filesReadCount !== 1 ? "s" : ""} read` : "wiki";
311
+ const stats = `${elapsed}s \u00b7 ${source}`;
312
+ const cols = process.stdout.columns || 80;
313
+ const pad = Math.max(0, cols - stats.length - 4);
314
+ process.stdout.write(`\n\n${dim("\u2500\u2500 " + stats + " " + "\u2500".repeat(pad))}\n`);
315
+ }
316
+ scheduler.onAgentEnd(event.messages as any[], doUpdate);
317
+ }
318
+
319
+ // ── Wiki throttle ────────────────────────────────────────────────────
320
+ if (event.type === "message_end") {
321
+ scheduler.onMessageEnd(event.message, () => ({ messages: session.state.messages as any[] }), doUpdate);
322
+ }
323
+ });
324
+
325
+ return {
326
+ setQuestion(q: string) { lastQuestion = q; },
327
+ flush() { return scheduler.flush(); },
328
+ };
329
+ }
330
+
331
+ // ── Session factory ─────────────────────────────────────────────────────────
332
+
333
+ export interface ChatSession {
334
+ session: AgentSession;
335
+ display: ReturnType<typeof subscribeDisplay>;
336
+ }
337
+
338
+ export async function createChat(
339
+ folder: string,
340
+ options: { save?: boolean; authStorage?: AuthStorage; modelId?: string; tuiDisplay?: ChatDisplay }
341
+ ): Promise<ChatSession> {
342
+ const sourcesDir = join(folder, ".llm-kb", "wiki", "sources");
343
+ const files = await readdir(sourcesDir);
344
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
345
+ if (mdFiles.length === 0) throw new Error("No sources found. Run 'llm-kb run' first.");
346
+ if (options.save) await mkdir(join(folder, ".llm-kb", "wiki", "outputs"), { recursive: true });
347
+
348
+ process.env.NODE_PATH = getNodeModulesPath();
349
+
350
+ const wikiPath = join(folder, ".llm-kb", "wiki", "wiki.md");
351
+ const wikiContent = existsSync(wikiPath) ? await readFile(wikiPath, "utf-8").catch(() => "") : "";
352
+ const agentsContent = buildQueryAgents(mdFiles, !!options.save, wikiContent);
353
+
354
+ const loader = new DefaultResourceLoader({
355
+ cwd: folder,
356
+ agentsFilesOverride: (current) => ({
357
+ agentsFiles: [...current.agentsFiles, { path: ".llm-kb/AGENTS.md", content: agentsContent }],
358
+ }),
359
+ });
360
+ await loader.reload();
361
+
362
+ // Always include all tools — agent needs bash for .docx/.xlsx reading
363
+ const tools = [
364
+ createReadTool(folder),
365
+ createBashTool(folder),
366
+ createWriteTool(folder),
367
+ ];
368
+
369
+ const model = options.modelId ? getModels("anthropic").find((m) => m.id === options.modelId) : undefined;
370
+
371
+ const { session } = await createAgentSession({
372
+ cwd: folder,
373
+ resourceLoader: loader,
374
+ tools,
375
+ sessionManager: options.save ? await createKBSession(folder) : await continueKBSession(folder),
376
+ settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
377
+ thinkingLevel: "low",
378
+ ...(options.authStorage ? { authStorage: options.authStorage } : {}),
379
+ ...(model ? { model } : {}),
380
+ });
381
+
382
+ const display = subscribeDisplay(session, {
383
+ modelId: options.modelId, authStorage: options.authStorage,
384
+ folder, mdFiles, tuiDisplay: options.tuiDisplay,
385
+ });
386
+
387
+ return { session, display };
388
+ }
389
+
390
+ // ── One-shot query (stdout mode, for `llm-kb query` command) ────────────────
391
+
392
+ export async function query(
393
+ folder: string,
394
+ question: string,
395
+ options: { save?: boolean; authStorage?: AuthStorage; modelId?: string }
396
+ ): Promise<void> {
397
+ const { session, display } = await createChat(folder, options);
398
+ session.setSessionName(`query: ${question}`);
399
+ display.setQuestion(question);
400
+ await session.prompt(question);
401
+ await display.flush();
402
+ session.dispose();
403
+ if (options.save) {
404
+ const sourcesDir = join(folder, ".llm-kb", "wiki", "sources");
405
+ const { buildIndex } = await import("./indexer.js");
406
+ await buildIndex(folder, sourcesDir, undefined, options.authStorage);
407
+ }
408
+ }
@@ -0,0 +1,22 @@
1
+ import { SessionManager } from "@mariozechner/pi-coding-agent";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * Continue the most recent session, or create a new one if none exists.
7
+ * Sessions persist in .llm-kb/sessions/ — conversation history survives restarts.
8
+ */
9
+ export async function continueKBSession(kbRoot: string): Promise<SessionManager> {
10
+ const sessionDir = join(kbRoot, ".llm-kb", "sessions");
11
+ await mkdir(sessionDir, { recursive: true });
12
+ return SessionManager.continueRecent(kbRoot, sessionDir);
13
+ }
14
+
15
+ /**
16
+ * Always create a fresh session (for one-shot `llm-kb query` or indexing).
17
+ */
18
+ export async function createKBSession(kbRoot: string): Promise<SessionManager> {
19
+ const sessionDir = join(kbRoot, ".llm-kb", "sessions");
20
+ await mkdir(sessionDir, { recursive: true });
21
+ return SessionManager.create(kbRoot, sessionDir);
22
+ }
@@ -0,0 +1,89 @@
1
+ import { watch } from "chokidar";
2
+ import { join, basename } from "node:path";
3
+ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
4
+ import { existsSync } from "node:fs";
5
+ import { buildTrace, saveTrace, appendToQueryLog } from "./trace-builder.js";
6
+ import { updateWiki } from "./wiki-updater.js";
7
+
8
+ const PROCESSED_LOG = ".llm-kb/traces/.processed";
9
+
10
+ async function loadProcessed(kbRoot: string): Promise<Set<string>> {
11
+ const path = join(kbRoot, PROCESSED_LOG);
12
+ if (!existsSync(path)) return new Set();
13
+ try {
14
+ const lines = (await readFile(path, "utf-8")).split("\n").filter(Boolean);
15
+ return new Set(lines);
16
+ } catch {
17
+ return new Set();
18
+ }
19
+ }
20
+
21
+ async function markProcessed(kbRoot: string, sessionId: string): Promise<void> {
22
+ const path = join(kbRoot, PROCESSED_LOG);
23
+ await mkdir(join(kbRoot, ".llm-kb", "traces"), { recursive: true });
24
+ await writeFile(path, sessionId + "\n", { flag: "a" });
25
+ }
26
+
27
+ /**
28
+ * Watch .llm-kb/sessions/ for completed session files.
29
+ * Processes them silently — saves traces, updates wiki, logs queries.
30
+ * Persists processed IDs to .llm-kb/traces/.processed to survive restarts.
31
+ */
32
+ export async function startSessionWatcher(kbRoot: string): Promise<void> {
33
+ const sessionsDir = join(kbRoot, ".llm-kb", "sessions");
34
+ const sourcesDir = join(kbRoot, ".llm-kb", "wiki", "sources");
35
+
36
+ const processed = await loadProcessed(kbRoot);
37
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
38
+
39
+ async function processSession(filePath: string): Promise<void> {
40
+ const sessionId = basename(filePath, ".jsonl").split("_")[1] ?? basename(filePath, ".jsonl");
41
+ if (processed.has(sessionId)) return;
42
+
43
+ try {
44
+ const trace = await buildTrace(filePath, sourcesDir);
45
+ if (!trace) return;
46
+
47
+ processed.add(trace.sessionId);
48
+ await markProcessed(kbRoot, trace.sessionId);
49
+
50
+ await saveTrace(kbRoot, trace);
51
+
52
+ if (trace.mode === "query") {
53
+ await appendToQueryLog(kbRoot, trace);
54
+ await updateWiki(kbRoot, trace);
55
+ }
56
+ } catch {
57
+ // Non-fatal — session may still be in progress
58
+ }
59
+ }
60
+
61
+ function scheduleProcess(filePath: string): void {
62
+ const existing = timers.get(filePath);
63
+ if (existing) clearTimeout(existing);
64
+ const timer = setTimeout(() => {
65
+ timers.delete(filePath);
66
+ processSession(filePath);
67
+ }, 1500);
68
+ timers.set(filePath, timer);
69
+ }
70
+
71
+ // Catch-up: process existing unprocessed sessions silently
72
+ if (existsSync(sessionsDir)) {
73
+ try {
74
+ const files = (await readdir(sessionsDir)).filter((f) => f.endsWith(".jsonl"));
75
+ for (const f of files) {
76
+ await processSession(join(sessionsDir, f));
77
+ }
78
+ } catch { /* ignore */ }
79
+ }
80
+
81
+ const watcher = watch(sessionsDir, {
82
+ ignoreInitial: true,
83
+ awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
84
+ depth: 0,
85
+ });
86
+
87
+ watcher.on("add", (p) => { if (p.endsWith(".jsonl")) scheduleProcess(p); });
88
+ watcher.on("change", (p) => { if (p.endsWith(".jsonl")) scheduleProcess(p); });
89
+ }