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
package/README.md
CHANGED
|
@@ -1,81 +1,83 @@
|
|
|
1
1
|
# ex-brain
|
|
2
2
|
|
|
3
|
-
CLI
|
|
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
|
-
-
|
|
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
|
-
|
|
15
|
+
## Data Collection
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
- 支持代码块、表格、数学公式
|
|
19
|
-
- 本地处理,隐私友好
|
|
20
|
-
- 支持 Obsidian 集成
|
|
17
|
+
We recommend [MarkSnip](https://chromewebstore.google.com/detail/kcbaglhfgbkjdnpeokaamjjkddempipm) for data collection:
|
|
21
18
|
|
|
22
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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 #
|
|
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
|
-
#
|
|
68
|
+
# Start MCP Server (for AI tool integration)
|
|
67
69
|
ebrain serve
|
|
68
70
|
```
|
|
69
71
|
|
|
70
|
-
##
|
|
72
|
+
## Configuration
|
|
71
73
|
|
|
72
|
-
|
|
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", //
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "CLI personal knowledge base powered by seekdb",
|
|
5
|
-
"
|
|
5
|
+
"module": "src/cli.ts",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"ebrain": "
|
|
8
|
+
"ebrain": "src/cli.ts"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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
|
+
}
|