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.
@@ -0,0 +1,168 @@
1
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join, basename as pathBasename } from "node:path";
4
+
5
+ export interface KBTrace {
6
+ sessionId: string;
7
+ sessionFile: string;
8
+ timestamp: string;
9
+ mode: "query" | "index" | "unknown";
10
+ question?: string;
11
+ answer?: string;
12
+ filesRead: string[];
13
+ filesAvailable: string[];
14
+ filesSkipped: string[];
15
+ model?: string;
16
+ durationMs?: number;
17
+ }
18
+
19
+ /** Parse a session JSONL file and build a KBTrace. Returns null if session isn't complete yet. */
20
+ export async function buildTrace(
21
+ sessionFile: string,
22
+ sourcesDir: string
23
+ ): Promise<KBTrace | null> {
24
+ const raw = await readFile(sessionFile, "utf-8");
25
+ const lines = raw.trim().split("\n").filter(Boolean);
26
+ if (lines.length < 2) return null;
27
+
28
+ const entries: any[] = [];
29
+ let header: any = null;
30
+ for (const line of lines) {
31
+ try {
32
+ const obj = JSON.parse(line);
33
+ if (obj.type === "session") header = obj;
34
+ else entries.push(obj);
35
+ } catch { /* skip malformed lines */ }
36
+ }
37
+
38
+ if (!header) return null;
39
+
40
+ const messages = entries.filter((e) => e.type === "message");
41
+ const lastAssistant = [...messages].reverse().find(
42
+ (e) => e.message?.role === "assistant" && e.message?.stopReason === "stop"
43
+ );
44
+ if (!lastAssistant) return null;
45
+
46
+ const modelChange = entries.find((e) => e.type === "model_change");
47
+ const model = modelChange?.modelId ?? lastAssistant.message?.model ?? undefined;
48
+
49
+ const firstUser = messages.find((e) => e.message?.role === "user");
50
+ const question = extractText(firstUser?.message?.content);
51
+
52
+ const sessionInfo = entries.find((e) => e.type === "session_info");
53
+ const sessionName: string = sessionInfo?.name ?? "";
54
+ const mode: KBTrace["mode"] = sessionName.startsWith("index:")
55
+ ? "index"
56
+ : sessionName.startsWith("query:") || question
57
+ ? "query"
58
+ : "unknown";
59
+
60
+ const answer = extractText(lastAssistant.message?.content);
61
+
62
+ const filesRead: string[] = [];
63
+ for (const entry of messages) {
64
+ if (entry.message?.role !== "assistant") continue;
65
+ for (const block of entry.message?.content ?? []) {
66
+ if (block.type === "toolCall" && block.name === "read") {
67
+ const p: string = block.arguments?.path ?? "";
68
+ if (p && !filesRead.includes(p)) filesRead.push(p);
69
+ }
70
+ }
71
+ }
72
+
73
+ let filesAvailable: string[] = [];
74
+ try {
75
+ const all = await readdir(sourcesDir);
76
+ filesAvailable = all.filter((f) => f.endsWith(".md"));
77
+ } catch { /* sources dir may not exist */ }
78
+
79
+ const filesSkipped = filesAvailable.filter(
80
+ (f) => !filesRead.some((r) => r.endsWith(f))
81
+ );
82
+
83
+ const firstMsg = messages[0];
84
+ const lastMsg = messages[messages.length - 1];
85
+ let durationMs: number | undefined;
86
+ if (firstMsg?.timestamp && lastMsg?.timestamp) {
87
+ durationMs = new Date(lastMsg.timestamp).getTime() - new Date(firstMsg.timestamp).getTime();
88
+ }
89
+
90
+ return {
91
+ sessionId: header.id,
92
+ sessionFile: pathBasename(sessionFile),
93
+ timestamp: header.timestamp,
94
+ mode,
95
+ question: question || undefined,
96
+ answer: answer || undefined,
97
+ filesRead,
98
+ filesAvailable,
99
+ filesSkipped,
100
+ model,
101
+ durationMs,
102
+ };
103
+ }
104
+
105
+ /** Write a KBTrace to .llm-kb/traces/<sessionId>.json */
106
+ export async function saveTrace(kbRoot: string, trace: KBTrace): Promise<void> {
107
+ const tracesDir = join(kbRoot, ".llm-kb", "traces");
108
+ await mkdir(tracesDir, { recursive: true });
109
+ const outPath = join(tracesDir, `${trace.sessionId}.json`);
110
+ await writeFile(outPath, JSON.stringify(trace, null, 2) + "\n", "utf-8");
111
+ }
112
+
113
+ /** Append a query entry to .llm-kb/wiki/queries.md (newest first) */
114
+ export async function appendToQueryLog(kbRoot: string, trace: KBTrace): Promise<void> {
115
+ if (trace.mode !== "query" || !trace.question) return;
116
+
117
+ const wikiDir = join(kbRoot, ".llm-kb", "wiki");
118
+ await mkdir(wikiDir, { recursive: true });
119
+ const logPath = join(wikiDir, "queries.md");
120
+
121
+ const date = new Date(trace.timestamp).toISOString().replace("T", " ").slice(0, 19);
122
+ const durationSec = trace.durationMs ? `${(trace.durationMs / 1000).toFixed(1)}s` : "?";
123
+ const filesLine = trace.filesRead.length > 0
124
+ ? trace.filesRead.map((f) => pathBasename(f)).join(", ")
125
+ : "_none_";
126
+
127
+ let header = "";
128
+ if (!existsSync(logPath)) {
129
+ header = `# Query Log\n\nAll queries run against this knowledge base.\n\n---\n\n`;
130
+ }
131
+
132
+ const entry = [
133
+ `## ${trace.question}`,
134
+ ``,
135
+ `- **Date:** ${date}`,
136
+ `- **Model:** ${trace.model ?? "unknown"}`,
137
+ `- **Duration:** ${durationSec}`,
138
+ `- **Files read:** ${filesLine}`,
139
+ trace.filesSkipped.length > 0
140
+ ? `- **Files skipped:** ${trace.filesSkipped.join(", ")}`
141
+ : null,
142
+ ``,
143
+ trace.answer ? `### Answer\n\n${trace.answer}` : null,
144
+ ``,
145
+ `---`,
146
+ ``,
147
+ ]
148
+ .filter((l) => l !== null)
149
+ .join("\n");
150
+
151
+ const existing = existsSync(logPath) ? await readFile(logPath, "utf-8") : "";
152
+ await writeFile(logPath, header + entry + existing, "utf-8");
153
+ }
154
+
155
+ // --- helpers ---
156
+
157
+ function extractText(content: any): string {
158
+ if (!content) return "";
159
+ if (typeof content === "string") return content;
160
+ if (Array.isArray(content)) {
161
+ return content
162
+ .filter((b) => b.type === "text")
163
+ .map((b) => b.text ?? "")
164
+ .join("")
165
+ .trim();
166
+ }
167
+ return "";
168
+ }
@@ -0,0 +1,281 @@
1
+ import {
2
+ TUI, Container, Spacer, Text, Markdown, ProcessTerminal,
3
+ type MarkdownTheme, type Component, Input,
4
+ } from "@mariozechner/pi-tui";
5
+ import chalk from "chalk";
6
+
7
+ // ── Markdown theme ──────────────────────────────────────────────────────────
8
+
9
+ function createMarkdownTheme(): MarkdownTheme {
10
+ return {
11
+ heading: (t) => chalk.bold(t),
12
+ link: (t) => chalk.cyan(t),
13
+ linkUrl: (t) => chalk.dim(t),
14
+ code: (t) => chalk.cyan(t),
15
+ codeBlock: (t) => chalk.dim(t),
16
+ codeBlockBorder: (t) => chalk.dim(t),
17
+ quote: (t) => chalk.italic(t),
18
+ quoteBorder: (t) => chalk.dim(t),
19
+ hr: (t) => chalk.dim(t),
20
+ listBullet: (t) => chalk.dim(t),
21
+ bold: (t) => chalk.bold(t),
22
+ italic: (t) => chalk.italic(t),
23
+ underline: (t) => chalk.underline(t),
24
+ strikethrough: (t) => chalk.strikethrough(t),
25
+ };
26
+ }
27
+
28
+ const mdTheme = createMarkdownTheme();
29
+
30
+ // ── Helper components ───────────────────────────────────────────────────────
31
+
32
+ function dimText(text: string, px = 1, py = 0): Text {
33
+ return new Text(chalk.dim(text), px, py);
34
+ }
35
+
36
+ class HRule implements Component {
37
+ private colorFn: (s: string) => string;
38
+ constructor(colorFn?: (s: string) => string) {
39
+ this.colorFn = colorFn ?? chalk.dim;
40
+ }
41
+ invalidate() {}
42
+ render(width: number): string[] {
43
+ return [this.colorFn("\u2500".repeat(width))];
44
+ }
45
+ }
46
+
47
+ // ── Chat display ────────────────────────────────────────────────────────────
48
+ //
49
+ // All components are appended sequentially to currentResponse in the order
50
+ // events arrive. This naturally handles interleaved thinking/tools/text:
51
+ //
52
+ // ⟡ model
53
+ // ▸ Thinking: reasoning...
54
+ // Let me read the file... ← 1st Markdown block
55
+ // ▸ Reading file.md ← tool call (between text blocks)
56
+ // ──────────────────────
57
+ // Based on the document... ← 2nd Markdown block
58
+ // ▸ Thinking: let me check another...
59
+ // ▸ Reading file2.md
60
+ // ──────────────────────
61
+ // The final answer is... ← 3rd Markdown block
62
+ // ── 12.3s · 2 files read ──────
63
+
64
+ export class ChatDisplay {
65
+ private tui: TUI;
66
+ private terminal: ProcessTerminal;
67
+ private messageArea: Container;
68
+ private inputArea: Container;
69
+ private input: Input;
70
+
71
+ // Current response (reset per prompt)
72
+ private currentResponse: Container | null = null;
73
+ private currentMd: Markdown | null = null; // active text block
74
+ private currentThinking: Text | null = null; // active thinking block
75
+ private hadSeparator = false; // has a ─── line been drawn?
76
+
77
+ private filesReadCount = 0;
78
+ private shownToolCalls = new Set<string>();
79
+ private startTime = Date.now();
80
+
81
+ onSubmit?: (text: string) => void;
82
+ onExit?: () => void;
83
+
84
+ constructor() {
85
+ this.terminal = new ProcessTerminal();
86
+ this.tui = new TUI(this.terminal);
87
+
88
+ this.messageArea = new Container();
89
+ this.tui.addChild(this.messageArea);
90
+
91
+ this.inputArea = new Container();
92
+ this.inputArea.addChild(new HRule((s) => chalk.hex("#c678dd")(s)));
93
+
94
+ this.input = new Input();
95
+ this.input.onSubmit = (text) => {
96
+ if (text.trim() && this.onSubmit) {
97
+ this.addUserMessage(text.trim());
98
+ this.onSubmit(text.trim());
99
+ }
100
+ this.input.setValue("");
101
+ };
102
+ this.inputArea.addChild(this.input);
103
+ this.inputArea.addChild(new HRule((s) => chalk.hex("#c678dd")(s)));
104
+
105
+ this.tui.addChild(this.inputArea);
106
+ this.tui.setFocus(this.input);
107
+ }
108
+
109
+ start(): void {
110
+ this.tui.start();
111
+
112
+ this.tui.addInputListener((data) => {
113
+ if (data === "\x03" || data === "\x04") {
114
+ this.stop();
115
+ if (this.onExit) this.onExit();
116
+ else process.exit(0);
117
+ return { consume: true };
118
+ }
119
+ return undefined;
120
+ });
121
+
122
+ this.tui.requestRender();
123
+ }
124
+
125
+ stop(): void {
126
+ this.tui.stop();
127
+ }
128
+
129
+ addUserMessage(text: string): void {
130
+ this.messageArea.addChild(new Spacer(1));
131
+ this.messageArea.addChild(new Text(chalk.bold(text), 1, 0));
132
+ this.tui.requestRender();
133
+ }
134
+
135
+ // ── Per-prompt lifecycle (events arrive in any order) ───────────────────
136
+
137
+ beginResponse(modelName: string): void {
138
+ this.filesReadCount = 0;
139
+ this.shownToolCalls = new Set();
140
+ this.startTime = Date.now();
141
+ this.currentMd = null;
142
+ this.currentThinking = null;
143
+ this.hadSeparator = false;
144
+
145
+ this.currentResponse = new Container();
146
+ this.currentResponse.addChild(new Spacer(1));
147
+ this.currentResponse.addChild(dimText(`\u27e1 ${modelName}`));
148
+ this.messageArea.addChild(this.currentResponse);
149
+ this.tui.requestRender();
150
+ }
151
+
152
+ /** Start or continue a thinking block. Closes any active text block. */
153
+ appendThinking(text: string): void {
154
+ if (!this.currentResponse) return;
155
+
156
+ // Close active text block — thinking interrupts it
157
+ this.currentMd = null;
158
+
159
+ if (!this.currentThinking) {
160
+ this.currentResponse.addChild(new Spacer(1));
161
+ this.currentResponse.addChild(dimText("\u25b8 Thinking"));
162
+ this.currentThinking = new Text(chalk.dim(chalk.italic(text)), 2, 0);
163
+ this.currentResponse.addChild(this.currentThinking);
164
+ } else {
165
+ const prev = (this.currentThinking as any).text ?? "";
166
+ this.currentThinking.setText(
167
+ chalk.dim(chalk.italic(prev.replace(/\x1b\[[0-9;]*m/g, "") + text))
168
+ );
169
+ }
170
+ this.tui.requestRender();
171
+ }
172
+
173
+ /** End the current thinking block */
174
+ endThinking(): void {
175
+ this.currentThinking = null;
176
+ }
177
+
178
+ /** Add a tool call line. Closes active text block. */
179
+ addToolCall(toolCallId: string, label: string, toolName: string): void {
180
+ if (!this.currentResponse || this.shownToolCalls.has(toolCallId)) return;
181
+ this.shownToolCalls.add(toolCallId);
182
+ if (toolName === "read") this.filesReadCount++;
183
+
184
+ // Close active text block — tool call interrupts it
185
+ this.currentMd = null;
186
+
187
+ this.currentResponse.addChild(dimText(` \u25b8 ${label}`));
188
+ this.tui.requestRender();
189
+ }
190
+
191
+ /** Show code block (e.g. bash script the agent wrote) */
192
+ addCodeBlock(code: string): void {
193
+ if (!this.currentResponse) return;
194
+ this.currentMd = null;
195
+ // Use Markdown component to render the code block with proper formatting
196
+ this.currentResponse.addChild(new Markdown("```javascript\n" + code + "\n```", 2, 0, mdTheme));
197
+ this.tui.requestRender();
198
+ }
199
+
200
+ /** Show tool execution result (success/error) */
201
+ addToolResult(toolCallId: string, isError: boolean): void {
202
+ if (!this.currentResponse) return;
203
+ if (isError) {
204
+ this.currentResponse.addChild(new Text(chalk.red(" \u2717 failed"), 0, 0));
205
+ this.tui.requestRender();
206
+ }
207
+ }
208
+
209
+ /** Start a NEW text block with a separator (if not the first). */
210
+ beginAnswer(): void {
211
+ if (!this.currentResponse) return;
212
+
213
+ // Close previous thinking
214
+ this.currentThinking = null;
215
+
216
+ // Add separator line before answer text
217
+ this.currentResponse.addChild(new Spacer(1));
218
+ this.currentResponse.addChild(new HRule());
219
+ this.currentResponse.addChild(new Spacer(1));
220
+ this.hadSeparator = true;
221
+
222
+ // Create a new Markdown block for this text segment
223
+ this.currentMd = new Markdown("", 1, 0, mdTheme);
224
+ this.currentResponse.addChild(this.currentMd);
225
+ this.tui.requestRender();
226
+ }
227
+
228
+ /** Append text to the active Markdown block (creates one if needed) */
229
+ appendAnswer(text: string): void {
230
+ if (!this.currentResponse) return;
231
+
232
+ if (!this.currentMd) {
233
+ // No active text block — create one (with separator if this is the first)
234
+ if (!this.hadSeparator) {
235
+ this.currentResponse.addChild(new Spacer(1));
236
+ this.currentResponse.addChild(new HRule());
237
+ this.currentResponse.addChild(new Spacer(1));
238
+ this.hadSeparator = true;
239
+ }
240
+ this.currentMd = new Markdown("", 1, 0, mdTheme);
241
+ this.currentResponse.addChild(this.currentMd);
242
+ }
243
+
244
+ const prev = (this.currentMd as any).text ?? "";
245
+ this.currentMd.setText(prev + text);
246
+ this.tui.requestRender();
247
+ }
248
+
249
+ showCompletion(): void {
250
+ if (!this.currentResponse) return;
251
+ const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1);
252
+ const source = this.filesReadCount > 0
253
+ ? `${this.filesReadCount} file${this.filesReadCount !== 1 ? "s" : ""} read`
254
+ : "wiki";
255
+ const stats = `\u2500\u2500 ${elapsed}s \u00b7 ${source} `;
256
+
257
+ const completion: Component = {
258
+ invalidate() {},
259
+ render(width: number) {
260
+ const pad = Math.max(0, width - stats.length);
261
+ return [chalk.dim(stats + "\u2500".repeat(pad))];
262
+ },
263
+ };
264
+
265
+ this.currentResponse.addChild(new Spacer(1));
266
+ this.currentResponse.addChild(completion);
267
+ this.currentResponse = null;
268
+ this.currentMd = null;
269
+ this.currentThinking = null;
270
+ this.tui.requestRender();
271
+ }
272
+
273
+ enableInput(): void {
274
+ this.tui.setFocus(this.input);
275
+ this.tui.requestRender();
276
+ }
277
+
278
+ disableInput(): void {
279
+ this.tui.setFocus(null);
280
+ }
281
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { join, dirname } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+
6
+ /**
7
+ * Find the node_modules directory for llm-kb's bundled libraries.
8
+ * Walks up from the current file to locate the nearest node_modules.
9
+ */
10
+ export function getNodeModulesPath(): string {
11
+ let dir = __dirname;
12
+ for (let i = 0; i < 5; i++) {
13
+ const candidate = join(dir, "node_modules");
14
+ try { return candidate; } catch { dir = dirname(dir); }
15
+ }
16
+ return join(process.cwd(), "node_modules");
17
+ }
package/src/watcher.ts CHANGED
@@ -2,15 +2,18 @@ import { watch } from "chokidar";
2
2
  import { extname, join, basename } from "node:path";
3
3
  import { parsePDF } from "./pdf.js";
4
4
  import { buildIndex } from "./indexer.js";
5
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
5
6
  import chalk from "chalk";
6
7
 
7
8
  interface WatcherOptions {
8
9
  folder: string;
9
10
  sourcesDir: string;
10
11
  debounceMs?: number;
12
+ authStorage?: AuthStorage;
13
+ indexModel?: string;
11
14
  }
12
15
 
13
- export function startWatcher({ folder, sourcesDir, debounceMs = 2000 }: WatcherOptions) {
16
+ export function startWatcher({ folder, sourcesDir, debounceMs = 2000, authStorage, indexModel }: WatcherOptions) {
14
17
  let pendingFiles: string[] = [];
15
18
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
16
19
 
@@ -39,7 +42,7 @@ export function startWatcher({ folder, sourcesDir, debounceMs = 2000 }: WatcherO
39
42
  // Re-index
40
43
  process.stdout.write(` Re-indexing...`);
41
44
  try {
42
- await buildIndex(folder, sourcesDir);
45
+ await buildIndex(folder, sourcesDir, undefined, authStorage, indexModel);
43
46
  console.log(chalk.green(` ✓ index.md updated`));
44
47
  } catch (err: any) {
45
48
  console.log(chalk.red(` ✗ ${err.message}`));
@@ -0,0 +1,136 @@
1
+ import { getModels, completeSimple } from "@mariozechner/pi-ai";
2
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
3
+ import { existsSync } from "node:fs";
4
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import type { KBTrace } from "./trace-builder.js";
8
+
9
+ async function resolveApiKey(authStorage?: AuthStorage): Promise<string | undefined> {
10
+ if (authStorage) {
11
+ return authStorage.getApiKey("anthropic");
12
+ }
13
+ const piAuthPath = join(homedir(), ".pi", "agent", "auth.json");
14
+ if (existsSync(piAuthPath)) {
15
+ const storage = AuthStorage.create(piAuthPath);
16
+ return storage.getApiKey("anthropic");
17
+ }
18
+ return process.env.ANTHROPIC_API_KEY;
19
+ }
20
+
21
+ function buildPrompt(
22
+ question: string,
23
+ answer: string,
24
+ sources: string,
25
+ date: string,
26
+ currentWiki: string
27
+ ): string {
28
+ const rules = `Rules for wiki structure:
29
+ - Use ## for CONCEPTS and TOPICS — NOT source file names
30
+ Good: "## Electronic Evidence", "## Mob Lynching", "## Burden of Proof"
31
+ Bad: "## Indian Evidence Act.md", "## indian penal code - new.md"
32
+ - Use ### for subtopics within a concept
33
+ - A concept can draw from MULTIPLE source files — synthesize, don't separate by file
34
+ - If knowledge from this Q&A fits an existing concept, ADD to it — never duplicate
35
+ - If it's a genuinely new concept, create a new ## section
36
+ - Be concise: bullet points for lists, short prose for explanations
37
+ - Include source citations inline: (Source: filename, p.X)
38
+ - Add cross-references where concepts relate: See also: [[Other Concept]]
39
+ - End each ## section with: *Sources: file1, file2 · date*
40
+ - Separate ## sections with: ---`;
41
+
42
+ if (currentWiki.trim()) {
43
+ return `You are maintaining a concept-organized knowledge wiki.
44
+
45
+ ## Current wiki
46
+ ${currentWiki}
47
+
48
+ ## New Q&A to integrate
49
+ **Question:** ${question}
50
+ **Sources used:** ${sources}
51
+ **Date:** ${date}
52
+
53
+ **Answer:**
54
+ ${answer}
55
+
56
+ ---
57
+
58
+ Update the wiki to integrate this new knowledge.
59
+ ${rules}
60
+
61
+ Return ONLY the complete updated wiki markdown. No explanation.`;
62
+ }
63
+
64
+ return `You are creating a concept-organized knowledge wiki.
65
+
66
+ ## First Q&A to add
67
+ **Question:** ${question}
68
+ **Sources used:** ${sources}
69
+ **Date:** ${date}
70
+
71
+ **Answer:**
72
+ ${answer}
73
+
74
+ ---
75
+
76
+ Create a clean wiki from this Q&A.
77
+ - Start with: # Knowledge Wiki\\n\\n> Concept-organized knowledge base. Updated after each query.\\n\\n---
78
+ ${rules}
79
+
80
+ Return ONLY the wiki markdown. No explanation.`;
81
+ }
82
+
83
+ /**
84
+ * Update .llm-kb/wiki/wiki.md using a direct Haiku call.
85
+ * Organizes knowledge by CONCEPT (cross-cutting topics),
86
+ * not by source file.
87
+ */
88
+ export async function updateWiki(
89
+ kbRoot: string,
90
+ trace: KBTrace,
91
+ authStorage?: AuthStorage,
92
+ indexModelId = "claude-haiku-4-5"
93
+ ): Promise<void> {
94
+ if (trace.mode !== "query" || !trace.question || !trace.answer) return;
95
+
96
+ const wikiDir = join(kbRoot, ".llm-kb", "wiki");
97
+ await mkdir(wikiDir, { recursive: true });
98
+ const wikiPath = join(wikiDir, "wiki.md");
99
+
100
+ const currentWiki = existsSync(wikiPath)
101
+ ? await readFile(wikiPath, "utf-8").catch(() => "")
102
+ : "";
103
+
104
+ const sources = trace.filesRead
105
+ .map((f) => f.split(/[\\/]/).pop() ?? f)
106
+ .filter((f) => f.endsWith(".md") && f !== "index.md" && f !== "wiki.md")
107
+ .join(", ") || "unknown";
108
+
109
+ const date = new Date(trace.timestamp).toISOString().slice(0, 10);
110
+ const prompt = buildPrompt(trace.question, trace.answer, sources, date, currentWiki);
111
+
112
+ const apiKey = await resolveApiKey(authStorage);
113
+ if (!apiKey) return;
114
+
115
+ const model = getModels("anthropic").find((m) => m.id === indexModelId);
116
+ if (!model) return;
117
+
118
+ const result = await completeSimple(
119
+ model,
120
+ {
121
+ 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.",
122
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
123
+ },
124
+ { apiKey }
125
+ );
126
+
127
+ const text = result.content
128
+ .filter((b) => b.type === "text")
129
+ .map((b) => (b as any).text)
130
+ .join("")
131
+ .trim();
132
+
133
+ if (text) {
134
+ await writeFile(wikiPath, text + "\n", "utf-8");
135
+ }
136
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+
3
+ // Mock existsSync before importing auth module
4
+ vi.mock("node:fs", async () => {
5
+ const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
6
+ return { ...actual, existsSync: vi.fn() };
7
+ });
8
+
9
+ import { checkAuth } from "../src/auth.js";
10
+ import { existsSync } from "node:fs";
11
+
12
+ const mockExistsSync = vi.mocked(existsSync);
13
+
14
+ describe("checkAuth", () => {
15
+ const origEnv = process.env.ANTHROPIC_API_KEY;
16
+
17
+ afterEach(() => {
18
+ if (origEnv !== undefined) process.env.ANTHROPIC_API_KEY = origEnv;
19
+ else delete process.env.ANTHROPIC_API_KEY;
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ it("returns pi-sdk when auth.json exists", () => {
24
+ mockExistsSync.mockReturnValue(true);
25
+ delete process.env.ANTHROPIC_API_KEY;
26
+
27
+ const result = checkAuth();
28
+ expect(result.ok).toBe(true);
29
+ if (result.ok) {
30
+ expect(result.method).toBe("pi-sdk");
31
+ expect(result.authStorage).toBeUndefined(); // uses default
32
+ }
33
+ });
34
+
35
+ it("returns api-key when ANTHROPIC_API_KEY is set", () => {
36
+ mockExistsSync.mockReturnValue(false);
37
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
38
+
39
+ const result = checkAuth();
40
+ expect(result.ok).toBe(true);
41
+ if (result.ok) {
42
+ expect(result.method).toBe("api-key");
43
+ expect(result.authStorage).toBeDefined();
44
+ }
45
+ });
46
+
47
+ it("returns failure when neither is available", () => {
48
+ mockExistsSync.mockReturnValue(false);
49
+ delete process.env.ANTHROPIC_API_KEY;
50
+
51
+ const result = checkAuth();
52
+ expect(result.ok).toBe(false);
53
+ });
54
+
55
+ it("prefers pi-sdk over api-key when both exist", () => {
56
+ mockExistsSync.mockReturnValue(true);
57
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
58
+
59
+ const result = checkAuth();
60
+ expect(result.ok).toBe(true);
61
+ if (result.ok) {
62
+ expect(result.method).toBe("pi-sdk");
63
+ }
64
+ });
65
+ });