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,436 @@
|
|
|
1
|
+
import type { ResolvedLLM } from "../settings";
|
|
2
|
+
import type { TimelineEntry } from "../types";
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface TimelineExtractionInput {
|
|
9
|
+
/** Content to extract timeline from */
|
|
10
|
+
content: string;
|
|
11
|
+
/** Source identifier */
|
|
12
|
+
source: string;
|
|
13
|
+
/** Default date if no date found */
|
|
14
|
+
defaultDate: string;
|
|
15
|
+
/** Page slug for timeline entries */
|
|
16
|
+
pageSlug: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TimelineExtractionResult {
|
|
20
|
+
/** Extracted timeline entries */
|
|
21
|
+
entries: TimelineEntry[];
|
|
22
|
+
/** Whether extraction succeeded */
|
|
23
|
+
success: boolean;
|
|
24
|
+
/** Confidence of extraction */
|
|
25
|
+
confidence: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EventExtraction {
|
|
29
|
+
/** Event date (ISO or YYYY-MM-DD) */
|
|
30
|
+
date: string;
|
|
31
|
+
/** Event summary */
|
|
32
|
+
summary: string;
|
|
33
|
+
/** Event detail (optional) */
|
|
34
|
+
detail?: string;
|
|
35
|
+
/** Event type classification */
|
|
36
|
+
eventType: "milestone" | "update" | "meeting" | "announcement" | "transaction" | "other";
|
|
37
|
+
/** Importance score */
|
|
38
|
+
importance: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Timeline Extraction
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Extract timeline events from unstructured content.
|
|
47
|
+
* Handles various date formats and event descriptions.
|
|
48
|
+
*/
|
|
49
|
+
export async function extractTimelineEvents(
|
|
50
|
+
input: TimelineExtractionInput,
|
|
51
|
+
llm: ResolvedLLM,
|
|
52
|
+
): Promise<TimelineExtractionResult> {
|
|
53
|
+
const apiKey = resolveApiKey(llm);
|
|
54
|
+
if (!apiKey) {
|
|
55
|
+
// Fallback: regex-based extraction
|
|
56
|
+
return fallbackExtract(input);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const prompt = buildExtractionPrompt(input);
|
|
60
|
+
const resp = await callLLM(llm, prompt, 2048);
|
|
61
|
+
|
|
62
|
+
if (!resp) {
|
|
63
|
+
return fallbackExtract(input);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entries = parseExtractionResponse(resp, input.pageSlug);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
entries,
|
|
70
|
+
success: entries.length > 0,
|
|
71
|
+
confidence: entries.length > 0 ? 0.85 : 0.3,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract timeline events from entity relations.
|
|
77
|
+
* Used when processing entity-link extraction results.
|
|
78
|
+
*/
|
|
79
|
+
export async function extractTimelineFromRelation(
|
|
80
|
+
relation: {
|
|
81
|
+
from: string;
|
|
82
|
+
to: string;
|
|
83
|
+
relationType: string;
|
|
84
|
+
context: string;
|
|
85
|
+
},
|
|
86
|
+
defaultDate: string,
|
|
87
|
+
pageSlug: string,
|
|
88
|
+
llm: ResolvedLLM,
|
|
89
|
+
): Promise<TimelineEntry | null> {
|
|
90
|
+
// Only extract timeline for significant relation types
|
|
91
|
+
const significantTypes = ["invested_in", "acquired", "founder_of", "leader_of", "works_at"];
|
|
92
|
+
if (!significantTypes.includes(relation.relationType)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const prompt = buildRelationTimelinePrompt(relation, defaultDate);
|
|
97
|
+
const resp = await callLLM(llm, prompt, 512);
|
|
98
|
+
|
|
99
|
+
if (!resp) return null;
|
|
100
|
+
|
|
101
|
+
const entries = parseExtractionResponse(resp, pageSlug);
|
|
102
|
+
return entries[0] ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Prompts
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
function buildExtractionPrompt(input: TimelineExtractionInput): string {
|
|
110
|
+
return `Extract timeline events from this content.
|
|
111
|
+
|
|
112
|
+
## Content
|
|
113
|
+
Source: ${input.source}
|
|
114
|
+
Default Date (use if no date found): ${input.defaultDate}
|
|
115
|
+
Content:
|
|
116
|
+
${input.content.slice(0, 4000)}
|
|
117
|
+
|
|
118
|
+
## Task
|
|
119
|
+
Extract ALL significant events worth recording in a timeline. Output ONLY JSON array.
|
|
120
|
+
|
|
121
|
+
Schema:
|
|
122
|
+
[
|
|
123
|
+
{
|
|
124
|
+
"date": "YYYY-MM-DD (extract from content or use default)",
|
|
125
|
+
"summary": "concise one-line summary (max 80 chars)",
|
|
126
|
+
"detail": "optional markdown detail",
|
|
127
|
+
"eventType": "milestone|update|meeting|announcement|transaction|other",
|
|
128
|
+
"importance": 1-5 (5 = most important)
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
Rules:
|
|
133
|
+
1. Extract explicit dates from content (formats: "Jan 15", "2024-01-15", "1月15日", "last week", "yesterday", etc.)
|
|
134
|
+
2. Convert relative dates to absolute using default date as reference
|
|
135
|
+
3. Include: milestones, decisions, meetings, announcements, transactions, status changes
|
|
136
|
+
4. Exclude: trivial mentions, routine activities, vague references
|
|
137
|
+
5. Importance 5: founding, acquisition, major funding, product launch
|
|
138
|
+
6. Importance 3-4: meetings, partnerships, minor updates
|
|
139
|
+
7. Importance 1-2: minor mentions, routine status
|
|
140
|
+
8. Max 5 entries, prioritized by importance
|
|
141
|
+
9. Empty array if no significant events
|
|
142
|
+
|
|
143
|
+
Examples:
|
|
144
|
+
- "River AI closed Series A yesterday" → [{date: "${input.defaultDate}", summary: "River AI closed Series A funding", eventType: "transaction", importance: 5}]
|
|
145
|
+
- "We met with the team on Jan 15" → [{date: "2025-01-15", summary: "Met with team", eventType: "meeting", importance: 3}]
|
|
146
|
+
- "The company was founded in 2020" → [{date: "2020-01-01", summary: "Company founded", eventType: "milestone", importance: 5}]
|
|
147
|
+
|
|
148
|
+
/no_think`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildRelationTimelinePrompt(
|
|
152
|
+
relation: { from: string; to: string; relationType: string; context: string },
|
|
153
|
+
defaultDate: string,
|
|
154
|
+
): string {
|
|
155
|
+
return `Create a timeline entry for this relationship event.
|
|
156
|
+
|
|
157
|
+
## Relationship
|
|
158
|
+
From: ${relation.from}
|
|
159
|
+
To: ${relation.to}
|
|
160
|
+
Type: ${relation.relationType}
|
|
161
|
+
Context: ${relation.context}
|
|
162
|
+
Default Date: ${defaultDate}
|
|
163
|
+
|
|
164
|
+
## Task
|
|
165
|
+
Output ONLY JSON array (single entry or empty).
|
|
166
|
+
|
|
167
|
+
[
|
|
168
|
+
{
|
|
169
|
+
"date": "YYYY-MM-DD",
|
|
170
|
+
"summary": "concise summary (max 80 chars)",
|
|
171
|
+
"detail": "",
|
|
172
|
+
"eventType": "milestone|update|transaction",
|
|
173
|
+
"importance": 1-5
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
Rules:
|
|
178
|
+
1. Extract date from context if mentioned
|
|
179
|
+
2. Summarize the relationship event factually
|
|
180
|
+
3. Empty array if context is vague or lacks timing
|
|
181
|
+
|
|
182
|
+
Examples:
|
|
183
|
+
- "John founded the company in 2019" → [{date: "2019-01-01", summary: "${relation.from} founded ${relation.to}", importance: 5}]
|
|
184
|
+
- "She joined as CEO last month" → [{date: "${defaultDate}", summary: "${relation.from} became CEO of ${relation.to}", importance: 4}]
|
|
185
|
+
|
|
186
|
+
/no_think`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// LLM Call
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
async function callLLM(llm: ResolvedLLM, prompt: string, maxTokens: number): Promise<string> {
|
|
194
|
+
const apiKey = resolveApiKey(llm);
|
|
195
|
+
if (!apiKey) return "";
|
|
196
|
+
|
|
197
|
+
const body = {
|
|
198
|
+
model: llm.model,
|
|
199
|
+
messages: [
|
|
200
|
+
{ role: "system", content: "You are a timeline extraction assistant. Extract events from unstructured text. Always output valid JSON array. Be concise and factual." },
|
|
201
|
+
{ role: "user", content: prompt },
|
|
202
|
+
],
|
|
203
|
+
temperature: 0.1,
|
|
204
|
+
max_tokens: maxTokens,
|
|
205
|
+
enable_thinking: false,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const resp = await fetch(
|
|
210
|
+
llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions",
|
|
211
|
+
{
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (!resp.ok) {
|
|
219
|
+
const text = await resp.text();
|
|
220
|
+
console.warn(`[timeline-extractor] LLM call failed (${resp.status}): ${text.slice(0, 200)}`);
|
|
221
|
+
return "";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const data = await resp.json();
|
|
225
|
+
return data.choices?.[0]?.message?.content?.trim() ?? "";
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
228
|
+
console.warn(`[timeline-extractor] LLM call error: ${msg}`);
|
|
229
|
+
return "";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Response Parsing
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
function parseExtractionResponse(resp: string, pageSlug: string): TimelineEntry[] {
|
|
238
|
+
const match = resp.match(/\[[\s\S]*\]/);
|
|
239
|
+
if (!match) return [];
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(match[0]) as unknown[];
|
|
243
|
+
const entries: TimelineEntry[] = [];
|
|
244
|
+
|
|
245
|
+
for (const e of parsed) {
|
|
246
|
+
if (typeof e !== "object" || e === null) continue;
|
|
247
|
+
const entry = e as Record<string, unknown>;
|
|
248
|
+
|
|
249
|
+
const date = normalizeDate(String(entry.date ?? ""));
|
|
250
|
+
if (!date) continue;
|
|
251
|
+
|
|
252
|
+
entries.push({
|
|
253
|
+
pageSlug,
|
|
254
|
+
date,
|
|
255
|
+
source: "extracted",
|
|
256
|
+
summary: String(entry.summary ?? "").slice(0, 120),
|
|
257
|
+
detail: String(entry.detail ?? ""),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Sort by date descending
|
|
262
|
+
entries.sort((a, b) => b.date.localeCompare(a.date));
|
|
263
|
+
|
|
264
|
+
return entries.slice(0, 5); // Max 5 entries per extraction
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Fallback Extraction (Regex-based)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function fallbackExtract(input: TimelineExtractionInput): TimelineExtractionResult {
|
|
275
|
+
const entries: TimelineEntry[] = [];
|
|
276
|
+
const content = input.content;
|
|
277
|
+
|
|
278
|
+
// Common date patterns
|
|
279
|
+
const datePatterns = [
|
|
280
|
+
// ISO: 2024-01-15
|
|
281
|
+
/\b(\d{4}-\d{2}-\d{2})\b/g,
|
|
282
|
+
// Chinese: 2024年1月15日, 1月15日
|
|
283
|
+
/\b(\d{4}年\d{1,2}月\d{1,2}日)\b/g,
|
|
284
|
+
/\b(\d{1,2}月\d{1,2}日)\b/g,
|
|
285
|
+
// English: Jan 15, January 15, Jan 15th
|
|
286
|
+
/\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?(?:,?\s+\d{4})?)\b/gi,
|
|
287
|
+
// Relative: yesterday, last week, last month
|
|
288
|
+
/\b(yesterday|last\s+week|last\s+month|recently)\b/gi,
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
// Try to find dates and extract surrounding context
|
|
292
|
+
for (const pattern of datePatterns) {
|
|
293
|
+
const matches = content.matchAll(pattern);
|
|
294
|
+
for (const match of matches) {
|
|
295
|
+
if (!match[1]) continue;
|
|
296
|
+
|
|
297
|
+
const rawDate = match[1];
|
|
298
|
+
const normalizedDate = normalizeDate(rawDate, input.defaultDate);
|
|
299
|
+
if (!normalizedDate) continue;
|
|
300
|
+
|
|
301
|
+
// Extract context around the date (up to 100 chars before and after)
|
|
302
|
+
const start = Math.max(0, match.index! - 100);
|
|
303
|
+
const end = Math.min(content.length, match.index! + match[0].length + 100);
|
|
304
|
+
const context = content.slice(start, end).trim();
|
|
305
|
+
|
|
306
|
+
// Create a summary from the context
|
|
307
|
+
const summary = context.slice(0, 80).replace(/\n+/g, " ").trim();
|
|
308
|
+
|
|
309
|
+
if (summary.length > 10) {
|
|
310
|
+
entries.push({
|
|
311
|
+
pageSlug: input.pageSlug,
|
|
312
|
+
date: normalizedDate,
|
|
313
|
+
source: input.source,
|
|
314
|
+
summary,
|
|
315
|
+
detail: "",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Deduplicate by date + summary similarity
|
|
322
|
+
const uniqueEntries = deduplicateEntries(entries);
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
entries: uniqueEntries,
|
|
326
|
+
success: uniqueEntries.length > 0,
|
|
327
|
+
confidence: 0.4, // Lower confidence for regex fallback
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Date Normalization
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
function normalizeDate(raw: string, defaultDate?: string): string {
|
|
336
|
+
const trimmed = raw.trim();
|
|
337
|
+
|
|
338
|
+
// Already ISO format
|
|
339
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
|
340
|
+
return trimmed;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Chinese format: 2024年1月15日
|
|
344
|
+
const chineseMatch = trimmed.match(/(\d{4})年(\d{1,2})月(\d{1,2})日/);
|
|
345
|
+
if (chineseMatch) {
|
|
346
|
+
const [, year, month, day] = chineseMatch;
|
|
347
|
+
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Chinese format without year: 1月15日
|
|
351
|
+
const chineseNoYearMatch = trimmed.match(/(\d{1,2})月(\d{1,2})日/);
|
|
352
|
+
if (chineseNoYearMatch && defaultDate) {
|
|
353
|
+
const [, month, day] = chineseNoYearMatch;
|
|
354
|
+
const year = defaultDate.slice(0, 4);
|
|
355
|
+
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// English month names
|
|
359
|
+
const monthMap: Record<string, string> = {
|
|
360
|
+
jan: "01", january: "01",
|
|
361
|
+
feb: "02", february: "02",
|
|
362
|
+
mar: "03", march: "03",
|
|
363
|
+
apr: "04", april: "04",
|
|
364
|
+
may: "05",
|
|
365
|
+
jun: "06", june: "06",
|
|
366
|
+
jul: "07", july: "07",
|
|
367
|
+
aug: "08", august: "08",
|
|
368
|
+
sep: "09", september: "09",
|
|
369
|
+
oct: "10", october: "10",
|
|
370
|
+
nov: "11", november: "11",
|
|
371
|
+
dec: "12", december: "12",
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const englishMatch = trimmed.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+(\d{1,2})(?:st|nd|rd|th)?(?:,?\s+(\d{4}))?/i);
|
|
375
|
+
if (englishMatch) {
|
|
376
|
+
const [, monthName, day, year] = englishMatch;
|
|
377
|
+
const month = monthMap[monthName.toLowerCase().slice(0, 3)];
|
|
378
|
+
if (month) {
|
|
379
|
+
const finalYear = year || (defaultDate ? defaultDate.slice(0, 4) : new Date().getFullYear().toString());
|
|
380
|
+
return `${finalYear}-${month}-${day.padStart(2, "0")}`;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Relative dates
|
|
385
|
+
if (/yesterday/i.test(trimmed) && defaultDate) {
|
|
386
|
+
const d = new Date(defaultDate);
|
|
387
|
+
d.setDate(d.getDate() - 1);
|
|
388
|
+
return d.toISOString().slice(0, 10);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (/last\s+week/i.test(trimmed) && defaultDate) {
|
|
392
|
+
const d = new Date(defaultDate);
|
|
393
|
+
d.setDate(d.getDate() - 7);
|
|
394
|
+
return d.toISOString().slice(0, 10);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (/last\s+month/i.test(trimmed) && defaultDate) {
|
|
398
|
+
const d = new Date(defaultDate);
|
|
399
|
+
d.setMonth(d.getMonth() - 1);
|
|
400
|
+
return d.toISOString().slice(0, 10);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (/recently/i.test(trimmed) && defaultDate) {
|
|
404
|
+
return defaultDate;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Default date fallback
|
|
408
|
+
if (defaultDate) {
|
|
409
|
+
return defaultDate;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return "";
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
// Helpers
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
function resolveApiKey(llm: ResolvedLLM): string {
|
|
420
|
+
if (llm.apiKey) return llm.apiKey;
|
|
421
|
+
if (llm.apiKeyEnv) return process.env[llm.apiKeyEnv] ?? "";
|
|
422
|
+
return "";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function deduplicateEntries(entries: TimelineEntry[]): TimelineEntry[] {
|
|
426
|
+
const seen = new Map<string, TimelineEntry>();
|
|
427
|
+
|
|
428
|
+
for (const entry of entries) {
|
|
429
|
+
const key = `${entry.date}:${entry.summary.slice(0, 50)}`;
|
|
430
|
+
if (!seen.has(key)) {
|
|
431
|
+
seen.set(key, entry);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return Array.from(seen.values());
|
|
436
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { buildProgram } from "./commands";
|
|
3
|
+
|
|
4
|
+
async function main(): Promise<void> {
|
|
5
|
+
const program = buildProgram();
|
|
6
|
+
await program.parseAsync(process.argv);
|
|
7
|
+
// Force exit to avoid seekdb native library segfault on cleanup
|
|
8
|
+
// (seekdb has a bug where its native cleanup crashes on process exit)
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
main().catch((error: unknown) => {
|
|
13
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
14
|
+
console.error(`[ebrain] ${message}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { normalizeLongSlug, slugify } from "../config";
|
|
4
|
+
import { readMaybeStdin, readTextFile } from "../markdown/io";
|
|
5
|
+
import { loadSettings } from "../settings";
|
|
6
|
+
import { BrainRepository } from "../repositories/brain-repo";
|
|
7
|
+
import { BrainDb } from "../db/client";
|
|
8
|
+
import { createProgress, formatDuration } from "../utils/progress";
|
|
9
|
+
|
|
10
|
+
function isDryRun(opts: Record<string, unknown>): boolean {
|
|
11
|
+
return Boolean(opts.dryRun);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function resolveInput(
|
|
15
|
+
fileOpt: string | undefined,
|
|
16
|
+
stdin: boolean,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
if (fileOpt) return readTextFile(fileOpt);
|
|
19
|
+
return readMaybeStdin().then((s) => s ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withRepo(
|
|
23
|
+
program: Command,
|
|
24
|
+
callback: (repo: BrainRepository) => Promise<void>,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const settings = await loadSettings();
|
|
27
|
+
const cliDb = program.opts().db;
|
|
28
|
+
const dbPath = cliDb ?? settings.dbPath;
|
|
29
|
+
const db = await BrainDb.connect(dbPath, settings);
|
|
30
|
+
const repo = new BrainRepository(db);
|
|
31
|
+
await callback(repo);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function print(program: Command, payload: unknown): void {
|
|
36
|
+
if (program.opts().json) {
|
|
37
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function registerCompileCommands(program: Command): void {
|
|
44
|
+
// -- compile (Smart Compilation)
|
|
45
|
+
program
|
|
46
|
+
.command("compile")
|
|
47
|
+
.argument("<slug>", "page slug")
|
|
48
|
+
.argument("<info>", "new information to compile")
|
|
49
|
+
.option("--source <source>", "source of information", "user")
|
|
50
|
+
.option("--date <date>", "date of information (YYYY-MM-DD)")
|
|
51
|
+
.option("--dry-run", "preview changes without executing", false)
|
|
52
|
+
.description("Intelligently compile new information into a page's compiled truth")
|
|
53
|
+
.addHelpText(
|
|
54
|
+
"after",
|
|
55
|
+
`
|
|
56
|
+
Examples:
|
|
57
|
+
ebrain compile companies/river-ai "River AI closed Series A funding" --source meeting_notes
|
|
58
|
+
ebrain compile people/john "John joined as CEO last month" --date 2025-03-01
|
|
59
|
+
`,
|
|
60
|
+
)
|
|
61
|
+
.action(async (slug: string, info: string, opts: { source?: string; date?: string; dryRun?: boolean }) => {
|
|
62
|
+
if (isDryRun(opts)) {
|
|
63
|
+
print(program, {
|
|
64
|
+
dryRun: true,
|
|
65
|
+
action: "compile",
|
|
66
|
+
slug,
|
|
67
|
+
info,
|
|
68
|
+
source: opts.source ?? "user",
|
|
69
|
+
date: opts.date ?? new Date().toISOString().slice(0, 10),
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await withRepo(program, async (repo) => {
|
|
75
|
+
const settings = await loadSettings();
|
|
76
|
+
const progress = createProgress();
|
|
77
|
+
|
|
78
|
+
progress.start(`Compiling into ${slug}...`);
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
|
|
81
|
+
const result = await repo.compilePage(
|
|
82
|
+
slug,
|
|
83
|
+
info,
|
|
84
|
+
opts.source ?? "user",
|
|
85
|
+
opts.date ?? new Date().toISOString().slice(0, 10),
|
|
86
|
+
settings.llm,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const duration = formatDuration(Date.now() - startTime);
|
|
90
|
+
|
|
91
|
+
if (result.changed) {
|
|
92
|
+
progress.succeed(`${result.changeSummary} (${duration})`);
|
|
93
|
+
} else {
|
|
94
|
+
progress.stop();
|
|
95
|
+
process.stderr.write(`No changes made (${duration})\n`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
print(program, {
|
|
99
|
+
ok: true,
|
|
100
|
+
action: "compile",
|
|
101
|
+
slug,
|
|
102
|
+
changed: result.changed,
|
|
103
|
+
changeType: result.changeType,
|
|
104
|
+
changeSummary: result.changeSummary,
|
|
105
|
+
timelineEntriesAdded: result.timelineEntries.length,
|
|
106
|
+
confidence: result.confidence,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// -- smart-ingest (Full Intelligent Ingestion)
|
|
112
|
+
program
|
|
113
|
+
.command("smart-ingest")
|
|
114
|
+
.argument("[slug]", "page slug (optional; auto-generated if omitted)")
|
|
115
|
+
.option("--file <path>", "read content from file")
|
|
116
|
+
.option("--stdin", "read content from stdin", false)
|
|
117
|
+
.option("--type <type>", "page type", "note")
|
|
118
|
+
.option("--title <title>", "page title")
|
|
119
|
+
.option("--source <source>", "source identifier", "ingest")
|
|
120
|
+
.option("--dry-run", "preview changes without executing", false)
|
|
121
|
+
.description("Full intelligent ingestion: compile truth, extract timeline, create entity links")
|
|
122
|
+
.addHelpText(
|
|
123
|
+
"after",
|
|
124
|
+
`
|
|
125
|
+
Examples:
|
|
126
|
+
ebrain smart-ingest --file meeting.md --type meeting --source "meeting_notes"
|
|
127
|
+
ebrain smart-ingest companies/river-ai --file report.md --type company
|
|
128
|
+
cat article.md | ebrain smart-ingest --stdin --type article
|
|
129
|
+
`,
|
|
130
|
+
)
|
|
131
|
+
.action(async (slug: string | undefined, opts: { file?: string; stdin?: boolean; type?: string; title?: string; source?: string; dryRun?: boolean }) => {
|
|
132
|
+
const input = await resolveInput(opts.file, opts.stdin ?? false);
|
|
133
|
+
if (!input.trim()) {
|
|
134
|
+
throw new Error("empty input — provide --file <path>, --stdin, or pipe content");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let finalSlug = slug;
|
|
138
|
+
if (!finalSlug) {
|
|
139
|
+
if (opts.file) {
|
|
140
|
+
const fileName = basename(opts.file).replace(/\.[^.]+$/i, "");
|
|
141
|
+
finalSlug = normalizeLongSlug(slugify(fileName));
|
|
142
|
+
} else if (opts.title) {
|
|
143
|
+
finalSlug = normalizeLongSlug(slugify(opts.title));
|
|
144
|
+
} else {
|
|
145
|
+
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
|
|
146
|
+
finalSlug = `ingest/${timestamp}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isDryRun(opts)) {
|
|
151
|
+
print(program, {
|
|
152
|
+
dryRun: true,
|
|
153
|
+
action: "smart-ingest",
|
|
154
|
+
slug: finalSlug,
|
|
155
|
+
type: opts.type ?? "note",
|
|
156
|
+
source: opts.source ?? "ingest",
|
|
157
|
+
contentLength: input.length,
|
|
158
|
+
});
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
await withRepo(program, async (repo) => {
|
|
163
|
+
const settings = await loadSettings();
|
|
164
|
+
const progress = createProgress();
|
|
165
|
+
const startTime = Date.now();
|
|
166
|
+
|
|
167
|
+
progress.start(`Ingesting into ${finalSlug}...`);
|
|
168
|
+
|
|
169
|
+
const result = await repo.ingestContent(
|
|
170
|
+
finalSlug,
|
|
171
|
+
input,
|
|
172
|
+
opts.source ?? "ingest",
|
|
173
|
+
opts.type ?? "note",
|
|
174
|
+
settings.llm,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const duration = formatDuration(Date.now() - startTime);
|
|
178
|
+
|
|
179
|
+
const parts = [];
|
|
180
|
+
if (result.compileResult.changed) parts.push(result.compileResult.changeSummary);
|
|
181
|
+
if (result.timelineResult.entries.length > 0) parts.push(`${result.timelineResult.entries.length} timeline entries`);
|
|
182
|
+
|
|
183
|
+
if (parts.length > 0) {
|
|
184
|
+
progress.succeed(`${parts.join(", ")} (${duration})`);
|
|
185
|
+
} else {
|
|
186
|
+
progress.stop();
|
|
187
|
+
process.stderr.write(`No changes made (${duration})\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
print(program, {
|
|
191
|
+
ok: true,
|
|
192
|
+
action: "smart-ingest",
|
|
193
|
+
slug: result.page.slug,
|
|
194
|
+
compile: {
|
|
195
|
+
changed: result.compileResult.changed,
|
|
196
|
+
changeType: result.compileResult.changeType,
|
|
197
|
+
changeSummary: result.compileResult.changeSummary,
|
|
198
|
+
confidence: result.compileResult.confidence,
|
|
199
|
+
},
|
|
200
|
+
timeline: {
|
|
201
|
+
entriesAdded: result.timelineResult.entries.length,
|
|
202
|
+
confidence: result.timelineResult.confidence,
|
|
203
|
+
},
|
|
204
|
+
updatedAt: result.page.updatedAt,
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|