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.
- package/README.md +39 -37
- package/package.json +5 -5
- package/src/ai/compiler.ts +529 -0
- package/src/ai/embed-factory.ts +116 -0
- package/src/ai/entity-link.ts +226 -0
- package/src/ai/hash-embed.ts +30 -0
- package/src/ai/timeline-extractor.ts +436 -0
- package/src/cli.ts +16 -0
- package/src/commands/compile-cmd.ts +208 -0
- package/src/commands/graph-cmd.ts +1070 -0
- package/src/commands/index.ts +1447 -0
- package/src/config.ts +80 -0
- package/src/db/client.ts +101 -0
- package/src/db/schema.ts +49 -0
- package/src/markdown/io.ts +61 -0
- package/src/markdown/parser.ts +72 -0
- package/src/mcp/server.ts +540 -0
- package/src/repositories/brain-repo.ts +772 -0
- package/src/settings.ts +214 -0
- package/src/types/index.ts +55 -0
- package/src/utils/progress.ts +171 -0
- package/dist/cli.js +0 -93543
|
@@ -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
|
+
}
|