ex-brain 0.1.1 → 0.2.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/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
  // ---------------------------------------------------------------------------
@@ -26,6 +26,7 @@ export interface TimelineEntry {
26
26
  source: string;
27
27
  summary: string;
28
28
  detail: string;
29
+ importance?: number;
29
30
  }
30
31
 
31
32
  export interface SearchHit {
@@ -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
+ }