ex-brain 0.1.0 → 0.1.1

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,1447 @@
1
+ import { basename, resolve } from "node:path";
2
+ import { Command } from "commander";
3
+ import { DEFAULT_DB_NAME, inferTypeFromSlug, slugToTitle, normalizeLongSlug, slugify } from "../config";
4
+ import { BrainDb } from "../db/client";
5
+ import {
6
+ collectMarkdownFiles,
7
+ ensureDir,
8
+ fileExists,
9
+ pathToSlug,
10
+ readMaybeStdin,
11
+ readTextFile,
12
+ slugToPath,
13
+ writeTextFile,
14
+ } from "../markdown/io";
15
+ import {
16
+ extractTimelineLines,
17
+ extractWikiStyleLinks,
18
+ parsePageMarkdown,
19
+ renderPageMarkdown,
20
+ } from "../markdown/parser";
21
+ import { BrainRepository } from "../repositories/brain-repo";
22
+ import { loadSettings, SETTINGS_PATH, DEFAULT_DB_PATH, type ResolvedLLM } from "../settings";
23
+ import { extractRelations, entityToSlug, EntityType } from "../ai/entity-link";
24
+ import { registerCompileCommands } from "./compile-cmd";
25
+ import { registerGraphCommand } from "./graph-cmd";
26
+ import { createProgress, formatDuration } from "../utils/progress";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function addDryRun(cmd: Command): Command {
33
+ return cmd.option("--dry-run", "preview changes without executing", false);
34
+ }
35
+
36
+ function isDryRun(opts: Record<string, unknown>): boolean {
37
+ return Boolean(opts.dryRun);
38
+ }
39
+
40
+ // Simple progress output to stderr (won't interfere with --json stdout).
41
+ // e.g. "[3/42] import docs/api"
42
+ function progress(label: string, current: number, total: number, json: boolean): void {
43
+ if (json) return;
44
+ process.stderr.write(`[${current}/${total}] ${label}\n`);
45
+ }
46
+
47
+ /**
48
+ * Extract entities and create entity pages + links.
49
+ * Non-blocking: failures produce warnings, not errors.
50
+ */
51
+ async function applyEntityLinks(
52
+ repo: BrainRepository,
53
+ sourceSlug: string,
54
+ content: string,
55
+ json: boolean,
56
+ ): Promise<{ created: number; linked: number }> {
57
+ if (!content.trim()) return { created: 0, linked: 0 };
58
+
59
+ const settings = await loadSettings();
60
+ if (!settings.llm.baseURL) {
61
+ if (!json) {
62
+ process.stderr.write(`[entity-link] LLM not configured, skipping for ${sourceSlug}\n`);
63
+ }
64
+ return { created: 0, linked: 0 };
65
+ }
66
+
67
+ const progress = createProgress();
68
+ if (!json) {
69
+ progress.start(`Extracting entities from ${sourceSlug}`);
70
+ }
71
+
72
+ const startTime = Date.now();
73
+ const relations = await extractRelations(content, settings.llm);
74
+
75
+ // Filter by confidence
76
+ const highConfidence = relations.filter((r) => r.confidence >= 0.6);
77
+ const ignoredCount = relations.length - highConfidence.length;
78
+
79
+ if (highConfidence.length === 0) {
80
+ if (!json) {
81
+ progress.fail(`No high-confidence entities found`);
82
+ }
83
+ return { created: 0, linked: 0 };
84
+ }
85
+
86
+ let created = 0;
87
+ let linked = 0;
88
+
89
+ for (const r of highConfidence) {
90
+ // 1. Resolve entity slugs (disambiguation)
91
+ const fromCandidate = entityToSlug(r.from.name, r.from.type);
92
+ const toCandidate = entityToSlug(r.to.name, r.to.type);
93
+
94
+ const fromSlug = await repo.findSimilarSlug(fromCandidate, r.from.name);
95
+ const toSlug = await repo.findSimilarSlug(toCandidate, r.to.name);
96
+
97
+ // 2. Ensure entity pages exist
98
+ const c1 = await repo.ensureEntityPage(fromSlug, r.from.type, r.from.name, r.relation, r.context, sourceSlug);
99
+ const c2 = await repo.ensureEntityPage(toSlug, r.to.type, r.to.name, r.relation, r.context, sourceSlug);
100
+ if (c1) created += 1;
101
+ if (c2) created += 1;
102
+
103
+ // 3. Link between entities (context includes relation type)
104
+ await repo.link(fromSlug, toSlug, `[${r.relation}] ${r.context}`);
105
+ linked += 1;
106
+
107
+ // 4. Link from source document to entities (for backlinks tracing)
108
+ await repo.link(sourceSlug, fromSlug, `Mentions ${r.from.name}`);
109
+ linked += 1;
110
+ await repo.link(sourceSlug, toSlug, `Mentions ${r.to.name}`);
111
+ linked += 1;
112
+ }
113
+
114
+ if (!json) {
115
+ const duration = formatDuration(Date.now() - startTime);
116
+ const entityNames = highConfidence.flatMap((r) => [r.from.name, r.to.name]);
117
+ progress.succeed(`${[...new Set(entityNames)].join(", ")} (${created} created, ${linked} links, ${duration})`);
118
+ }
119
+
120
+ return { created, linked };
121
+ }
122
+
123
+ async function resolveInput(
124
+ fileOpt: string | undefined,
125
+ stdin: boolean,
126
+ ): Promise<string> {
127
+ if (fileOpt) return readTextFile(resolve(fileOpt));
128
+ return readMaybeStdin().then((s) => s ?? "");
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Build
133
+ // ---------------------------------------------------------------------------
134
+
135
+ export function buildProgram(): Command {
136
+ const program = new Command("ebrain")
137
+ .description("Personal knowledge base CLI powered by seekdb")
138
+ .addHelpText(
139
+ "after",
140
+ `
141
+ Examples:
142
+ ebrain config
143
+ ebrain put docs/api --file api.md
144
+ ebrain search "machine learning" --limit 5
145
+ ebrain query "What projects did we ship in Q4?"
146
+ cat note.md | ebrain put notes/daily --stdin
147
+ ebrain serve # start MCP server for AI tools
148
+ `,
149
+ )
150
+ .option("--db <path>", "database path (overrides settings.json)")
151
+ .option("--json", "output as JSON", false);
152
+
153
+ // -- config ---------------------------------------------------------------
154
+
155
+ program
156
+ .command("config")
157
+ .description("show resolved configuration")
158
+ .action(async () => {
159
+ const settings = await loadSettings();
160
+ const cliDb = program.opts().db;
161
+ const effectiveDb = cliDb ?? settings.dbPath;
162
+ print(program, {
163
+ settingsFile: SETTINGS_PATH,
164
+ dbPath: effectiveDb,
165
+ mode: settings.remote ? "remote" : "local",
166
+ remote: settings.remote ?? null,
167
+ embed: {
168
+ provider: settings.embed.provider,
169
+ baseURL: settings.embed.baseURL,
170
+ model: settings.embed.model,
171
+ dimensions: settings.embed.dimensions,
172
+ hasApiKey:
173
+ !!settings.embed.apiKey ||
174
+ !!process.env[settings.embed.apiKeyEnv],
175
+ },
176
+ llm: {
177
+ baseURL: settings.llm.baseURL || "(not configured)",
178
+ model: settings.llm.model,
179
+ hasApiKey:
180
+ !!settings.llm.apiKey ||
181
+ !!process.env[settings.llm.apiKeyEnv],
182
+ },
183
+ });
184
+ });
185
+
186
+ // -- page CRUD ------------------------------------------------------------
187
+
188
+ addDryRun(
189
+ program
190
+ .command("put")
191
+ .argument("[slug]", "page slug (optional; auto-generated if omitted)")
192
+ .option("--file <path>", "read markdown from file")
193
+ .option("--stdin", "read markdown from stdin", false)
194
+ .option("--type <type>", "page type")
195
+ .option("--title <title>", "page title")
196
+ .description(
197
+ "create or update a page (idempotent; upserts by slug). If slug is omitted, it is auto-generated from file name, title, or timestamp.",
198
+ )
199
+ .addHelpText(
200
+ "after",
201
+ `
202
+ Examples:
203
+ ebrain put --file api.md # auto-generate slug from file name
204
+ ebrain put docs/api --file api.md # explicit slug
205
+ cat note.md | ebrain put --stdin # auto-generate slug from title/timestamp
206
+ ebrain put --title "My Note" --stdin # auto-generate slug from title
207
+ ebrain put people/john --type person --title "John Doe"
208
+ ebrain put docs/api --file api.md --dry-run
209
+ `,
210
+ ),
211
+ ).action(
212
+ async (
213
+ slug: string | undefined,
214
+ opts: {
215
+ file?: string;
216
+ stdin?: boolean;
217
+ type?: string;
218
+ title?: string;
219
+ dryRun?: boolean;
220
+ },
221
+ ) => {
222
+ const input = await resolveInput(opts.file, opts.stdin ?? false);
223
+ if (!input.trim()) {
224
+ throw new Error(
225
+ "empty input — provide --file <path>, --stdin, or pipe markdown",
226
+ );
227
+ }
228
+ const parsed = parsePageMarkdown(input);
229
+
230
+ // Auto-generate slug if not provided
231
+ let finalSlug = slug;
232
+ if (!finalSlug) {
233
+ // Priority: file name > title option > frontmatter title > timestamp
234
+ if (opts.file) {
235
+ const fileName = basename(opts.file).replace(/\.md$/i, "");
236
+ finalSlug = normalizeLongSlug(slugify(fileName));
237
+ } else if (opts.title) {
238
+ finalSlug = normalizeLongSlug(slugify(opts.title));
239
+ } else if (parsed.frontmatter.title) {
240
+ finalSlug = normalizeLongSlug(slugify(String(parsed.frontmatter.title)));
241
+ } else {
242
+ // Use timestamp as fallback
243
+ const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
244
+ finalSlug = `notes/${timestamp}`;
245
+ }
246
+ }
247
+
248
+ const type =
249
+ opts.type ??
250
+ String(parsed.frontmatter.type ?? inferTypeFromSlug(finalSlug));
251
+ const title =
252
+ opts.title ??
253
+ String(parsed.frontmatter.title ?? slugToTitle(finalSlug));
254
+
255
+ if (isDryRun(opts)) {
256
+ print(program, {
257
+ dryRun: true,
258
+ action: "put",
259
+ slug: finalSlug,
260
+ type,
261
+ title,
262
+ contentLength: parsed.compiledTruth.length,
263
+ hasTimeline: !!parsed.timeline,
264
+ frontmatterKeys: Object.keys(parsed.frontmatter),
265
+ });
266
+ return;
267
+ }
268
+
269
+ await withRepo(program, async (repo) => {
270
+ const page = await repo.putPage({
271
+ slug: finalSlug,
272
+ type,
273
+ title,
274
+ compiledTruth: parsed.compiledTruth,
275
+ timeline: parsed.timeline,
276
+ frontmatter: parsed.frontmatter,
277
+ });
278
+ await applyEntityLinks(
279
+ repo,
280
+ finalSlug,
281
+ parsed.compiledTruth,
282
+ isJson(program),
283
+ );
284
+ print(program, { ok: true, slug: page.slug, updatedAt: page.updatedAt });
285
+ });
286
+ },
287
+ );
288
+
289
+ program
290
+ .command("get")
291
+ .argument("<slug>", "page slug")
292
+ .option("--json", "output as JSON (overrides global --json)")
293
+ .description("read a page and render it as markdown")
294
+ .addHelpText(
295
+ "after",
296
+ `
297
+ Examples:
298
+ ebrain get docs/api
299
+ ebrain get docs/api --json
300
+ `,
301
+ )
302
+ .action(async (slug: string, opts: { json?: boolean }) => {
303
+ const localJson = opts.json !== undefined ? opts.json : isJson(program);
304
+ await withRepo(program, async (repo) => {
305
+ const page = await repo.getPage(slug);
306
+ if (!page) {
307
+ throw new Error(`page not found: ${slug}`);
308
+ }
309
+ if (localJson) {
310
+ console.log(JSON.stringify(page, null, 2));
311
+ return;
312
+ }
313
+ console.log(
314
+ renderPageMarkdown(
315
+ page.frontmatter,
316
+ page.compiledTruth,
317
+ page.timeline,
318
+ ),
319
+ );
320
+ });
321
+ });
322
+
323
+ addDryRun(
324
+ program
325
+ .command("delete")
326
+ .argument("<slug>", "page slug to delete")
327
+ .description("delete a page and its related data (links, tags, timeline, raw)")
328
+ .addHelpText(
329
+ "after",
330
+ `
331
+ Examples:
332
+ ebrain delete notes/old-draft
333
+ ebrain delete notes/old-draft --dry-run
334
+ `,
335
+ ),
336
+ ).action(async (slug: string, opts: { dryRun?: boolean }) => {
337
+ if (isDryRun(opts)) {
338
+ await withRepo(program, async (repo) => {
339
+ const page = await repo.getPage(slug);
340
+ if (!page) {
341
+ throw new Error(`page not found: ${slug}`);
342
+ }
343
+ print(program, {
344
+ dryRun: true,
345
+ action: "delete",
346
+ slug,
347
+ title: page.title,
348
+ });
349
+ });
350
+ return;
351
+ }
352
+ await withRepo(program, async (repo) => {
353
+ await repo.deletePage(slug);
354
+ print(program, { ok: true, action: "delete", slug });
355
+ });
356
+ });
357
+
358
+ program
359
+ .command("list")
360
+ .option("--type <type>", "filter by page type")
361
+ .option("--tag <tag>", "filter by tag")
362
+ .option("-f, --fields <fields>", "comma-separated fields to display (slug,type,title,createdAt,updatedAt)")
363
+ .option("--limit <number>", "max results", "50")
364
+ .description("list pages")
365
+ .addHelpText(
366
+ "after",
367
+ `
368
+ Examples:
369
+ ebrain list
370
+ ebrain list --type person
371
+ ebrain list -f slug
372
+ ebrain list -f slug,title,type
373
+ `,
374
+ )
375
+ .action(async (opts: Record<string, string | undefined>) => {
376
+ await withRepo(program, async (repo) => {
377
+ const rows = await repo.listPages({
378
+ type: opts.type,
379
+ tag: opts.tag,
380
+ limit: Number(opts.limit),
381
+ });
382
+
383
+ // When --fields is set, show one page per line with tab-separated values
384
+ if (opts.fields) {
385
+ const fields = opts.fields.split(",").map((f) => f.trim());
386
+ for (const row of rows) {
387
+ const vals = fields.map((field) => {
388
+ const val = (row as Record<string, unknown>)[field];
389
+ if (val === undefined || val === null) return "";
390
+ if (typeof val === "object") return JSON.stringify(val);
391
+ return String(val);
392
+ });
393
+ console.log(vals.join("\t"));
394
+ }
395
+ return;
396
+ }
397
+
398
+ print(program, rows);
399
+ });
400
+ });
401
+
402
+ // -- search / query -------------------------------------------------------
403
+
404
+ program
405
+ .command("search")
406
+ .argument("<query>", "full-text search query")
407
+ .option("--type <type>", "filter by page type")
408
+ .option("--limit <number>", "max results", "10")
409
+ .description("full-text / hybrid search")
410
+ .addHelpText(
411
+ "after",
412
+ `
413
+ Examples:
414
+ ebrain search "machine learning"
415
+ ebrain search "quarterly revenue" --type deal --limit 5
416
+ `,
417
+ )
418
+ .action(async (query: string, opts: Record<string, string>) => {
419
+ await withRepo(program, async (repo) => {
420
+ const hits = await repo.search(
421
+ query,
422
+ Number(opts.limit ?? 10),
423
+ opts.type,
424
+ );
425
+ print(program, hits);
426
+ });
427
+ });
428
+
429
+ program
430
+ .command("query")
431
+ .argument("<question>", "natural language question")
432
+ .option("--limit <number>", "max results", "10")
433
+ .option("--llm", "use LLM to answer based on retrieved context", false)
434
+ .option("--context-limit <number>", "max pages to use as context", "5")
435
+ .description("semantic / vector search")
436
+ .addHelpText(
437
+ "after",
438
+ `
439
+ Examples:
440
+ ebrain query "What projects did we ship in Q4?"
441
+ ebrain query "Who leads the ML team?" --limit 5
442
+ ebrain query "What are the key findings?" --llm
443
+ `,
444
+ )
445
+ .action(async (question: string, opts: Record<string, string>) => {
446
+ await withRepo(program, async (repo) => {
447
+ const limit = Number(opts.limit ?? 10);
448
+ const hits = await repo.query(question, limit);
449
+
450
+ // If --llm flag, generate answer based on context
451
+ if (opts.llm) {
452
+ const settings = await loadSettings();
453
+ if (!settings.llm.baseURL) {
454
+ print(program, { error: "LLM not configured. Set llm.baseURL in settings." });
455
+ return;
456
+ }
457
+
458
+ const progress = createProgress();
459
+ progress.start("Searching knowledge base...");
460
+
461
+ // Use excerpts from hits as context (avoids extra DB queries that cause segfault)
462
+ const contextLimit = Number(opts.contextLimit ?? 5);
463
+ const topHits = hits.slice(0, contextLimit);
464
+
465
+ // Build context from search results
466
+ const contextPages = topHits.map(hit => ({
467
+ slug: hit.slug,
468
+ title: hit.title,
469
+ excerpt: hit.excerpt || "",
470
+ }));
471
+
472
+ progress.update("Generating answer...");
473
+ const startTime = Date.now();
474
+
475
+ const answer = await generateAnswerFromExcerpts(question, contextPages, settings.llm);
476
+
477
+ const duration = formatDuration(Date.now() - startTime);
478
+ progress.succeed(`Answer generated (${duration})`);
479
+
480
+ // Output markdown
481
+ console.log("\n" + answer);
482
+
483
+ // Show sources
484
+ if (contextPages.length > 0) {
485
+ console.log("\n---\n**Sources:**\n");
486
+ contextPages.forEach((p, i) => {
487
+ console.log(`${i + 1}. [[${p.slug}|${p.title}]]`);
488
+ });
489
+ }
490
+ } else {
491
+ print(program, hits);
492
+ }
493
+ });
494
+ });
495
+
496
+ // -- link -----------------------------------------------------------------
497
+
498
+ addDryRun(
499
+ program
500
+ .command("link")
501
+ .argument("<from>", "source page slug")
502
+ .argument("<to>", "target page slug")
503
+ .option("--context <text>", "link context", "")
504
+ .description("create a cross-link between pages (idempotent)")
505
+ .addHelpText(
506
+ "after",
507
+ `
508
+ Examples:
509
+ ebrain link docs/api docs/getting-started
510
+ ebrain link people/john projects/alpha --context "lead"
511
+ ebrain link docs/api docs/getting-started --dry-run
512
+ `,
513
+ ),
514
+ ).action(
515
+ async (
516
+ from: string,
517
+ to: string,
518
+ opts: { context?: string; dryRun?: boolean },
519
+ ) => {
520
+ if (isDryRun(opts)) {
521
+ print(program, {
522
+ dryRun: true,
523
+ action: "link",
524
+ from,
525
+ to,
526
+ context: opts.context ?? "",
527
+ });
528
+ return;
529
+ }
530
+ await withRepo(program, async (repo) => {
531
+ await repo.link(from, to, opts.context ?? "");
532
+ print(program, { ok: true, from, to });
533
+ });
534
+ },
535
+ );
536
+
537
+ program
538
+ .command("backlinks")
539
+ .argument("<slug>", "target page slug")
540
+ .description("list pages that link to this page")
541
+ .addHelpText(
542
+ "after",
543
+ `
544
+ Examples:
545
+ ebrain backlinks docs/api
546
+ `,
547
+ )
548
+ .action(async (slug: string) => {
549
+ await withRepo(program, async (repo) => {
550
+ const links = await repo.backlinks(slug);
551
+ print(program, links);
552
+ });
553
+ });
554
+
555
+ // -- timeline (subcommands) -----------------------------------------------
556
+
557
+ const timelineCmd = program
558
+ .command("timeline")
559
+ .description("manage timeline entries");
560
+
561
+ timelineCmd
562
+ .command("list")
563
+ .argument("<slug>", "page slug")
564
+ .option("--limit <number>", "max results", "50")
565
+ .description("list timeline entries for a page")
566
+ .addHelpText(
567
+ "after",
568
+ `
569
+ Examples:
570
+ ebrain timeline list projects/alpha
571
+ ebrain timeline list projects/alpha --limit 10
572
+ `,
573
+ )
574
+ .action(async (slug: string, opts: Record<string, string>) => {
575
+ await withRepo(program, async (repo) => {
576
+ const rows = await repo.timeline(slug, Number(opts.limit ?? 50));
577
+ print(program, rows);
578
+ });
579
+ });
580
+
581
+ addDryRun(
582
+ timelineCmd
583
+ .command("add")
584
+ .argument("<slug>", "page slug")
585
+ .requiredOption("--date <date>", "date (YYYY-MM-DD or ISO)")
586
+ .requiredOption("--summary <summary>", "one-line summary")
587
+ .option("--source <source>", "event source", "manual")
588
+ .option("--detail <detail>", "detail markdown", "")
589
+ .description("add a timeline entry")
590
+ .addHelpText(
591
+ "after",
592
+ `
593
+ Examples:
594
+ ebrain timeline add projects/alpha --date 2025-03-15 --summary "v1.0 shipped"
595
+ ebrain timeline add projects/alpha --date 2025-03-15 --summary "launch" --source release
596
+ ebrain timeline add projects/alpha --date 2025-03-15 --summary "launch" --dry-run
597
+ `,
598
+ ),
599
+ ).action(
600
+ async (
601
+ slug: string,
602
+ opts: {
603
+ date: string;
604
+ summary: string;
605
+ source?: string;
606
+ detail?: string;
607
+ dryRun?: boolean;
608
+ },
609
+ ) => {
610
+ if (isDryRun(opts)) {
611
+ print(program, {
612
+ dryRun: true,
613
+ action: "timeline-add",
614
+ slug,
615
+ date: opts.date,
616
+ summary: opts.summary,
617
+ source: opts.source ?? "manual",
618
+ });
619
+ return;
620
+ }
621
+ await withRepo(program, async (repo) => {
622
+ await repo.timelineAdd({
623
+ pageSlug: slug,
624
+ date: opts.date,
625
+ source: opts.source ?? "manual",
626
+ summary: opts.summary,
627
+ detail: opts.detail ?? "",
628
+ });
629
+ print(program, {
630
+ ok: true,
631
+ action: "timeline-add",
632
+ slug,
633
+ date: opts.date,
634
+ });
635
+ });
636
+ },
637
+ );
638
+
639
+ addDryRun(
640
+ timelineCmd
641
+ .command("extract")
642
+ .argument("<slug>", "page slug")
643
+ .option("--source <source>", "source identifier", "extracted")
644
+ .option("--default-date <date>", "default date (YYYY-MM-DD)")
645
+ .description("extract timeline events from page content using AI")
646
+ .addHelpText(
647
+ "after",
648
+ `
649
+ Examples:
650
+ ebrain timeline extract companies/river-ai
651
+ ebrain timeline extract docs/meeting --source meeting_notes --default-date 2024-03-15
652
+ `,
653
+ ),
654
+ ).action(async (slug: string, opts: { source?: string; defaultDate?: string; dryRun?: boolean }) => {
655
+ if (isDryRun(opts)) {
656
+ print(program, {
657
+ dryRun: true,
658
+ action: "timeline-extract",
659
+ slug,
660
+ source: opts.source ?? "extracted",
661
+ defaultDate: opts.defaultDate ?? new Date().toISOString().slice(0, 10),
662
+ });
663
+ return;
664
+ }
665
+ await withRepo(program, async (repo) => {
666
+ const page = await repo.getPage(slug);
667
+ if (!page) {
668
+ throw new Error(`page not found: ${slug}`);
669
+ }
670
+ const settings = await loadSettings();
671
+
672
+ const progress = createProgress();
673
+ progress.start(`Extracting timeline from ${slug}...`);
674
+ const startTime = Date.now();
675
+
676
+ const result = await repo.extractAndAddTimeline(
677
+ slug,
678
+ page.compiledTruth,
679
+ opts.source ?? "extracted",
680
+ opts.defaultDate ?? new Date().toISOString().slice(0, 10),
681
+ settings.llm,
682
+ );
683
+
684
+ const duration = formatDuration(Date.now() - startTime);
685
+
686
+ if (result.entries.length > 0) {
687
+ progress.succeed(`${result.entries.length} events extracted (${duration})`);
688
+ } else {
689
+ progress.stop();
690
+ process.stderr.write(`No events found (${duration})\n`);
691
+ }
692
+
693
+ print(program, {
694
+ ok: true,
695
+ action: "timeline-extract",
696
+ slug,
697
+ entriesAdded: result.entries.length,
698
+ entries: result.entries,
699
+ confidence: result.confidence,
700
+ });
701
+ });
702
+ });
703
+
704
+ timelineCmd
705
+ .command("global")
706
+ .option("--limit <number>", "max results", "100")
707
+ .description("list timeline entries across all pages")
708
+ .addHelpText(
709
+ "after",
710
+ `
711
+ Examples:
712
+ ebrain timeline global
713
+ ebrain timeline global --limit 20
714
+ `,
715
+ )
716
+ .action(async (opts: Record<string, string>) => {
717
+ await withRepo(program, async (repo) => {
718
+ const entries = await repo.timelineGlobal(Number(opts.limit ?? 100));
719
+ print(program, entries);
720
+ });
721
+ });
722
+
723
+ // -- tag (subcommands) ----------------------------------------------------
724
+
725
+ const tagCmd = program
726
+ .command("tag")
727
+ .description("manage tags on a page");
728
+
729
+ tagCmd
730
+ .command("list")
731
+ .argument("<slug>", "page slug")
732
+ .description("list tags on a page")
733
+ .addHelpText(
734
+ "after",
735
+ `
736
+ Examples:
737
+ ebrain tag list docs/api
738
+ `,
739
+ )
740
+ .action(async (slug: string) => {
741
+ await withRepo(program, async (repo) => {
742
+ const tags = await repo.tags(slug);
743
+ print(program, tags);
744
+ });
745
+ });
746
+
747
+ addDryRun(
748
+ tagCmd
749
+ .command("add")
750
+ .argument("<slug>", "page slug")
751
+ .argument("<tag>", "tag to add")
752
+ .description("add a tag to a page (idempotent)")
753
+ .addHelpText(
754
+ "after",
755
+ `
756
+ Examples:
757
+ ebrain tag add docs/api rest
758
+ ebrain tag add docs/api rest --dry-run
759
+ `,
760
+ ),
761
+ ).action(async (slug: string, tag: string, opts: { dryRun?: boolean }) => {
762
+ if (isDryRun(opts)) {
763
+ print(program, { dryRun: true, action: "tag-add", slug, tag });
764
+ return;
765
+ }
766
+ await withRepo(program, async (repo) => {
767
+ await repo.tag(slug, tag);
768
+ print(program, { ok: true, action: "tag-add", slug, tag });
769
+ });
770
+ });
771
+
772
+ addDryRun(
773
+ tagCmd
774
+ .command("remove")
775
+ .argument("<slug>", "page slug")
776
+ .argument("<tag>", "tag to remove")
777
+ .description("remove a tag from a page")
778
+ .addHelpText(
779
+ "after",
780
+ `
781
+ Examples:
782
+ ebrain tag remove docs/api outdated
783
+ ebrain tag remove docs/api outdated --dry-run
784
+ `,
785
+ ),
786
+ ).action(async (slug: string, tag: string, opts: { dryRun?: boolean }) => {
787
+ if (isDryRun(opts)) {
788
+ print(program, { dryRun: true, action: "tag-remove", slug, tag });
789
+ return;
790
+ }
791
+ await withRepo(program, async (repo) => {
792
+ await repo.untag(slug, tag);
793
+ print(program, { ok: true, action: "tag-remove", slug, tag });
794
+ });
795
+ });
796
+
797
+ // -- raw (subcommands) ----------------------------------------------------
798
+
799
+ const rawCmd = program
800
+ .command("raw")
801
+ .description("manage raw source data for a page");
802
+
803
+ rawCmd
804
+ .command("get")
805
+ .argument("<slug>", "page slug")
806
+ .option("--source <source>", "filter by source name")
807
+ .description("read raw source data for a page")
808
+ .addHelpText(
809
+ "after",
810
+ `
811
+ Examples:
812
+ ebrain raw get ingest/report
813
+ ebrain raw get ingest/report --source crm
814
+ `,
815
+ )
816
+ .action(async (slug: string, opts: { source?: string }) => {
817
+ await withRepo(program, async (repo) => {
818
+ const rows = await repo.readRaw(slug, opts.source);
819
+ print(program, rows);
820
+ });
821
+ });
822
+
823
+ addDryRun(
824
+ rawCmd
825
+ .command("set")
826
+ .argument("<slug>", "page slug")
827
+ .requiredOption("--source <source>", "source name")
828
+ .option("--data <json>", "JSON string")
829
+ .option("--stdin", "read JSON from stdin", false)
830
+ .description("write raw source data for a page")
831
+ .addHelpText(
832
+ "after",
833
+ `
834
+ Examples:
835
+ ebrain raw set ingest/report --source crm --data '{"rev": 1000}'
836
+ echo '{"rev": 1000}' | ebrain raw set ingest/report --source crm --stdin
837
+ ebrain raw set ingest/report --source crm --data '{"rev": 1000}' --dry-run
838
+ `,
839
+ ),
840
+ ).action(
841
+ async (
842
+ slug: string,
843
+ opts: {
844
+ source: string;
845
+ data?: string;
846
+ stdin?: boolean;
847
+ dryRun?: boolean;
848
+ },
849
+ ) => {
850
+ let data: unknown;
851
+ if (opts.data) {
852
+ data = JSON.parse(opts.data);
853
+ } else if (opts.stdin) {
854
+ const raw = await readMaybeStdin();
855
+ if (!raw?.trim()) throw new Error("empty stdin — pipe JSON");
856
+ data = JSON.parse(raw);
857
+ } else {
858
+ throw new Error("provide --data <json> or --stdin");
859
+ }
860
+
861
+ if (isDryRun(opts)) {
862
+ print(program, {
863
+ dryRun: true,
864
+ action: "raw-set",
865
+ slug,
866
+ source: opts.source,
867
+ });
868
+ return;
869
+ }
870
+
871
+ await withRepo(program, async (repo) => {
872
+ await repo.writeRaw(slug, opts.source, data);
873
+ print(program, {
874
+ ok: true,
875
+ action: "raw-set",
876
+ slug,
877
+ source: opts.source,
878
+ });
879
+ });
880
+ },
881
+ );
882
+
883
+ // -- import / export ------------------------------------------------------
884
+
885
+ addDryRun(
886
+ program
887
+ .command("import")
888
+ .argument("<dir>", "directory of markdown files")
889
+ .description("import a directory of markdown files")
890
+ .addHelpText(
891
+ "after",
892
+ `
893
+ Examples:
894
+ ebrain import ./docs
895
+ ebrain import ./docs --dry-run
896
+ `,
897
+ ),
898
+ ).action(async (dir: string, opts: { dryRun?: boolean }) => {
899
+ await withRepo(program, async (repo) => {
900
+ const root = resolve(dir);
901
+ const files = await collectMarkdownFiles(root);
902
+ if (isDryRun(opts)) {
903
+ print(program, {
904
+ dryRun: true,
905
+ action: "import",
906
+ dir: root,
907
+ filesFound: files.length,
908
+ slugs: files.map((f) => pathToSlug(f, root)),
909
+ });
910
+ return;
911
+ }
912
+
913
+ const jsonOut = isJson(program);
914
+ const settings = await loadSettings();
915
+ const progress = createProgress();
916
+ const startTime = Date.now();
917
+
918
+ // Phase 1: Parse all files and collect data
919
+ progress.start(`Scanning ${files.length} files...`);
920
+ const fileData: Array<{
921
+ file: string;
922
+ slug: string;
923
+ parsed: ReturnType<typeof parsePageMarkdown>;
924
+ content: string;
925
+ wikiLinks: string[];
926
+ timelineEntries: ReturnType<typeof extractTimelineLines>;
927
+ tags: string[];
928
+ }> = [];
929
+
930
+ for (const file of files) {
931
+ const rawSlug = pathToSlug(file, root);
932
+ const slug = normalizeLongSlug(rawSlug);
933
+ const content = await readTextFile(file);
934
+ const parsed = parsePageMarkdown(content);
935
+ const wikiLinks = extractWikiStyleLinks(content).map(normalizeLinkSlug);
936
+ const timelineEntries = extractTimelineLines(parsed.timeline);
937
+ const tags = Array.isArray(parsed.frontmatter.tags)
938
+ ? parsed.frontmatter.tags.filter((t): t is string => typeof t === "string")
939
+ : [];
940
+ fileData.push({ file, slug, parsed, content, wikiLinks, timelineEntries, tags });
941
+ }
942
+
943
+ // Phase 2: Write all pages first
944
+ progress.update(`Writing ${fileData.length} pages...`);
945
+ for (let i = 0; i < fileData.length; i++) {
946
+ const { slug, parsed } = fileData[i]!;
947
+ if (!jsonOut && i % 10 === 0) {
948
+ progress.update(`Writing pages... ${i + 1}/${fileData.length}`);
949
+ }
950
+ await repo.putPage({
951
+ slug,
952
+ type: String(parsed.frontmatter.type ?? inferTypeFromSlug(slug)),
953
+ title: String(parsed.frontmatter.title ?? slugToTitle(slug)),
954
+ compiledTruth: parsed.compiledTruth,
955
+ timeline: parsed.timeline,
956
+ frontmatter: parsed.frontmatter,
957
+ });
958
+ }
959
+
960
+ // Phase 3: Parallel entity extraction (main optimization)
961
+ progress.update("Extracting entities...");
962
+ const BATCH_SIZE = 10;
963
+ const entityResults = new Map<string, Awaited<ReturnType<typeof extractRelations>>>();
964
+
965
+ if (settings.llm.baseURL) {
966
+ for (let i = 0; i < fileData.length; i += BATCH_SIZE) {
967
+ const batch = fileData.slice(i, i + BATCH_SIZE).filter(d => d.tags.length === 0);
968
+ if (!jsonOut) {
969
+ progress.update(`Extracting entities... ${Math.min(i + BATCH_SIZE, fileData.length)}/${fileData.length}`);
970
+ }
971
+ const batchPromises = batch.map(async ({ slug, content }) => {
972
+ const relations = await extractRelations(content, settings.llm);
973
+ return { slug, relations };
974
+ });
975
+ const results = await Promise.all(batchPromises);
976
+ for (const { slug, relations } of results) {
977
+ entityResults.set(slug, relations);
978
+ }
979
+ }
980
+ }
981
+
982
+ // Phase 4: Write links, tags, timeline, and entity pages
983
+ progress.update("Creating links and timeline...");
984
+ let linkCount = 0;
985
+ let timelineCount = 0;
986
+ let entityCount = 0;
987
+
988
+ for (const { slug, wikiLinks, timelineEntries, tags, content } of fileData) {
989
+ // Wiki links
990
+ for (const link of wikiLinks) {
991
+ await repo.link(slug, link, "import");
992
+ linkCount++;
993
+ }
994
+
995
+ // Timeline entries
996
+ for (const entry of timelineEntries) {
997
+ await repo.timelineAdd({
998
+ pageSlug: slug,
999
+ date: entry.date,
1000
+ source: entry.source,
1001
+ summary: entry.summary,
1002
+ detail: "",
1003
+ });
1004
+ timelineCount++;
1005
+ }
1006
+
1007
+ // Tags
1008
+ for (const tag of tags) {
1009
+ await repo.tag(slug, tag);
1010
+ }
1011
+
1012
+ // Entity links from parallel extraction
1013
+ const relations = entityResults.get(slug);
1014
+ if (relations && relations.length > 0) {
1015
+ const highConfidence = relations.filter(r => r.confidence >= 0.6);
1016
+ for (const r of highConfidence) {
1017
+ const fromCandidate = entityToSlug(r.from.name, r.from.type);
1018
+ const toCandidate = entityToSlug(r.to.name, r.to.type);
1019
+ const fromSlug = await repo.findSimilarSlug(fromCandidate, r.from.name);
1020
+ const toSlug = await repo.findSimilarSlug(toCandidate, r.to.name);
1021
+
1022
+ const c1 = await repo.ensureEntityPage(fromSlug, r.from.type, r.from.name, r.relation, r.context, slug);
1023
+ const c2 = await repo.ensureEntityPage(toSlug, r.to.type, r.to.name, r.relation, r.context, slug);
1024
+ if (c1) entityCount++;
1025
+ if (c2) entityCount++;
1026
+
1027
+ await repo.link(fromSlug, toSlug, `[${r.relation}] ${r.context}`);
1028
+ await repo.link(slug, fromSlug, `Mentions ${r.from.name}`);
1029
+ await repo.link(slug, toSlug, `Mentions ${r.to.name}`);
1030
+ linkCount += 3;
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ const duration = formatDuration(Date.now() - startTime);
1036
+ progress.succeed(`${files.length} files imported, ${entityCount} entities, ${linkCount} links (${duration})`);
1037
+
1038
+ print(program, {
1039
+ importedFiles: files.length,
1040
+ pages: fileData.length,
1041
+ links: linkCount,
1042
+ timelineEntries: timelineCount,
1043
+ entities: entityCount,
1044
+ });
1045
+ });
1046
+ });
1047
+
1048
+ program
1049
+ .command("export")
1050
+ .option("--dir <dir>", "output directory", resolve(process.cwd(), "export"))
1051
+ .description("export all pages as markdown files")
1052
+ .addHelpText(
1053
+ "after",
1054
+ `
1055
+ Examples:
1056
+ ebrain export
1057
+ ebrain export --dir ./backup
1058
+ `,
1059
+ )
1060
+ .action(async (opts: { dir: string }) => {
1061
+ await withRepo(program, async (repo) => {
1062
+ const dir = resolve(opts.dir);
1063
+ await ensureDir(dir);
1064
+ const pages = await repo.listPages({ limit: 100000 });
1065
+ const jsonOut = isJson(program);
1066
+ for (let i = 0; i < pages.length; i += 1) {
1067
+ const page = pages[i]!;
1068
+ progress("export " + page.slug, i + 1, pages.length, jsonOut);
1069
+ const tags = await repo.tags(page.slug);
1070
+ const fm = {
1071
+ ...page.frontmatter,
1072
+ type: page.type,
1073
+ title: page.title,
1074
+ };
1075
+ if (tags.length > 0)
1076
+ (fm as Record<string, unknown>).tags = tags;
1077
+ const md = renderPageMarkdown(fm, page.compiledTruth, page.timeline);
1078
+ await writeTextFile(slugToPath(page.slug, dir), md);
1079
+ }
1080
+ print(program, { exported: pages.length, dir });
1081
+ });
1082
+ });
1083
+
1084
+ // -- ingest ---------------------------------------------------------------
1085
+
1086
+ addDryRun(
1087
+ program
1088
+ .command("ingest")
1089
+ .argument("[file]", "file path to ingest (omit for stdin)")
1090
+ .option("--type <type>", "source type", "doc")
1091
+ .option("--stdin", "read from stdin", false)
1092
+ .description("ingest a file as a new page (under ingest/<name>)")
1093
+ .addHelpText(
1094
+ "after",
1095
+ `
1096
+ Examples:
1097
+ ebrain ingest report.pdf --type pdf
1098
+ cat article.md | ebrain ingest --stdin --type article
1099
+ ebrain ingest report.pdf --type pdf --dry-run
1100
+ `,
1101
+ ),
1102
+ ).action(
1103
+ async (
1104
+ file: string | undefined,
1105
+ opts: { type?: string; stdin?: boolean; dryRun?: boolean },
1106
+ ) => {
1107
+ let content: string;
1108
+ let fileName: string;
1109
+
1110
+ if (file) {
1111
+ const fullPath = resolve(file);
1112
+ if (!(await fileExists(fullPath))) {
1113
+ throw new Error(`file not found: ${file}`);
1114
+ }
1115
+ content = await readTextFile(fullPath);
1116
+ fileName = basename(fullPath);
1117
+ } else if (opts.stdin) {
1118
+ const raw = await readMaybeStdin();
1119
+ if (!raw?.trim()) throw new Error("empty stdin — pipe content");
1120
+ content = raw;
1121
+ fileName = "stdin";
1122
+ } else {
1123
+ throw new Error("provide <file> or --stdin");
1124
+ }
1125
+
1126
+ const slug = `ingest/${fileName.replace(/\.[^.]+$/, "")}`;
1127
+ const type = opts.type ?? "doc";
1128
+
1129
+ if (isDryRun(opts)) {
1130
+ print(program, {
1131
+ dryRun: true,
1132
+ action: "ingest",
1133
+ slug,
1134
+ type,
1135
+ contentLength: content.length,
1136
+ });
1137
+ return;
1138
+ }
1139
+
1140
+ await withRepo(program, async (repo) => {
1141
+ await repo.putPage({
1142
+ slug,
1143
+ type,
1144
+ title: slugToTitle(slug),
1145
+ compiledTruth: content,
1146
+ timeline: "",
1147
+ frontmatter: {
1148
+ sourceFile: resolve(fileName),
1149
+ sourceType: type,
1150
+ },
1151
+ });
1152
+ await repo.timelineAdd({
1153
+ pageSlug: slug,
1154
+ date: new Date().toISOString().slice(0, 10),
1155
+ source: type,
1156
+ summary: `Ingested file ${fileName}`,
1157
+ detail: "",
1158
+ });
1159
+ await applyEntityLinks(
1160
+ repo,
1161
+ slug,
1162
+ content,
1163
+ isJson(program),
1164
+ );
1165
+ print(program, { ok: true, action: "ingest", slug });
1166
+ });
1167
+ },
1168
+ );
1169
+
1170
+ // -- embed ----------------------------------------------------------------
1171
+
1172
+ addDryRun(
1173
+ program
1174
+ .command("embed")
1175
+ .argument("[slug]", "page slug (omit with --all)")
1176
+ .option("--all", "embed all pages")
1177
+ .description("refresh page embedding(s)")
1178
+ .addHelpText(
1179
+ "after",
1180
+ `
1181
+ Examples:
1182
+ ebrain embed docs/api
1183
+ ebrain embed --all
1184
+ ebrain embed --all --dry-run
1185
+ `,
1186
+ ),
1187
+ ).action(
1188
+ async (
1189
+ slug: string | undefined,
1190
+ opts: { all?: boolean; dryRun?: boolean },
1191
+ ) => {
1192
+ if (opts.all) {
1193
+ if (isDryRun(opts)) {
1194
+ await withRepo(program, async (repo) => {
1195
+ const pages = await repo.listPages({ limit: 100000 });
1196
+ print(program, {
1197
+ dryRun: true,
1198
+ action: "embed",
1199
+ mode: "all",
1200
+ pagesFound: pages.length,
1201
+ });
1202
+ });
1203
+ return;
1204
+ }
1205
+ await withRepo(program, async (repo) => {
1206
+ const jsonOut = isJson(program);
1207
+ const pages = await repo.listPages({ limit: 100000 });
1208
+ let count = 0;
1209
+ for (const page of pages) {
1210
+ count += 1;
1211
+ progress("embed " + page.slug, count, pages.length, jsonOut);
1212
+ await repo.syncPageToSearch(page.slug);
1213
+ }
1214
+ print(program, { embedded: count, mode: "all" });
1215
+ });
1216
+ return;
1217
+ }
1218
+ if (!slug) {
1219
+ throw new Error("provide <slug> or --all");
1220
+ }
1221
+ if (isDryRun(opts)) {
1222
+ print(program, { dryRun: true, action: "embed", slug });
1223
+ return;
1224
+ }
1225
+ await withRepo(program, async (repo) => {
1226
+ await repo.syncPageToSearch(slug);
1227
+ print(program, { embedded: 1, slug });
1228
+ });
1229
+ },
1230
+ );
1231
+
1232
+ // -- init / stats ---------------------------------------------------------
1233
+
1234
+ program
1235
+ .command("init")
1236
+ .description("initialize the ebrain database")
1237
+ .addHelpText(
1238
+ "after",
1239
+ `
1240
+ Examples:
1241
+ ebrain init
1242
+ `,
1243
+ )
1244
+ .action(async () => {
1245
+ await withRepo(program, async () => {
1246
+ print(program, {
1247
+ ok: true,
1248
+ dbPath:
1249
+ program.opts().db ?? (await loadSettings()).dbPath,
1250
+ });
1251
+ });
1252
+ });
1253
+
1254
+ program
1255
+ .command("stats")
1256
+ .description("show knowledge base statistics")
1257
+ .addHelpText(
1258
+ "after",
1259
+ `
1260
+ Examples:
1261
+ ebrain stats
1262
+ ebrain stats --json
1263
+ `,
1264
+ )
1265
+ .action(async () => {
1266
+ await withRepo(program, async (repo) => {
1267
+ print(program, await repo.stats());
1268
+ });
1269
+ });
1270
+
1271
+ // Register compile and smart-ingest commands
1272
+ registerCompileCommands(program);
1273
+
1274
+ // Register graph command
1275
+ registerGraphCommand(program);
1276
+
1277
+ // -- serve / tools-json ---------------------------------------------------
1278
+
1279
+ program
1280
+ .command("serve")
1281
+ .description("start MCP server over stdio (for AI tool integration)")
1282
+ .addHelpText(
1283
+ "after",
1284
+ `
1285
+ Examples:
1286
+ ebrain serve
1287
+ `,
1288
+ )
1289
+ .action(async () => {
1290
+ const { startMcpServer } = await import("../mcp/server");
1291
+ const dbPath = String(program.opts().db);
1292
+ await startMcpServer(dbPath);
1293
+ });
1294
+
1295
+ program
1296
+ .command("tools-json")
1297
+ .description("print MCP tools discovery JSON")
1298
+ .action(() => {
1299
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1300
+ const { TOOL_MANIFEST } = require("../mcp/server");
1301
+ console.log(JSON.stringify({ tools: TOOL_MANIFEST }, null, 2));
1302
+ });
1303
+
1304
+ // -- legacy aliases (backward compat, hidden) -----------------------------
1305
+
1306
+
1307
+
1308
+
1309
+
1310
+ return program;
1311
+ }
1312
+
1313
+ // ---------------------------------------------------------------------------
1314
+ // Repo / output helpers
1315
+ // ---------------------------------------------------------------------------
1316
+
1317
+ async function withRepo(
1318
+ program: Command,
1319
+ callback: (repo: BrainRepository) => Promise<void>,
1320
+ ): Promise<void> {
1321
+ const settings = await loadSettings();
1322
+ const cliDb = program.opts().db;
1323
+ const dbPath = cliDb ?? settings.dbPath;
1324
+ const db = await BrainDb.connect(dbPath, settings);
1325
+ const repo = new BrainRepository(db);
1326
+ await callback(repo);
1327
+ // CLI 短生命周期应用:强制退出绕过 seekdb native 模块的 cleanup bug
1328
+ process.exit(0);
1329
+ }
1330
+
1331
+ function print(program: Command, payload: unknown): void {
1332
+ if (isJson(program)) {
1333
+ console.log(JSON.stringify(payload, null, 2));
1334
+ return;
1335
+ }
1336
+ if (typeof payload === "string") {
1337
+ console.log(payload);
1338
+ return;
1339
+ }
1340
+ console.log(formatHuman(payload));
1341
+ }
1342
+
1343
+ function isJson(program: Command): boolean {
1344
+ return Boolean(program.opts().json);
1345
+ }
1346
+
1347
+ function formatHuman(payload: unknown): string {
1348
+ if (Array.isArray(payload)) {
1349
+ return payload
1350
+ .map((item) =>
1351
+ typeof item === "string"
1352
+ ? `- ${item}`
1353
+ : `- ${JSON.stringify(item)}`,
1354
+ )
1355
+ .join("\n");
1356
+ }
1357
+ return JSON.stringify(payload, null, 2);
1358
+ }
1359
+
1360
+ function normalizeLinkSlug(path: string): string {
1361
+ return path
1362
+ .replaceAll("\\", "/")
1363
+ .replace(/^\.\//, "")
1364
+ .replace(/^\.\.\//g, "")
1365
+ .replace(/\.md$/, "");
1366
+ }
1367
+
1368
+ // ---------------------------------------------------------------------------
1369
+ // LLM Answer Generation
1370
+ // ---------------------------------------------------------------------------
1371
+
1372
+ interface ContextPage {
1373
+ slug: string;
1374
+ title: string;
1375
+ excerpt: string;
1376
+ }
1377
+
1378
+ async function generateAnswerFromExcerpts(
1379
+ question: string,
1380
+ pages: ContextPage[],
1381
+ llm: ResolvedLLM,
1382
+ ): Promise<string> {
1383
+ const apiKey = llm.apiKey || process.env[llm.apiKeyEnv] || "";
1384
+ if (!apiKey) {
1385
+ return "Error: LLM API key not configured.";
1386
+ }
1387
+
1388
+ // Build context from page excerpts
1389
+ const context = pages
1390
+ .map((p, i) => {
1391
+ return `## Source ${i + 1}: ${p.title}\n**Slug:** ${p.slug}\n\n${p.excerpt}`;
1392
+ })
1393
+ .join("\n\n---\n\n");
1394
+
1395
+ const prompt = `You are answering a question based on the provided knowledge base context.
1396
+
1397
+ ## Question
1398
+ ${question}
1399
+
1400
+ ## Context from Knowledge Base
1401
+ ${context || "(No relevant pages found)"}
1402
+
1403
+ ## Instructions
1404
+ - Answer the question based ONLY on the provided context
1405
+ - If the context doesn't contain enough information, say so
1406
+ - Cite sources using markdown links like [Title](slug) when referencing specific information
1407
+ - Format your answer in clean markdown
1408
+ - Be concise but comprehensive
1409
+
1410
+ ## Answer`;
1411
+
1412
+ try {
1413
+ const resp = await fetch(
1414
+ llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions",
1415
+ {
1416
+ method: "POST",
1417
+ headers: {
1418
+ "Content-Type": "application/json",
1419
+ Authorization: `Bearer ${apiKey}`,
1420
+ },
1421
+ body: JSON.stringify({
1422
+ model: llm.model,
1423
+ messages: [
1424
+ {
1425
+ role: "system",
1426
+ content: "You are a helpful assistant that answers questions based on a knowledge base. Always cite your sources.",
1427
+ },
1428
+ { role: "user", content: prompt },
1429
+ ],
1430
+ temperature: 0.3,
1431
+ max_tokens: 2048,
1432
+ }),
1433
+ },
1434
+ );
1435
+
1436
+ if (!resp.ok) {
1437
+ const text = await resp.text();
1438
+ return `Error: LLM API failed (${resp.status}): ${text.slice(0, 200)}`;
1439
+ }
1440
+
1441
+ const data = await resp.json();
1442
+ return data.choices?.[0]?.message?.content || "(No answer generated)";
1443
+ } catch (error) {
1444
+ const msg = error instanceof Error ? error.message : String(error);
1445
+ return `Error: ${msg}`;
1446
+ }
1447
+ }