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/PHASE3_SPEC.md +245 -0
- package/PHASE4_SPEC.md +358 -0
- package/README.md +182 -61
- package/bin/cli.js +5473 -165
- package/package.json +11 -6
- package/plan.md +248 -3
- package/src/auth.ts +55 -0
- package/src/cli.ts +163 -38
- package/src/config.ts +61 -0
- package/src/eval.ts +548 -0
- package/src/indexer.ts +36 -32
- package/src/md-stream.ts +133 -0
- package/src/query.ts +408 -132
- package/src/session-store.ts +22 -0
- package/src/session-watcher.ts +89 -0
- package/src/trace-builder.ts +168 -0
- package/src/tui-display.ts +281 -0
- package/src/utils.ts +17 -0
- package/src/watcher.ts +5 -2
- package/src/wiki-updater.ts +136 -0
- package/test/auth.test.ts +65 -0
- package/test/config.test.ts +96 -0
- package/test/md-stream.test.ts +98 -0
- package/test/resolve-kb.test.ts +33 -0
- package/test/scan.test.ts +65 -0
- package/test/trace-builder.test.ts +215 -0
- package/vitest.config.ts +8 -0
- package/bin/chunk-MYQ36JJB.js +0 -118
- package/bin/indexer-LSYSZXZX.js +0 -6
|
@@ -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
|
+
});
|