ex-brain 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }