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 CHANGED
@@ -1,81 +1,83 @@
1
1
  # ex-brain
2
2
 
3
- CLI 个人知识库,基于 [seekdb](https://docs.seekdb.ai/) 构建,支持页面管理、混合检索、时间线、标签、导入导出与 MCP Server
3
+ CLI personal knowledge base built on [seekdb](https://docs.seekdb.ai/), featuring page management, hybrid search, timelines, tags, import/export, and MCP Server.
4
4
 
5
- ## 核心功能
5
+ ## Core Features
6
6
 
7
- - **知识图谱可视化** - 交互式图谱,展示实体关联关系
8
- - **智能编译** - 语义分析,智能更新 Compiled Truth
9
- - **时间线管理** - 自动提取事件,记录历史演变
10
- - **混合检索** - 全文搜索 + 向量语义查询
11
- - **实体链接** - 自动识别实体,创建关联页面
7
+ - **Knowledge Graph Visualization** - Interactive graph showing entity relationships
8
+ - **Intelligent Compilation** - Semantic analysis with smart Compiled Truth updates
9
+ - **Timeline Management** - Automatic event extraction and history tracking
10
+ - **Hybrid Search** - Full-text search + vector semantic queries
11
+ - **Entity Linking** - Auto-detect entities and create linked pages
12
12
 
13
- ## 数据采集
13
+ <img src="https://mdn.alipayobjects.com/huamei_ytl0i7/afts/img/A*TqdfTZ-yCPwAAAAAgBAAAAgAejCYAQ/original" width="800">
14
14
 
15
- 推荐使用 [MarkSnip](https://chromewebstore.google.com/detail/kcbaglhfgbkjdnpeokaamjjkddempipm) 作为数据采集工具:
15
+ ## Data Collection
16
16
 
17
- - 一键剪藏网页为 Markdown 格式
18
- - 支持代码块、表格、数学公式
19
- - 本地处理,隐私友好
20
- - 支持 Obsidian 集成
17
+ We recommend [MarkSnip](https://chromewebstore.google.com/detail/kcbaglhfgbkjdnpeokaamjjkddempipm) for data collection:
21
18
 
22
- 配合 ex-brain 使用:
19
+ - One-click web clipping to Markdown format
20
+ - Supports code blocks, tables, math formulas
21
+ - Local processing, privacy-friendly
22
+ - Obsidian integration support
23
+
24
+ Use with ex-brain:
23
25
 
24
26
  ```bash
25
- # MarkSnip 剪藏后,导入到知识库
27
+ # After clipping with MarkSnip, import to knowledge base
26
28
  cat article.md | ebrain put articles/slug --stdin
27
29
 
28
- # 或智能编译
30
+ # Or intelligent compilation
29
31
  ebrain compile companies/river-ai --file article.md --source web_clip
30
32
  ```
31
33
 
32
- ## 安装
34
+ ## Installation
33
35
 
34
36
  ```bash
35
- # 全局安装(需要 Bun Node.js
37
+ # Global installation (requires Bun or Node.js)
36
38
  bun install -g ex-brain
37
- #
39
+ # or
38
40
  npm install -g ex-brain
39
41
 
40
42
  ebrain --help
41
43
  ```
42
44
 
43
- ## 快速开始
45
+ ## Quick Start
44
46
 
45
47
  ```bash
46
- # 初始化(自动创建 ~/.ebrain/data/ebrain.db
48
+ # Initialize (creates ~/.ebrain/data/ebrain.db automatically)
47
49
  ebrain init
48
50
 
49
- # 写入页面
51
+ # Write a page
50
52
  ebrain put my/note --file note.md
51
53
 
52
- # 知识图谱可视化
53
- ebrain graph # 启动图谱 Web UI (http://localhost:3000)
54
- ebrain graph --port 8080 --open # 指定端口并自动打开浏览器
54
+ # Knowledge graph visualization
55
+ ebrain graph # Start graph Web UI (http://localhost:3000)
56
+ ebrain graph --port 8080 --open # Custom port and auto-open browser
55
57
 
56
- # 智能编译新信息
58
+ # Intelligently compile new information
57
59
  ebrain compile companies/river-ai "River AI completed Series A funding" --source meeting_notes
58
60
 
59
- # 从页面提取时间线事件
61
+ # Extract timeline events from a page
60
62
  ebrain timeline extract companies/river-ai
61
63
 
62
- # 检索
63
- ebrain search "某主题"
64
- ebrain query "某问题"
64
+ # Search
65
+ ebrain search "some topic"
66
+ ebrain query "some question"
65
67
 
66
- # 启动 MCP Server(供 AI 工具调用)
68
+ # Start MCP Server (for AI tool integration)
67
69
  ebrain serve
68
70
  ```
69
71
 
70
- ## 配置
72
+ ## Configuration
71
73
 
72
- 编辑 `~/.ebrain/settings.json`:
74
+ Edit `~/.ebrain/settings.json`:
73
75
 
74
76
  ```jsonc
75
77
  {
76
78
  "db": { "path": "~/.ebrain/data/ebrain.db" },
77
79
  "embed": {
78
- "provider": "hash", // "openai_compatible"
80
+ "provider": "hash", // or "openai_compatible"
79
81
  "baseURL": "...",
80
82
  "model": "...",
81
83
  "dimensions": 1024,
@@ -84,12 +86,12 @@ ebrain serve
84
86
  }
85
87
  ```
86
88
 
87
- 运行 `ebrain config` 查看当前生效配置。详见 [docs/ebrain-cli.md](docs/ebrain-cli.md)
89
+ Run `ebrain config` to view active configuration. See [docs/ebrain-cli.md](docs/ebrain-cli.md) for details.
88
90
 
89
- ## 开发
91
+ ## Development
90
92
 
91
93
  ```bash
92
94
  bun install
93
95
  bun run src/cli.ts --help
94
96
  bun test
95
- ```
97
+ ```
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "ex-brain",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI personal knowledge base powered by seekdb",
5
- "main": "dist/cli.js",
5
+ "module": "src/cli.ts",
6
6
  "type": "module",
7
7
  "bin": {
8
- "ebrain": "dist/cli.js"
8
+ "ebrain": "src/cli.ts"
9
9
  },
10
10
  "files": [
11
- "dist",
12
- "README.md"
11
+ "src",
12
+ "!src/**/*.test.ts"
13
13
  ],
14
14
  "scripts": {
15
15
  "dev": "bun run src/cli.ts",
@@ -0,0 +1,529 @@
1
+ import type { ResolvedLLM } from "../settings";
2
+ import type { TimelineEntry } from "../types";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface CompileInput {
9
+ /** Current compiled truth content */
10
+ currentTruth: string;
11
+ /** Timeline entries for context */
12
+ timeline: TimelineEntry[];
13
+ /** New information to process */
14
+ newInfo: string;
15
+ /** Source of the new information */
16
+ source: string;
17
+ /** Date of the new information (ISO or YYYY-MM-DD) */
18
+ date: string;
19
+ /** Page metadata for context */
20
+ pageContext?: {
21
+ slug: string;
22
+ type: string;
23
+ title: string;
24
+ };
25
+ }
26
+
27
+ export interface CompileResult {
28
+ /** Updated compiled truth */
29
+ compiledTruth: string;
30
+ /** Whether any update was made */
31
+ changed: boolean;
32
+ /** Type of change */
33
+ changeType: "append" | "update" | "replace" | "none" | "conflict";
34
+ /** Human-readable summary of what changed */
35
+ changeSummary: string;
36
+ /** Timeline entries to add (extracted from new info) */
37
+ timelineEntries: TimelineEntry[];
38
+ /** Confidence score */
39
+ confidence: number;
40
+ }
41
+
42
+ export interface FactAnalysis {
43
+ /** Key facts extracted */
44
+ facts: ExtractedFact[];
45
+ /** Information type classification */
46
+ infoType: "status_update" | "new_event" | "correction" | "confirmation" | "new_entity";
47
+ /** Entities mentioned */
48
+ entities: string[];
49
+ /** Temporal context */
50
+ temporalContext: string;
51
+ }
52
+
53
+ export interface ExtractedFact {
54
+ /** Fact category (e.g., "funding_stage", "valuation", "ceo") */
55
+ category: string;
56
+ /** Previous value (if this is an update) */
57
+ oldValue?: string;
58
+ /** New value */
59
+ newValue: string;
60
+ /** Whether this replaces or adds */
61
+ action: "replace" | "add";
62
+ /** Source sentence */
63
+ sourceSentence: string;
64
+ /** Confidence */
65
+ confidence: number;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Compile Logic
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Intelligent compilation: analyze new info, merge/update compiled truth.
74
+ * Uses LLM to understand semantic changes and update appropriately.
75
+ */
76
+ export async function compileTruth(
77
+ input: CompileInput,
78
+ llm: ResolvedLLM,
79
+ ): Promise<CompileResult> {
80
+ const apiKey = resolveApiKey(llm);
81
+ if (!apiKey) {
82
+ return {
83
+ compiledTruth: appendFact(input.currentTruth, input.newInfo, input.source),
84
+ changed: true,
85
+ changeType: "append",
86
+ changeSummary: "LLM not configured, appended as simple fact",
87
+ timelineEntries: [],
88
+ confidence: 0.5,
89
+ };
90
+ }
91
+
92
+ // Step 1: Analyze the new information
93
+ const analysis = await analyzeNewInfo(input, llm);
94
+
95
+ // Step 2: Generate updated compiled truth
96
+ const updateResult = await generateUpdatedTruth(input, analysis, llm);
97
+
98
+ // Step 3: Extract timeline entries from new info
99
+ const timelineEntries = await extractTimelineFromInfo(input, analysis, llm);
100
+
101
+ return {
102
+ compiledTruth: updateResult.compiledTruth,
103
+ changed: updateResult.changed,
104
+ changeType: updateResult.changeType,
105
+ changeSummary: updateResult.changeSummary,
106
+ timelineEntries,
107
+ confidence: analysis.facts.reduce((sum, f) => sum + f.confidence, 0) / Math.max(analysis.facts.length, 1),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Step 1: Analyze new information to understand what it means
113
+ */
114
+ async function analyzeNewInfo(
115
+ input: CompileInput,
116
+ llm: ResolvedLLM,
117
+ ): Promise<FactAnalysis> {
118
+ const prompt = buildAnalysisPrompt(input);
119
+
120
+ const resp = await callLLM(llm, prompt, 2048);
121
+ const parsed = parseAnalysisResponse(resp);
122
+
123
+ return parsed;
124
+ }
125
+
126
+ /**
127
+ * Step 2: Generate updated compiled truth based on analysis
128
+ */
129
+ async function generateUpdatedTruth(
130
+ input: CompileInput,
131
+ analysis: FactAnalysis,
132
+ llm: ResolvedLLM,
133
+ ): Promise<{ compiledTruth: string; changed: boolean; changeType: CompileResult["changeType"]; changeSummary: string }> {
134
+ // If no facts extracted, no change needed
135
+ if (analysis.facts.length === 0) {
136
+ return {
137
+ compiledTruth: input.currentTruth,
138
+ changed: false,
139
+ changeType: "none",
140
+ changeSummary: "No actionable facts extracted",
141
+ };
142
+ }
143
+
144
+ // For status updates and corrections, use LLM to intelligently merge
145
+ if (analysis.infoType === "status_update" || analysis.infoType === "correction") {
146
+ return await smartMergeTruth(input, analysis, llm);
147
+ }
148
+
149
+ // For new events/entities, append
150
+ if (analysis.infoType === "new_event" || analysis.infoType === "new_entity") {
151
+ return {
152
+ compiledTruth: appendStructuredFacts(input.currentTruth, analysis.facts, input.source),
153
+ changed: true,
154
+ changeType: "append",
155
+ changeSummary: `Added ${analysis.facts.length} new facts`,
156
+ };
157
+ }
158
+
159
+ // Default: append with source attribution
160
+ return {
161
+ compiledTruth: appendFact(input.currentTruth, input.newInfo, input.source),
162
+ changed: true,
163
+ changeType: "append",
164
+ changeSummary: "Appended new information with source attribution",
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Smart merge: LLM understands semantic updates and rewrites compiled truth
170
+ */
171
+ async function smartMergeTruth(
172
+ input: CompileInput,
173
+ analysis: FactAnalysis,
174
+ llm: ResolvedLLM,
175
+ ): Promise<{ compiledTruth: string; changed: boolean; changeType: CompileResult["changeType"]; changeSummary: string }> {
176
+ const prompt = buildMergePrompt(input, analysis);
177
+
178
+ const resp = await callLLM(llm, prompt, 4096);
179
+ const result = parseMergeResponse(resp);
180
+
181
+ return result;
182
+ }
183
+
184
+ /**
185
+ * Step 3: Extract timeline entries from new information
186
+ */
187
+ async function extractTimelineFromInfo(
188
+ input: CompileInput,
189
+ analysis: FactAnalysis,
190
+ llm: ResolvedLLM,
191
+ ): Promise<TimelineEntry[]> {
192
+ // Only extract timeline for significant events
193
+ if (analysis.infoType === "status_update" || analysis.infoType === "new_event") {
194
+ const prompt = buildTimelinePrompt(input, analysis);
195
+ const resp = await callLLM(llm, prompt, 1024);
196
+ return parseTimelineResponse(resp, input.pageContext?.slug ?? "");
197
+ }
198
+
199
+ return [];
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Prompt Building
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function buildAnalysisPrompt(input: CompileInput): string {
207
+ return `Analyze the new information and classify what type of update this represents.
208
+
209
+ ## Context
210
+ Page: ${input.pageContext?.title ?? "Unknown"} (${input.pageContext?.type ?? "unknown"})
211
+ Current Compiled Truth:
212
+ ${input.currentTruth || "(empty)"}
213
+
214
+ Recent Timeline (for temporal context):
215
+ ${input.timeline.slice(0, 10).map(t => `- ${t.date} | ${t.source}: ${t.summary}`).join("\n") || "(no timeline)"}
216
+
217
+ ## New Information
218
+ Source: ${input.source}
219
+ Date: ${input.date}
220
+ Content: ${input.newInfo}
221
+
222
+ ## Task
223
+ Classify this information and extract key facts. Output ONLY JSON.
224
+
225
+ Schema:
226
+ {
227
+ "facts": [
228
+ {
229
+ "category": "funding_stage|valuation|ceo|employee_count|product_status|partnership|...",
230
+ "oldValue": "previous value if this updates something (null if new)",
231
+ "newValue": "the new value",
232
+ "action": "replace|add",
233
+ "sourceSentence": "exact sentence from new info",
234
+ "confidence": 0.0-1.0
235
+ }
236
+ ],
237
+ "infoType": "status_update|new_event|correction|confirmation|new_entity",
238
+ "entities": ["list of entities mentioned"],
239
+ "temporalContext": "when this happened or is valid for"
240
+ }
241
+
242
+ Rules:
243
+ 1. "status_update" = information that changes/updates existing state (e.g., funding stage change)
244
+ 2. "new_event" = discrete event that happened (e.g., product launch)
245
+ 3. "correction" = explicitly correcting previous information
246
+ 4. "confirmation" = confirming existing information without change
247
+ 5. "new_entity" = introducing new entity/aspect not previously tracked
248
+ 6. Extract ALL actionable facts, not just the most prominent one
249
+ 7. Use high confidence (0.8+) for clear, explicit statements; lower for ambiguous ones
250
+
251
+ /no_think`;
252
+ }
253
+
254
+ function buildMergePrompt(input: CompileInput, analysis: FactAnalysis): string {
255
+ const factSummaries = analysis.facts.map(f =>
256
+ `- ${f.category}: ${f.oldValue ? `"${f.oldValue}" → "${f.newValue}"` : `"${f.newValue}"`} (${f.action}, confidence: ${f.confidence})`
257
+ ).join("\n");
258
+
259
+ return `Rewrite the compiled truth to incorporate the analyzed changes.
260
+
261
+ ## Current Compiled Truth
262
+ ${input.currentTruth || "(empty)"}
263
+
264
+ ## Changes to Apply
265
+ ${factSummaries}
266
+
267
+ ## Source Attribution
268
+ Source: ${input.source}
269
+ Date: ${input.date}
270
+
271
+ ## Change Type
272
+ ${analysis.infoType}
273
+
274
+ ## Task
275
+ Rewrite the compiled truth. Output ONLY JSON with this schema:
276
+ {
277
+ "compiledTruth": "the full rewritten compiled truth content (markdown format)",
278
+ "changed": true|false,
279
+ "changeType": "update|replace|conflict|none",
280
+ "changeSummary": "human-readable summary of what changed"
281
+ }
282
+
283
+ Rules:
284
+ 1. For "replace" actions: remove the old value, add the new value
285
+ 2. For "add" actions: append the new fact in appropriate section
286
+ 3. Preserve the overall structure and style of existing content
287
+ 4. Add source attribution: append " (Source: ${input.source}, ${input.date})" to updated facts
288
+ 5. If structure doesn't exist, create appropriate sections (## Status, ## Facts, etc.)
289
+ 6. "update" = modified existing content; "replace" = replaced entire section; "conflict" = contradictory info (keep both with notes)
290
+ 7. Do NOT remove historical context - keep timeline references
291
+ 8. Format as clean markdown
292
+
293
+ Example output for funding stage update:
294
+ {
295
+ "compiledTruth": "## Status\n\n- **Funding Stage**: Series A (Source: meeting_notes, 2024-05-20)\n- **Valuation**: ~$50M (estimated)\n\n## History\n\n- Previously: Seed stage (until 2024-05-20)\n\n## Facts\n\n- ...",
296
+ "changed": true,
297
+ "changeType": "update",
298
+ "changeSummary": "Updated funding stage from Seed to Series A"
299
+ }
300
+
301
+ /no_think`;
302
+ }
303
+
304
+ function buildTimelinePrompt(input: CompileInput, analysis: FactAnalysis): string {
305
+ return `Extract timeline entries from this information.
306
+
307
+ ## New Information
308
+ Date: ${input.date}
309
+ Source: ${input.source}
310
+ Content: ${input.newInfo}
311
+
312
+ ## Analysis
313
+ Type: ${analysis.infoType}
314
+ Key Facts: ${analysis.facts.map(f => f.newValue).join(", ")}
315
+
316
+ ## Task
317
+ Create timeline entries. Output ONLY JSON array:
318
+ [
319
+ {
320
+ "date": "YYYY-MM-DD",
321
+ "source": "${input.source}",
322
+ "summary": "one-line summary (max 80 chars)",
323
+ "detail": "optional additional detail (markdown)"
324
+ }
325
+ ]
326
+
327
+ Rules:
328
+ 1. Use the provided date, or extract exact date from content if mentioned
329
+ 2. Summary should be concise and factual
330
+ 3. Only create entries for significant events worth tracking
331
+ 4. Max 2 entries per input
332
+ 5. Empty array if nothing significant
333
+
334
+ /no_think`;
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // LLM Call
339
+ // ---------------------------------------------------------------------------
340
+
341
+ async function callLLM(llm: ResolvedLLM, prompt: string, maxTokens: number): Promise<string> {
342
+ const apiKey = resolveApiKey(llm);
343
+ if (!apiKey) return "";
344
+
345
+ const body = {
346
+ model: llm.model,
347
+ messages: [
348
+ { role: "system", content: "You are a knowledge compilation assistant. You analyze information, extract facts, and maintain structured compiled truth. Always output valid JSON. Be precise and factual." },
349
+ { role: "user", content: prompt },
350
+ ],
351
+ temperature: 0.1,
352
+ max_tokens: maxTokens,
353
+ enable_thinking: false,
354
+ };
355
+
356
+ try {
357
+ const resp = await fetch(
358
+ llm.baseURL.endsWith("/") ? llm.baseURL + "chat/completions" : llm.baseURL + "/chat/completions",
359
+ {
360
+ method: "POST",
361
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
362
+ body: JSON.stringify(body),
363
+ },
364
+ );
365
+
366
+ if (!resp.ok) {
367
+ const text = await resp.text();
368
+ console.warn(`[compiler] LLM call failed (${resp.status}): ${text.slice(0, 200)}`);
369
+ return "";
370
+ }
371
+
372
+ const data = await resp.json();
373
+ return data.choices?.[0]?.message?.content?.trim() ?? "";
374
+ } catch (error) {
375
+ const msg = error instanceof Error ? error.message : String(error);
376
+ console.warn(`[compiler] LLM call error: ${msg}`);
377
+ return "";
378
+ }
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Response Parsing
383
+ // ---------------------------------------------------------------------------
384
+
385
+ function parseAnalysisResponse(resp: string): FactAnalysis {
386
+ const match = resp.match(/\{[\s\S]*\}/);
387
+ if (!match) {
388
+ return { facts: [], infoType: "new_entity", entities: [], temporalContext: "" };
389
+ }
390
+
391
+ try {
392
+ const parsed = JSON.parse(match[0]) as Record<string, unknown>;
393
+
394
+ const facts: ExtractedFact[] = [];
395
+ const rawFacts = parsed.facts as unknown[] ?? [];
396
+ for (const f of rawFacts) {
397
+ if (typeof f !== "object" || f === null) continue;
398
+ const fact = f as Record<string, unknown>;
399
+ facts.push({
400
+ category: String(fact.category ?? "other"),
401
+ oldValue: fact.oldValue ? String(fact.oldValue) : undefined,
402
+ newValue: String(fact.newValue ?? ""),
403
+ action: fact.action === "replace" ? "replace" : "add",
404
+ sourceSentence: String(fact.sourceSentence ?? ""),
405
+ confidence: typeof fact.confidence === "number" ? fact.confidence : 0.8,
406
+ });
407
+ }
408
+
409
+ return {
410
+ facts,
411
+ infoType: normalizeInfoType(String(parsed.infoType ?? "new_entity")),
412
+ entities: (parsed.entities as unknown[] ?? []).map(String),
413
+ temporalContext: String(parsed.temporalContext ?? ""),
414
+ };
415
+ } catch {
416
+ return { facts: [], infoType: "new_entity", entities: [], temporalContext: "" };
417
+ }
418
+ }
419
+
420
+ function parseMergeResponse(resp: string): { compiledTruth: string; changed: boolean; changeType: CompileResult["changeType"]; changeSummary: string } {
421
+ const match = resp.match(/\{[\s\S]*\}/);
422
+ if (!match) {
423
+ return {
424
+ compiledTruth: "",
425
+ changed: false,
426
+ changeType: "none",
427
+ changeSummary: "Failed to parse LLM response",
428
+ };
429
+ }
430
+
431
+ try {
432
+ const parsed = JSON.parse(match[0]) as Record<string, unknown>;
433
+ return {
434
+ compiledTruth: String(parsed.compiledTruth ?? ""),
435
+ changed: Boolean(parsed.changed),
436
+ changeType: normalizeChangeType(String(parsed.changeType ?? "none")),
437
+ changeSummary: String(parsed.changeSummary ?? ""),
438
+ };
439
+ } catch {
440
+ return {
441
+ compiledTruth: "",
442
+ changed: false,
443
+ changeType: "none",
444
+ changeSummary: "Failed to parse LLM response",
445
+ };
446
+ }
447
+ }
448
+
449
+ function parseTimelineResponse(resp: string, pageSlug: string): TimelineEntry[] {
450
+ const match = resp.match(/\[[\s\S]*\]/);
451
+ if (!match) return [];
452
+
453
+ try {
454
+ const parsed = JSON.parse(match[0]) as unknown[];
455
+ const entries: TimelineEntry[] = [];
456
+
457
+ for (const e of parsed) {
458
+ if (typeof e !== "object" || e === null) continue;
459
+ const entry = e as Record<string, unknown>;
460
+ entries.push({
461
+ pageSlug,
462
+ date: String(entry.date ?? ""),
463
+ source: String(entry.source ?? "manual"),
464
+ summary: String(entry.summary ?? "").slice(0, 120),
465
+ detail: String(entry.detail ?? ""),
466
+ });
467
+ }
468
+
469
+ return entries;
470
+ } catch {
471
+ return [];
472
+ }
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Helpers
477
+ // ---------------------------------------------------------------------------
478
+
479
+ function normalizeInfoType(raw: string): FactAnalysis["infoType"] {
480
+ const valid = ["status_update", "new_event", "correction", "confirmation", "new_entity"] as const;
481
+ const lower = raw.toLowerCase().trim();
482
+ if (valid.includes(lower as typeof valid[number])) return lower as typeof valid[number];
483
+ return "new_entity";
484
+ }
485
+
486
+ function normalizeChangeType(raw: string): CompileResult["changeType"] {
487
+ const valid = ["append", "update", "replace", "none", "conflict"] as const;
488
+ const lower = raw.toLowerCase().trim();
489
+ if (valid.includes(lower as typeof valid[number])) return lower as typeof valid[number];
490
+ return "none";
491
+ }
492
+
493
+ function resolveApiKey(llm: ResolvedLLM): string {
494
+ if (llm.apiKey) return llm.apiKey;
495
+ if (llm.apiKeyEnv) return process.env[llm.apiKeyEnv] ?? "";
496
+ return "";
497
+ }
498
+
499
+ function appendFact(current: string, newInfo: string, source: string): string {
500
+ const timestamp = new Date().toISOString().slice(0, 10);
501
+ const newLine = `- ${newInfo.trim()} (Source: ${source}, ${timestamp})`;
502
+
503
+ if (!current.trim()) {
504
+ return `## Facts\n\n${newLine}`;
505
+ }
506
+
507
+ if (!current.includes("## Facts")) {
508
+ return `${current}\n\n## Facts\n\n${newLine}`;
509
+ }
510
+
511
+ return `${current}\n${newLine}`;
512
+ }
513
+
514
+ function appendStructuredFacts(current: string, facts: ExtractedFact[], source: string): string {
515
+ const timestamp = new Date().toISOString().slice(0, 10);
516
+ const newLines = facts.map(f =>
517
+ `- **${f.category}**: ${f.newValue} (Source: ${source}, ${timestamp})`
518
+ ).join("\n");
519
+
520
+ if (!current.trim()) {
521
+ return `## Facts\n\n${newLines}`;
522
+ }
523
+
524
+ if (!current.includes("## Facts")) {
525
+ return `${current}\n\n## Facts\n\n${newLines}`;
526
+ }
527
+
528
+ return `${current}\n${newLines}`;
529
+ }