ex-brain 0.1.1 → 0.2.0
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 +48 -0
- package/package.json +2 -1
- package/src/ai/compiler.ts +18 -53
- package/src/ai/entity-link.ts +31 -62
- package/src/ai/llm-client.ts +291 -0
- package/src/ai/timeline-extractor.ts +29 -62
- package/src/commands/index.ts +612 -86
- package/src/db/client.ts +121 -15
- package/src/db/errors.ts +178 -0
- package/src/db/schema.ts +1 -0
- package/src/mcp/server.ts +400 -237
- package/src/repositories/brain-repo.ts +576 -358
- package/src/settings.ts +23 -2
- package/src/types/index.ts +1 -0
- package/src/utils/cli-output.ts +569 -0
- package/src/utils/query-sanitizer.ts +63 -0
package/src/settings.ts
CHANGED
|
@@ -45,6 +45,11 @@ const SettingsSchema = z.object({
|
|
|
45
45
|
.optional(),
|
|
46
46
|
embed: EmbedSchema.optional(),
|
|
47
47
|
llm: LLMSchema.optional(),
|
|
48
|
+
extraction: z
|
|
49
|
+
.object({
|
|
50
|
+
confidenceThreshold: z.number().min(0).max(1).optional(),
|
|
51
|
+
})
|
|
52
|
+
.optional(),
|
|
48
53
|
});
|
|
49
54
|
|
|
50
55
|
// ---------------------------------------------------------------------------
|
|
@@ -56,6 +61,11 @@ export interface ResolvedSettings {
|
|
|
56
61
|
remote: ResolvedRemoteDb | null;
|
|
57
62
|
embed: ResolvedEmbed;
|
|
58
63
|
llm: ResolvedLLM;
|
|
64
|
+
extraction: ResolvedExtraction;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ResolvedExtraction {
|
|
68
|
+
confidenceThreshold: number;
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export interface ResolvedRemoteDb {
|
|
@@ -111,6 +121,10 @@ const DEFAULT_LLM = {
|
|
|
111
121
|
apiKeyEnv: "DASHSCOPE_API_KEY",
|
|
112
122
|
};
|
|
113
123
|
|
|
124
|
+
const DEFAULT_EXTRACTION = {
|
|
125
|
+
confidenceThreshold: 0.7,
|
|
126
|
+
};
|
|
127
|
+
|
|
114
128
|
// ---------------------------------------------------------------------------
|
|
115
129
|
// Load & resolve
|
|
116
130
|
// ---------------------------------------------------------------------------
|
|
@@ -140,6 +154,7 @@ export function resolveSettings(parsed: z.infer<typeof SettingsSchema>): Resolve
|
|
|
140
154
|
const dbConf = parsed.db ?? {};
|
|
141
155
|
const remoteConf = dbConf.remote ?? {};
|
|
142
156
|
const embedConf = parsed.embed ?? {};
|
|
157
|
+
const extractionConf = parsed.extraction ?? {};
|
|
143
158
|
|
|
144
159
|
// Remote: settings → env → defaults
|
|
145
160
|
const host = remoteConf.host ?? process.env.EBRAIN_SEEKDB_HOST ?? "";
|
|
@@ -158,14 +173,14 @@ export function resolveSettings(parsed: z.infer<typeof SettingsSchema>): Resolve
|
|
|
158
173
|
),
|
|
159
174
|
tenant: nonEmpty(remoteConf.tenant ?? process.env.EBRAIN_SEEKDB_TENANT, ""),
|
|
160
175
|
};
|
|
161
|
-
return { dbPath: dbConf.path ?? DEFAULT_DB_PATH, remote, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}) };
|
|
176
|
+
return { dbPath: dbConf.path ?? DEFAULT_DB_PATH, remote, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}), extraction: resolveExtraction(extractionConf) };
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
// Local mode
|
|
165
180
|
const dbPath = dbConf.path
|
|
166
181
|
? resolvePath(dbConf.path)
|
|
167
182
|
: DEFAULT_DB_PATH;
|
|
168
|
-
return { dbPath, remote: null, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}) };
|
|
183
|
+
return { dbPath, remote: null, embed: resolveEmbed(embedConf), llm: resolveLLM(parsed.llm ?? {}), extraction: resolveExtraction(extractionConf) };
|
|
169
184
|
}
|
|
170
185
|
|
|
171
186
|
function resolveEmbed(conf: z.infer<typeof EmbedSchema>): ResolvedEmbed {
|
|
@@ -189,6 +204,12 @@ function resolveLLM(conf: z.infer<typeof LLMSchema>): ResolvedLLM {
|
|
|
189
204
|
return { baseURL, model, apiKey, apiKeyEnv };
|
|
190
205
|
}
|
|
191
206
|
|
|
207
|
+
function resolveExtraction(conf: { confidenceThreshold?: number }): ResolvedExtraction {
|
|
208
|
+
const threshold = conf.confidenceThreshold ?? process.env.EBRAIN_CONFIDENCE_THRESHOLD;
|
|
209
|
+
const value = typeof threshold === "number" ? threshold : (threshold ? parseFloat(threshold) : DEFAULT_EXTRACTION.confidenceThreshold);
|
|
210
|
+
return { confidenceThreshold: Math.max(0, Math.min(1, value)) };
|
|
211
|
+
}
|
|
212
|
+
|
|
192
213
|
// ---------------------------------------------------------------------------
|
|
193
214
|
// Helpers
|
|
194
215
|
// ---------------------------------------------------------------------------
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Output Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides colorful, structured output for CLI interactions:
|
|
5
|
+
* - Green for success messages
|
|
6
|
+
* - Red for errors
|
|
7
|
+
* - Yellow for warnings
|
|
8
|
+
* - Task status tracking (working -> [done])
|
|
9
|
+
* - Detailed step-by-step feedback
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ANSI color codes
|
|
13
|
+
export const COLORS = {
|
|
14
|
+
// Text colors
|
|
15
|
+
reset: '\x1b[0m',
|
|
16
|
+
bold: '\x1b[1m',
|
|
17
|
+
dim: '\x1b[2m',
|
|
18
|
+
|
|
19
|
+
// Foreground colors
|
|
20
|
+
black: '\x1b[30m',
|
|
21
|
+
red: '\x1b[31m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
yellow: '\x1b[33m',
|
|
24
|
+
blue: '\x1b[34m',
|
|
25
|
+
magenta: '\x1b[35m',
|
|
26
|
+
cyan: '\x1b[36m',
|
|
27
|
+
white: '\x1b[37m',
|
|
28
|
+
|
|
29
|
+
// Background colors
|
|
30
|
+
bgRed: '\x1b[41m',
|
|
31
|
+
bgGreen: '\x1b[42m',
|
|
32
|
+
bgYellow: '\x1b[43m',
|
|
33
|
+
bgBlue: '\x1b[44m',
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
// Icons
|
|
37
|
+
export const ICONS = {
|
|
38
|
+
success: '✓',
|
|
39
|
+
error: '✗',
|
|
40
|
+
warning: '⚠',
|
|
41
|
+
info: 'ℹ',
|
|
42
|
+
working: '◌',
|
|
43
|
+
done: '●',
|
|
44
|
+
arrow: '→',
|
|
45
|
+
bullet: '•',
|
|
46
|
+
chevron: '›',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
// Spinner frames
|
|
50
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
51
|
+
const SPINNER_INTERVAL = 80;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Strip ANSI color codes from string (for length calculation)
|
|
55
|
+
*/
|
|
56
|
+
function stripAnsi(str: string): string {
|
|
57
|
+
// eslint-disable-next-line no-control-regex
|
|
58
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pad string to width (considering ANSI codes)
|
|
63
|
+
*/
|
|
64
|
+
function padRight(str: string, width: number): string {
|
|
65
|
+
const visibleLen = stripAnsi(str).length;
|
|
66
|
+
return str + ' '.repeat(Math.max(0, width - visibleLen));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Output Stream
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
export type OutputStream = NodeJS.WritableStream;
|
|
74
|
+
|
|
75
|
+
const defaultStderr: OutputStream = process.stderr;
|
|
76
|
+
const defaultStdout: OutputStream = process.stdout;
|
|
77
|
+
|
|
78
|
+
// ============================================================================
|
|
79
|
+
// Basic Output Functions
|
|
80
|
+
// ============================================================================
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Print success message (green)
|
|
84
|
+
*/
|
|
85
|
+
export function success(message: string, stream: OutputStream = defaultStderr): void {
|
|
86
|
+
stream.write(`${COLORS.green}${ICONS.success}${COLORS.reset} ${message}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Print error message (red)
|
|
91
|
+
*/
|
|
92
|
+
export function error(message: string, stream: OutputStream = defaultStderr): void {
|
|
93
|
+
stream.write(`${COLORS.red}${ICONS.error}${COLORS.reset} ${message}\n`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Print warning message (yellow)
|
|
98
|
+
*/
|
|
99
|
+
export function warning(message: string, stream: OutputStream = defaultStderr): void {
|
|
100
|
+
stream.write(`${COLORS.yellow}${ICONS.warning}${COLORS.reset} ${message}\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Print info message (cyan)
|
|
105
|
+
*/
|
|
106
|
+
export function info(message: string, stream: OutputStream = defaultStderr): void {
|
|
107
|
+
stream.write(`${COLORS.cyan}${ICONS.info}${COLORS.reset} ${message}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Print a step/progress message
|
|
112
|
+
*/
|
|
113
|
+
export function step(stepNum: number, total: number, message: string, stream: OutputStream = defaultStderr): void {
|
|
114
|
+
const counter = `${COLORS.dim}[${stepNum}/${total}]${COLORS.reset}`;
|
|
115
|
+
stream.write(`${counter} ${message}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Print a sub-item (indented)
|
|
120
|
+
*/
|
|
121
|
+
export function subItem(message: string, indent: number = 2, stream: OutputStream = defaultStderr): void {
|
|
122
|
+
const spaces = ' '.repeat(indent);
|
|
123
|
+
stream.write(`${spaces}${COLORS.dim}${ICONS.bullet}${COLORS.reset} ${message}\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Print a key-value pair
|
|
128
|
+
*/
|
|
129
|
+
export function keyValue(key: string, value: string, stream: OutputStream = defaultStderr): void {
|
|
130
|
+
stream.write(` ${COLORS.dim}${key}:${COLORS.reset} ${value}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Print a header/section title
|
|
135
|
+
*/
|
|
136
|
+
export function header(title: string, stream: OutputStream = defaultStderr): void {
|
|
137
|
+
stream.write(`\n${COLORS.bold}${COLORS.blue}■ ${title}${COLORS.reset}\n`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Print a separator line
|
|
142
|
+
*/
|
|
143
|
+
export function separator(stream: OutputStream = defaultStderr): void {
|
|
144
|
+
stream.write(`${COLORS.dim}${'─'.repeat(50)}${COLORS.reset}\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Task Status Tracker
|
|
149
|
+
// ============================================================================
|
|
150
|
+
|
|
151
|
+
export interface TaskStatus {
|
|
152
|
+
name: string;
|
|
153
|
+
status: 'pending' | 'working' | 'done' | 'failed' | 'skipped';
|
|
154
|
+
message?: string;
|
|
155
|
+
details?: string[];
|
|
156
|
+
duration?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export class TaskRunner {
|
|
160
|
+
private tasks: TaskStatus[] = [];
|
|
161
|
+
private currentTaskIndex: number = -1;
|
|
162
|
+
private startTime: number = 0;
|
|
163
|
+
private spinnerInterval: Timer | null = null;
|
|
164
|
+
private frameIndex = 0;
|
|
165
|
+
private stream: OutputStream;
|
|
166
|
+
private json: boolean;
|
|
167
|
+
|
|
168
|
+
constructor(stream: OutputStream = defaultStderr, json: boolean = false) {
|
|
169
|
+
this.stream = stream;
|
|
170
|
+
this.json = json;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Register a task
|
|
175
|
+
*/
|
|
176
|
+
addTask(name: string): this {
|
|
177
|
+
this.tasks.push({ name, status: 'pending' });
|
|
178
|
+
return this;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Start a task by name or index
|
|
183
|
+
*/
|
|
184
|
+
start(taskIdentifier: string | number, message?: string): void {
|
|
185
|
+
if (this.json) return;
|
|
186
|
+
|
|
187
|
+
const index = typeof taskIdentifier === 'number'
|
|
188
|
+
? taskIdentifier
|
|
189
|
+
: this.tasks.findIndex(t => t.name === taskIdentifier);
|
|
190
|
+
|
|
191
|
+
if (index === -1) return;
|
|
192
|
+
|
|
193
|
+
this.currentTaskIndex = index;
|
|
194
|
+
this.tasks[index].status = 'working';
|
|
195
|
+
this.tasks[index].message = message;
|
|
196
|
+
this.startTime = Date.now();
|
|
197
|
+
|
|
198
|
+
this.render();
|
|
199
|
+
this.startSpinner();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Complete current task successfully
|
|
204
|
+
*/
|
|
205
|
+
succeed(message?: string, details?: string[]): void {
|
|
206
|
+
if (this.json) return;
|
|
207
|
+
|
|
208
|
+
this.stopSpinner();
|
|
209
|
+
|
|
210
|
+
if (this.currentTaskIndex >= 0) {
|
|
211
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
212
|
+
task.status = 'done';
|
|
213
|
+
task.message = message;
|
|
214
|
+
task.details = details;
|
|
215
|
+
task.duration = Date.now() - this.startTime;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
this.render();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Fail current task
|
|
223
|
+
*/
|
|
224
|
+
fail(message?: string, details?: string[]): void {
|
|
225
|
+
if (this.json) return;
|
|
226
|
+
|
|
227
|
+
this.stopSpinner();
|
|
228
|
+
|
|
229
|
+
if (this.currentTaskIndex >= 0) {
|
|
230
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
231
|
+
task.status = 'failed';
|
|
232
|
+
task.message = message;
|
|
233
|
+
task.details = details;
|
|
234
|
+
task.duration = Date.now() - this.startTime;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
this.render();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Skip current task
|
|
242
|
+
*/
|
|
243
|
+
skip(reason?: string): void {
|
|
244
|
+
if (this.json) return;
|
|
245
|
+
|
|
246
|
+
this.stopSpinner();
|
|
247
|
+
|
|
248
|
+
if (this.currentTaskIndex >= 0) {
|
|
249
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
250
|
+
task.status = 'skipped';
|
|
251
|
+
task.message = reason;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.render();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Update current task message (while working)
|
|
259
|
+
*/
|
|
260
|
+
update(message: string): void {
|
|
261
|
+
if (this.json) return;
|
|
262
|
+
|
|
263
|
+
if (this.currentTaskIndex >= 0) {
|
|
264
|
+
this.tasks[this.currentTaskIndex].message = message;
|
|
265
|
+
this.render();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Add detail to current task
|
|
271
|
+
*/
|
|
272
|
+
addDetail(detail: string): void {
|
|
273
|
+
if (this.json) return;
|
|
274
|
+
|
|
275
|
+
if (this.currentTaskIndex >= 0) {
|
|
276
|
+
const task = this.tasks[this.currentTaskIndex];
|
|
277
|
+
if (!task.details) task.details = [];
|
|
278
|
+
task.details.push(detail);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Print summary of all tasks
|
|
284
|
+
*/
|
|
285
|
+
summary(): void {
|
|
286
|
+
if (this.json) return;
|
|
287
|
+
|
|
288
|
+
this.stopSpinner();
|
|
289
|
+
this.stream.write('\n');
|
|
290
|
+
|
|
291
|
+
const done = this.tasks.filter(t => t.status === 'done').length;
|
|
292
|
+
const failed = this.tasks.filter(t => t.status === 'failed').length;
|
|
293
|
+
const skipped = this.tasks.filter(t => t.status === 'skipped').length;
|
|
294
|
+
const total = this.tasks.length;
|
|
295
|
+
|
|
296
|
+
// Summary line
|
|
297
|
+
const parts: string[] = [];
|
|
298
|
+
if (done > 0) parts.push(`${COLORS.green}${done} passed${COLORS.reset}`);
|
|
299
|
+
if (failed > 0) parts.push(`${COLORS.red}${failed} failed${COLORS.reset}`);
|
|
300
|
+
if (skipped > 0) parts.push(`${COLORS.yellow}${skipped} skipped${COLORS.reset}`);
|
|
301
|
+
|
|
302
|
+
const summaryText = parts.join(', ') || 'No tasks';
|
|
303
|
+
this.stream.write(`${COLORS.bold}Summary:${COLORS.reset} ${summaryText} (${total} total)\n`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// -----------------------------------------------------------------------
|
|
307
|
+
// Private methods
|
|
308
|
+
// -----------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
private startSpinner(): void {
|
|
311
|
+
this.stopSpinner();
|
|
312
|
+
this.spinnerInterval = setInterval(() => {
|
|
313
|
+
this.frameIndex = (this.frameIndex + 1) % SPINNER_FRAMES.length;
|
|
314
|
+
this.render();
|
|
315
|
+
}, SPINNER_INTERVAL);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private stopSpinner(): void {
|
|
319
|
+
if (this.spinnerInterval) {
|
|
320
|
+
clearInterval(this.spinnerInterval);
|
|
321
|
+
this.spinnerInterval = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private render(): void {
|
|
326
|
+
// Clear previous output
|
|
327
|
+
const linesToClear = this.tasks.length +
|
|
328
|
+
this.tasks.reduce((sum, t) => sum + (t.details?.length ?? 0), 0);
|
|
329
|
+
|
|
330
|
+
for (let i = 0; i < linesToClear + 2; i++) {
|
|
331
|
+
this.stream.write('\x1b[F\x1b[K'); // Move up and clear line
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Render each task
|
|
335
|
+
for (let i = 0; i < this.tasks.length; i++) {
|
|
336
|
+
const task = this.tasks[i];
|
|
337
|
+
this.renderTask(task, i === this.currentTaskIndex);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this.stream.write('\n');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private renderTask(task: TaskStatus, isCurrent: boolean): void {
|
|
344
|
+
const icon = this.getStatusIcon(task.status, isCurrent);
|
|
345
|
+
const color = this.getStatusColor(task.status);
|
|
346
|
+
const name = task.name;
|
|
347
|
+
const message = task.message ? ` ${COLORS.dim}${task.message}${COLORS.reset}` : '';
|
|
348
|
+
const duration = task.duration ? ` ${COLORS.dim}(${formatDuration(task.duration)})${COLORS.reset}` : '';
|
|
349
|
+
|
|
350
|
+
this.stream.write(`${color}${icon}${COLORS.reset} ${name}${message}${duration}\n`);
|
|
351
|
+
|
|
352
|
+
// Render details
|
|
353
|
+
if (task.details && task.details.length > 0) {
|
|
354
|
+
for (const detail of task.details) {
|
|
355
|
+
this.stream.write(` ${COLORS.dim}${ICONS.chevron} ${detail}${COLORS.reset}\n`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private getStatusIcon(status: TaskStatus['status'], isCurrent: boolean): string {
|
|
361
|
+
if (status === 'working' && isCurrent) {
|
|
362
|
+
return SPINNER_FRAMES[this.frameIndex];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
switch (status) {
|
|
366
|
+
case 'pending': return '○';
|
|
367
|
+
case 'working': return SPINNER_FRAMES[this.frameIndex];
|
|
368
|
+
case 'done': return ICONS.done;
|
|
369
|
+
case 'failed': return ICONS.error;
|
|
370
|
+
case 'skipped': return '○';
|
|
371
|
+
default: return '○';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private getStatusColor(status: TaskStatus['status']): string {
|
|
376
|
+
switch (status) {
|
|
377
|
+
case 'done': return COLORS.green;
|
|
378
|
+
case 'failed': return COLORS.red;
|
|
379
|
+
case 'skipped': return COLORS.yellow;
|
|
380
|
+
case 'working': return COLORS.cyan;
|
|
381
|
+
default: return COLORS.dim;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ============================================================================
|
|
387
|
+
// Progress Spinner (Simple)
|
|
388
|
+
// ============================================================================
|
|
389
|
+
|
|
390
|
+
export interface ProgressSpinner {
|
|
391
|
+
start(message: string): void;
|
|
392
|
+
update(message: string): void;
|
|
393
|
+
succeed(message?: string): void;
|
|
394
|
+
fail(message?: string): void;
|
|
395
|
+
warn(message?: string): void;
|
|
396
|
+
stop(): void;
|
|
397
|
+
nativeError(message?: string): void;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function createSpinner(stream: OutputStream = defaultStderr): ProgressSpinner {
|
|
401
|
+
// Allow disabling spinner via env var for debugging native errors
|
|
402
|
+
const noSpinner = process.env.EBRAIN_NO_SPINNER === '1' || process.env.EBRAIN_NO_SPINNER === 'true';
|
|
403
|
+
|
|
404
|
+
let frameIndex = 0;
|
|
405
|
+
let interval: Timer | null = null;
|
|
406
|
+
let currentMessage = '';
|
|
407
|
+
let isRunning = false;
|
|
408
|
+
|
|
409
|
+
function clearLine(): void {
|
|
410
|
+
stream.write('\r\x1b[K');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function render(): void {
|
|
414
|
+
if (!isRunning) return;
|
|
415
|
+
const frame = SPINNER_FRAMES[frameIndex];
|
|
416
|
+
clearLine();
|
|
417
|
+
stream.write(`\x1b[36m${frame}\x1b[0m ${currentMessage}`);
|
|
418
|
+
frameIndex = (frameIndex + 1) % SPINNER_FRAMES.length;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function start(message: string): void {
|
|
422
|
+
if (isRunning) stop();
|
|
423
|
+
currentMessage = message;
|
|
424
|
+
isRunning = true;
|
|
425
|
+
frameIndex = 0;
|
|
426
|
+
|
|
427
|
+
if (noSpinner) {
|
|
428
|
+
// No spinner mode: just print the message
|
|
429
|
+
stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
|
|
430
|
+
} else {
|
|
431
|
+
render();
|
|
432
|
+
interval = setInterval(render, SPINNER_INTERVAL);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function update(message: string): void {
|
|
437
|
+
currentMessage = message;
|
|
438
|
+
if (!isRunning) {
|
|
439
|
+
start(message);
|
|
440
|
+
} else if (noSpinner) {
|
|
441
|
+
stream.write(`${COLORS.cyan}${ICONS.working}${COLORS.reset} ${message}\n`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function stop(): void {
|
|
446
|
+
if (interval) {
|
|
447
|
+
clearInterval(interval);
|
|
448
|
+
interval = null;
|
|
449
|
+
}
|
|
450
|
+
if (isRunning) {
|
|
451
|
+
// Clear the spinner line completely so native errors show on new line
|
|
452
|
+
clearLine();
|
|
453
|
+
isRunning = false;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function succeed(message?: string): void {
|
|
458
|
+
stop();
|
|
459
|
+
const text = message || currentMessage;
|
|
460
|
+
stream.write(`\x1b[32m${ICONS.success}\x1b[0m ${text}\n`);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function fail(message?: string): void {
|
|
464
|
+
stop();
|
|
465
|
+
const text = message || currentMessage;
|
|
466
|
+
stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${text}\n`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function warn(message?: string): void {
|
|
470
|
+
stop();
|
|
471
|
+
const text = message || currentMessage;
|
|
472
|
+
stream.write(`\x1b[33m${ICONS.warning}\x1b[0m ${text}\n`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Stop spinner and show native error output
|
|
477
|
+
*/
|
|
478
|
+
function nativeError(message?: string): void {
|
|
479
|
+
stop();
|
|
480
|
+
// Print a blank line first so native stderr output is visible
|
|
481
|
+
stream.write('\n');
|
|
482
|
+
if (message) {
|
|
483
|
+
stream.write(`\x1b[31m${ICONS.error}\x1b[0m ${message}\n`);
|
|
484
|
+
}
|
|
485
|
+
stream.write(`${COLORS.dim}(Native library error output above)${COLORS.reset}\n`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { start, update, succeed, fail, warn, stop, nativeError };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ============================================================================
|
|
492
|
+
// Result Reporter
|
|
493
|
+
// ============================================================================
|
|
494
|
+
|
|
495
|
+
export interface OperationResult {
|
|
496
|
+
ok: boolean;
|
|
497
|
+
message?: string;
|
|
498
|
+
details?: Record<string, unknown>;
|
|
499
|
+
warnings?: string[];
|
|
500
|
+
errors?: string[];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Print detailed operation result
|
|
505
|
+
*/
|
|
506
|
+
export function reportResult(
|
|
507
|
+
result: OperationResult,
|
|
508
|
+
stream: OutputStream = defaultStderr
|
|
509
|
+
): void {
|
|
510
|
+
if (result.ok) {
|
|
511
|
+
success(result.message || 'Operation completed successfully', stream);
|
|
512
|
+
} else {
|
|
513
|
+
error(result.message || 'Operation failed', stream);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Print details
|
|
517
|
+
if (result.details) {
|
|
518
|
+
for (const [key, value] of Object.entries(result.details)) {
|
|
519
|
+
keyValue(key, String(value), stream);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Print warnings
|
|
524
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
525
|
+
for (const w of result.warnings) {
|
|
526
|
+
warning(w, stream);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Print errors
|
|
531
|
+
if (result.errors && result.errors.length > 0) {
|
|
532
|
+
for (const e of result.errors) {
|
|
533
|
+
subItem(`${COLORS.red}${e}${COLORS.reset}`, 4, stream);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ============================================================================
|
|
539
|
+
// Helpers
|
|
540
|
+
// ============================================================================
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Format duration in human-readable form
|
|
544
|
+
*/
|
|
545
|
+
export function formatDuration(ms: number): string {
|
|
546
|
+
if (ms < 1000) return `${ms}ms`;
|
|
547
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
548
|
+
const minutes = Math.floor(ms / 60000);
|
|
549
|
+
const seconds = Math.floor((ms % 60000) / 1000);
|
|
550
|
+
return `${minutes}m ${seconds}s`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Format bytes
|
|
555
|
+
*/
|
|
556
|
+
export function formatBytes(bytes: number): string {
|
|
557
|
+
if (bytes === 0) return '0 B';
|
|
558
|
+
const k = 1024;
|
|
559
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
560
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
561
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Format count with singular/plural
|
|
566
|
+
*/
|
|
567
|
+
export function formatCount(count: number, singular: string, plural?: string): string {
|
|
568
|
+
return `${count} ${count === 1 ? singular : (plural || singular + 's')}`;
|
|
569
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize query strings for seekdb to prevent JSON parse errors.
|
|
3
|
+
*
|
|
4
|
+
* seekdb's internal parser has issues with certain characters:
|
|
5
|
+
* - Single quotes break JSON string parsing
|
|
6
|
+
* - Control characters may crash the native module
|
|
7
|
+
* - Special characters need proper escaping
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Sanitize a search query string for safe use with seekdb.
|
|
12
|
+
* Removes or replaces characters that cause parse errors.
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeQuery(query: string): string {
|
|
15
|
+
if (!query || typeof query !== 'string') {
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return query
|
|
20
|
+
// Remove problematic characters that break JSON parsing
|
|
21
|
+
.replace(/'/g, '') // Remove single quotes (main issue)
|
|
22
|
+
.replace(/"/g, '') // Remove double quotes
|
|
23
|
+
.replace(/\\/g, '') // Remove backslashes
|
|
24
|
+
.replace(/\x00/g, '') // Remove null bytes
|
|
25
|
+
|
|
26
|
+
// Replace control characters with spaces
|
|
27
|
+
.replace(/\n/g, ' ') // Replace newlines
|
|
28
|
+
.replace(/\r/g, ' ') // Replace carriage returns
|
|
29
|
+
.replace(/\t/g, ' ') // Replace tabs
|
|
30
|
+
|
|
31
|
+
// Collapse multiple spaces into one
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
|
|
34
|
+
// Trim leading/trailing whitespace
|
|
35
|
+
.trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fallback search using SQL LIKE when vector search fails.
|
|
40
|
+
* More robust but less accurate than vector search.
|
|
41
|
+
*/
|
|
42
|
+
export function safeSearchPattern(query: string): string {
|
|
43
|
+
const sanitized = sanitizeQuery(query);
|
|
44
|
+
|
|
45
|
+
// For SQL LIKE, we need to escape % and _ wildcards
|
|
46
|
+
return sanitized
|
|
47
|
+
.replace(/%/g, '\\%')
|
|
48
|
+
.replace(/_/g, '\\_');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validate that a query string is safe for seekdb operations.
|
|
53
|
+
* Returns true if the query is safe, false otherwise.
|
|
54
|
+
*/
|
|
55
|
+
export function isQuerySafe(query: string): boolean {
|
|
56
|
+
if (!query || query.length === 0) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check for characters that cause parse errors
|
|
61
|
+
const dangerousChars = /['"\\\x00]/;
|
|
62
|
+
return !dangerousChars.test(query);
|
|
63
|
+
}
|