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,214 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { fileExists, readTextFile } from "./markdown/io";
5
+
6
+ const SETTINGS_DIR = join(homedir(), ".ebrain");
7
+ export const SETTINGS_PATH = join(SETTINGS_DIR, "settings.json");
8
+ export const DEFAULT_DB_PATH = resolve(SETTINGS_DIR, "data", "ebrain.db");
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Schema
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const RemoteDbSchema = z.object({
15
+ host: z.string().optional(),
16
+ port: z.number().optional(),
17
+ user: z.string().optional(),
18
+ password: z.string().optional(),
19
+ database: z.string().optional(),
20
+ tenant: z.string().optional(),
21
+ });
22
+
23
+ const EmbedSchema = z.object({
24
+ provider: z.enum(["hash", "openai_compatible"]).optional(),
25
+ baseURL: z.string().optional(),
26
+ model: z.string().optional(),
27
+ dimensions: z.number().optional(),
28
+ apiKey: z.string().optional(),
29
+ apiKeyEnv: z.string().optional(),
30
+ });
31
+
32
+ const LLMSchema = z.object({
33
+ baseURL: z.string().optional(),
34
+ model: z.string().optional(),
35
+ apiKey: z.string().optional(),
36
+ apiKeyEnv: z.string().optional(),
37
+ });
38
+
39
+ const SettingsSchema = z.object({
40
+ db: z
41
+ .object({
42
+ path: z.string().optional(),
43
+ remote: RemoteDbSchema.optional(),
44
+ })
45
+ .optional(),
46
+ embed: EmbedSchema.optional(),
47
+ llm: LLMSchema.optional(),
48
+ });
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Resolved types (all values present after defaults + env merge)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export interface ResolvedSettings {
55
+ dbPath: string;
56
+ remote: ResolvedRemoteDb | null;
57
+ embed: ResolvedEmbed;
58
+ llm: ResolvedLLM;
59
+ }
60
+
61
+ export interface ResolvedRemoteDb {
62
+ host: string;
63
+ port: number;
64
+ user: string;
65
+ password: string;
66
+ database: string;
67
+ tenant: string;
68
+ }
69
+
70
+ export interface ResolvedEmbed {
71
+ provider: "hash" | "openai_compatible";
72
+ baseURL: string;
73
+ model: string;
74
+ dimensions: number;
75
+ apiKey: string;
76
+ apiKeyEnv: string;
77
+ }
78
+
79
+ export interface ResolvedLLM {
80
+ baseURL: string;
81
+ model: string;
82
+ apiKey: string;
83
+ apiKeyEnv: string;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Defaults
88
+ // ---------------------------------------------------------------------------
89
+
90
+ const DEFAULT_REMOTE = {
91
+ port: 3306,
92
+ user: "root",
93
+ password: "",
94
+ database: "ebrain",
95
+ tenant: "",
96
+ };
97
+
98
+ const DEFAULT_EMBED = {
99
+ provider: "hash" as const,
100
+ baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
101
+ model: "text-embedding-v4",
102
+ dimensions: 1024,
103
+ apiKey: "",
104
+ apiKeyEnv: "DASHSCOPE_API_KEY",
105
+ };
106
+
107
+ const DEFAULT_LLM = {
108
+ baseURL: "",
109
+ model: "qwen-plus",
110
+ apiKey: "",
111
+ apiKeyEnv: "DASHSCOPE_API_KEY",
112
+ };
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Load & resolve
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export async function loadSettings(): Promise<ResolvedSettings> {
119
+ const raw = await readSettingsFile();
120
+ const parsed = SettingsSchema.parse(raw ?? {});
121
+ return resolveSettings(parsed);
122
+ }
123
+
124
+ export async function readSettingsFile(): Promise<unknown | null> {
125
+ if (!(await fileExists(SETTINGS_PATH))) {
126
+ return null;
127
+ }
128
+ const text = await readTextFile(SETTINGS_PATH);
129
+ try {
130
+ return JSON.parse(text) as unknown;
131
+ } catch {
132
+ console.warn(
133
+ `[ebrain] Failed to parse ${SETTINGS_PATH}, using defaults.`,
134
+ );
135
+ return null;
136
+ }
137
+ }
138
+
139
+ export function resolveSettings(parsed: z.infer<typeof SettingsSchema>): ResolvedSettings {
140
+ const dbConf = parsed.db ?? {};
141
+ const remoteConf = dbConf.remote ?? {};
142
+ const embedConf = parsed.embed ?? {};
143
+
144
+ // Remote: settings → env → defaults
145
+ const host = remoteConf.host ?? process.env.EBRAIN_SEEKDB_HOST ?? "";
146
+ if (host) {
147
+ const remote: ResolvedRemoteDb = {
148
+ host: host.trim(),
149
+ port: numOr(remoteConf.port ?? process.env.EBRAIN_SEEKDB_PORT, DEFAULT_REMOTE.port),
150
+ user: nonEmpty(remoteConf.user ?? process.env.EBRAIN_SEEKDB_USER, DEFAULT_REMOTE.user),
151
+ password: nonEmpty(
152
+ remoteConf.password ?? process.env.EBRAIN_SEEKDB_PASSWORD,
153
+ DEFAULT_REMOTE.password,
154
+ ),
155
+ database: nonEmpty(
156
+ remoteConf.database ?? process.env.EBRAIN_SEEKDB_DATABASE,
157
+ DEFAULT_REMOTE.database,
158
+ ),
159
+ tenant: nonEmpty(remoteConf.tenant ?? process.env.EBRAIN_SEEKDB_TENANT, ""),
160
+ };
161
+ return { dbPath: dbConf.path ?? DEFAULT_DB_PATH, remote, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}) };
162
+ }
163
+
164
+ // Local mode
165
+ const dbPath = dbConf.path
166
+ ? resolvePath(dbConf.path)
167
+ : DEFAULT_DB_PATH;
168
+ return { dbPath, remote: null, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}) };
169
+ }
170
+
171
+ function resolveEmbed(conf: z.infer<typeof EmbedSchema>): ResolvedEmbed {
172
+ const provider = nonEmpty(
173
+ conf.provider ?? process.env.EBRAIN_EMBED_PROVIDER,
174
+ DEFAULT_EMBED.provider,
175
+ ).trim().toLowerCase() as "hash" | "openai_compatible";
176
+ const baseURL = nonEmpty(conf.baseURL ?? process.env.EBRAIN_EMBED_BASE_URL, DEFAULT_EMBED.baseURL);
177
+ const model = nonEmpty(conf.model ?? process.env.EBRAIN_EMBED_MODEL, DEFAULT_EMBED.model);
178
+ const dimensions = numOr(conf.dimensions ?? process.env.EBRAIN_EMBED_DIMENSIONS, DEFAULT_EMBED.dimensions);
179
+ const apiKey = nonEmpty(conf.apiKey ?? process.env.EBRAIN_EMBED_API_KEY, "");
180
+ const apiKeyEnv = nonEmpty(conf.apiKeyEnv ?? process.env.EBRAIN_EMBED_API_KEY_ENV, DEFAULT_EMBED.apiKeyEnv);
181
+ return { provider, baseURL, model, dimensions, apiKey, apiKeyEnv };
182
+ }
183
+
184
+ function resolveLLM(conf: z.infer<typeof LLMSchema>): ResolvedLLM {
185
+ const baseURL = nonEmpty(conf.baseURL, DEFAULT_LLM.baseURL);
186
+ const model = nonEmpty(conf.model, DEFAULT_LLM.model);
187
+ const apiKey = nonEmpty(conf.apiKey, DEFAULT_LLM.apiKey);
188
+ const apiKeyEnv = nonEmpty(conf.apiKeyEnv, DEFAULT_LLM.apiKeyEnv);
189
+ return { baseURL, model, apiKey, apiKeyEnv };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Helpers
194
+ // ---------------------------------------------------------------------------
195
+
196
+ function nonEmpty(val: string | undefined, fallback: string): string {
197
+ return val?.trim() ?? fallback;
198
+ }
199
+
200
+ function numOr(val: number | string | undefined, fallback: number): number {
201
+ if (typeof val === "number") return val;
202
+ if (typeof val === "string") {
203
+ const n = Number(val.trim());
204
+ if (Number.isFinite(n)) return n;
205
+ }
206
+ return fallback;
207
+ }
208
+
209
+ function resolvePath(p: string): string {
210
+ if (p.startsWith("~")) {
211
+ return join(homedir(), p.slice(1));
212
+ }
213
+ return resolve(p);
214
+ }
@@ -0,0 +1,55 @@
1
+ export type PageType =
2
+ | "person"
3
+ | "company"
4
+ | "deal"
5
+ | "yc"
6
+ | "civic"
7
+ | "project"
8
+ | "note"
9
+ | "other";
10
+
11
+ export interface PageRecord {
12
+ slug: string;
13
+ type: PageType | string;
14
+ title: string;
15
+ compiledTruth: string;
16
+ timeline: string;
17
+ frontmatter: Record<string, unknown>;
18
+ createdAt: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ export interface TimelineEntry {
23
+ id?: number;
24
+ pageSlug: string;
25
+ date: string;
26
+ source: string;
27
+ summary: string;
28
+ detail: string;
29
+ }
30
+
31
+ export interface SearchHit {
32
+ slug: string;
33
+ title: string;
34
+ type: string;
35
+ score: number;
36
+ excerpt?: string;
37
+ updatedAt?: string;
38
+ }
39
+
40
+ export interface BrainStats {
41
+ pages: number;
42
+ links: number;
43
+ tags: number;
44
+ timelineEntries: number;
45
+ rawRows: number;
46
+ }
47
+
48
+ export interface PutPageInput {
49
+ slug: string;
50
+ type: string;
51
+ title: string;
52
+ compiledTruth: string;
53
+ timeline?: string;
54
+ frontmatter?: Record<string, unknown>;
55
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Progress indicator with spinner animation for long-running operations.
3
+ */
4
+
5
+ export interface ProgressIndicator {
6
+ start(message: string): void;
7
+ update(message: string): void;
8
+ succeed(message?: string): void;
9
+ fail(message?: string): void;
10
+ stop(): void;
11
+ }
12
+
13
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
14
+ const SPINNER_INTERVAL = 80;
15
+
16
+ /**
17
+ * Create a progress indicator with spinner animation.
18
+ * @param stream Output stream (default: process.stderr)
19
+ */
20
+ export function createProgress(stream: NodeJS.WritableStream = process.stderr): ProgressIndicator {
21
+ let frameIndex = 0;
22
+ let interval: Timer | null = null;
23
+ let currentMessage = '';
24
+ let isRunning = false;
25
+
26
+ function clearLine() {
27
+ stream.write('\r\x1b[K');
28
+ }
29
+
30
+ function render() {
31
+ if (!isRunning) return;
32
+ const frame = SPINNER_FRAMES[frameIndex];
33
+ clearLine();
34
+ stream.write(`\x1b[36m${frame}\x1b[0m ${currentMessage}`);
35
+ frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
36
+ }
37
+
38
+ function start(message: string) {
39
+ if (isRunning) stop();
40
+ currentMessage = message;
41
+ isRunning = true;
42
+ frameIndex = 0;
43
+ render();
44
+ interval = setInterval(render, SPINNER_INTERVAL);
45
+ }
46
+
47
+ function update(message: string) {
48
+ currentMessage = message;
49
+ if (!isRunning) {
50
+ start(message);
51
+ }
52
+ }
53
+
54
+ function stop() {
55
+ if (interval) {
56
+ clearInterval(interval);
57
+ interval = null;
58
+ }
59
+ if (isRunning) {
60
+ clearLine();
61
+ isRunning = false;
62
+ }
63
+ }
64
+
65
+ function succeed(message?: string) {
66
+ stop();
67
+ const text = message || currentMessage;
68
+ stream.write(`\x1b[32m✓\x1b[0m ${text}\n`);
69
+ }
70
+
71
+ function fail(message?: string) {
72
+ stop();
73
+ const text = message || currentMessage;
74
+ stream.write(`\x1b[31m✗\x1b[0m ${text}\n`);
75
+ }
76
+
77
+ return { start, update, succeed, fail, stop };
78
+ }
79
+
80
+ /**
81
+ * Simple spinner for async operations.
82
+ * Usage: const done = spinner.start('Processing...'); await task(); done('Done');
83
+ */
84
+ export function spinner(stream: NodeJS.WritableStream = process.stderr) {
85
+ const progress = createProgress(stream);
86
+
87
+ return {
88
+ start(message: string) {
89
+ progress.start(message);
90
+ return (finalMessage?: string) => {
91
+ progress.succeed(finalMessage);
92
+ };
93
+ },
94
+ fail(message: string) {
95
+ progress.fail(message);
96
+ },
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Progress bar for batch operations.
102
+ */
103
+ export function progressBar(total: number, stream: NodeJS.WritableStream = process.stderr) {
104
+ let current = 0;
105
+ let lastPercent = -1;
106
+
107
+ function render(label: string) {
108
+ const percent = Math.floor((current / total) * 100);
109
+ if (percent === lastPercent) return;
110
+ lastPercent = percent;
111
+
112
+ const barWidth = 30;
113
+ const filled = Math.floor((current / total) * barWidth);
114
+ const empty = barWidth - filled;
115
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
116
+
117
+ stream.write(`\r\x1b[K${label} [${bar}] ${percent}% (${current}/${total})`);
118
+ }
119
+
120
+ return {
121
+ start(label: string) {
122
+ current = 0;
123
+ lastPercent = -1;
124
+ render(label);
125
+ },
126
+ increment(label: string) {
127
+ current++;
128
+ render(label);
129
+ },
130
+ done(label: string) {
131
+ current = total;
132
+ render(label);
133
+ stream.write('\n');
134
+ },
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Format duration in human-readable form.
140
+ */
141
+ export function formatDuration(ms: number): string {
142
+ if (ms < 1000) return `${ms}ms`;
143
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
144
+ const minutes = Math.floor(ms / 60000);
145
+ const seconds = Math.floor((ms % 60000) / 1000);
146
+ return `${minutes}m ${seconds}s`;
147
+ }
148
+
149
+ /**
150
+ * Measure and report operation duration.
151
+ */
152
+ export async function withProgress<T>(
153
+ message: string,
154
+ fn: () => Promise<T>,
155
+ stream: NodeJS.WritableStream = process.stderr
156
+ ): Promise<T> {
157
+ const progress = createProgress(stream);
158
+ const start = Date.now();
159
+
160
+ progress.start(message);
161
+
162
+ try {
163
+ const result = await fn();
164
+ const duration = formatDuration(Date.now() - start);
165
+ progress.succeed(`${message} (${duration})`);
166
+ return result;
167
+ } catch (error) {
168
+ progress.fail(`${message} - failed`);
169
+ throw error;
170
+ }
171
+ }