claude-brain 0.17.13 → 0.22.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.
Files changed (46) hide show
  1. package/VERSION +1 -1
  2. package/package.json +3 -1
  3. package/scripts/postinstall.mjs +80 -104
  4. package/src/cli/auto-setup.ts +1 -9
  5. package/src/cli/bin.ts +23 -2
  6. package/src/cli/commands/export.ts +130 -0
  7. package/src/cli/commands/reindex.ts +107 -0
  8. package/src/cli/commands/serve.ts +54 -0
  9. package/src/cli/commands/status.ts +158 -0
  10. package/src/code-intelligence/indexer.ts +315 -0
  11. package/src/code-intelligence/linker.ts +178 -0
  12. package/src/code-intelligence/parser.ts +484 -0
  13. package/src/code-intelligence/query.ts +291 -0
  14. package/src/code-intelligence/schema.ts +83 -0
  15. package/src/code-intelligence/types.ts +95 -0
  16. package/src/config/defaults.ts +3 -3
  17. package/src/config/loader.ts +6 -0
  18. package/src/config/schema.ts +28 -2
  19. package/src/health/index.ts +5 -2
  20. package/src/hooks/brain-hook.ts +4 -1
  21. package/src/hooks/context-hook.ts +69 -10
  22. package/src/hooks/installer.ts +4 -7
  23. package/src/intelligence/cross-project/index.ts +1 -7
  24. package/src/intelligence/prediction/index.ts +1 -7
  25. package/src/intelligence/reasoning/index.ts +1 -7
  26. package/src/memory/compression.ts +105 -0
  27. package/src/memory/fts5-search.ts +456 -0
  28. package/src/memory/index.ts +342 -38
  29. package/src/memory/migrations/add-fts5.ts +98 -0
  30. package/src/memory/pruning.ts +60 -0
  31. package/src/routing/intent-classifier.ts +58 -1
  32. package/src/routing/response-filter.ts +128 -0
  33. package/src/routing/router.ts +457 -54
  34. package/src/server/http-api.ts +319 -1
  35. package/src/server/providers/resources.ts +1 -42
  36. package/src/server/services.ts +113 -12
  37. package/src/server/web-viewer.ts +1115 -0
  38. package/src/setup/index.ts +12 -22
  39. package/src/tools/schemas.ts +1 -1
  40. package/src/intelligence/cross-project/affinity.ts +0 -159
  41. package/src/intelligence/cross-project/transfer.ts +0 -201
  42. package/src/intelligence/prediction/context-anticipator.ts +0 -198
  43. package/src/intelligence/prediction/decision-predictor.ts +0 -184
  44. package/src/intelligence/reasoning/counterfactual.ts +0 -248
  45. package/src/intelligence/reasoning/synthesizer.ts +0 -167
  46. package/src/setup/wizard.ts +0 -459
@@ -0,0 +1,456 @@
1
+ /**
2
+ * FTS5 Search Service — Phase 26
3
+ * Full-text search over the observations table using SQLite FTS5.
4
+ * Replaces ChromaDB as the primary search backend.
5
+ */
6
+
7
+ import { randomUUID } from 'crypto'
8
+ import type { Database } from 'bun:sqlite'
9
+ import type { Logger } from 'pino'
10
+
11
+ export type ObservationCategory = 'decision' | 'pattern' | 'correction' | 'insight' | 'preference'
12
+
13
+ export interface NewObservation {
14
+ project: string
15
+ category: ObservationCategory
16
+ content: string
17
+ reasoning?: string
18
+ context?: string
19
+ confidence?: number
20
+ source?: string
21
+ tags?: string[]
22
+ file_paths?: string[]
23
+ symbols?: string[]
24
+ }
25
+
26
+ export interface ObservationResult {
27
+ id: string
28
+ project: string
29
+ category: ObservationCategory
30
+ content: string
31
+ reasoning: string | null
32
+ context: string | null
33
+ confidence: number
34
+ source: string
35
+ tags: string[]
36
+ file_paths: string[]
37
+ symbols: string[]
38
+ access_count: number
39
+ last_accessed: string | null
40
+ created_at: string
41
+ updated_at: string
42
+ archived: boolean
43
+ }
44
+
45
+ export interface ScoredResult extends ObservationResult {
46
+ score: number
47
+ }
48
+
49
+ export interface DuplicateResult {
50
+ id: string
51
+ content: string
52
+ score: number
53
+ }
54
+
55
+ export class FTS5Search {
56
+ private db: Database
57
+ private logger: Logger
58
+
59
+ constructor(db: Database, logger: Logger) {
60
+ this.db = db
61
+ this.logger = logger.child({ component: 'fts5-search' })
62
+ }
63
+
64
+ /**
65
+ * Search observations via FTS5 full-text search.
66
+ */
67
+ search(query: string, project?: string, limit: number = 10): ObservationResult[] {
68
+ if (!query.trim()) return []
69
+
70
+ const ftsQuery = this.buildFTSQuery(query)
71
+
72
+ try {
73
+ let sql: string
74
+ const params: any[] = []
75
+
76
+ if (project) {
77
+ sql = `
78
+ SELECT o.*, rank
79
+ FROM observations o
80
+ JOIN observations_fts fts ON o.rowid = fts.rowid
81
+ WHERE observations_fts MATCH ? AND o.project = ? AND o.archived = 0
82
+ ORDER BY rank
83
+ LIMIT ?
84
+ `
85
+ params.push(ftsQuery, project, limit)
86
+ } else {
87
+ sql = `
88
+ SELECT o.*, rank
89
+ FROM observations o
90
+ JOIN observations_fts fts ON o.rowid = fts.rowid
91
+ WHERE observations_fts MATCH ? AND o.archived = 0
92
+ ORDER BY rank
93
+ LIMIT ?
94
+ `
95
+ params.push(ftsQuery, limit)
96
+ }
97
+
98
+ const rows = this.db.prepare(sql).all(...params) as any[]
99
+ return rows.map(row => this.rowToResult(row))
100
+ } catch (error) {
101
+ this.logger.warn({ error, query, ftsQuery }, 'FTS5 search failed, trying fallback')
102
+ return this.fallbackSearch(query, project, limit)
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Search with BM25 ranking and confidence scoring.
108
+ * Returns results with a normalized score between 0 and 1.
109
+ */
110
+ searchWithConfidence(query: string, project?: string, limit: number = 10): ScoredResult[] {
111
+ if (!query.trim()) return []
112
+
113
+ const ftsQuery = this.buildFTSQuery(query)
114
+ const queryLower = query.toLowerCase()
115
+
116
+ try {
117
+ let sql: string
118
+ const params: any[] = []
119
+
120
+ if (project) {
121
+ sql = `
122
+ SELECT o.*, bm25(observations_fts) as bm25_score
123
+ FROM observations o
124
+ JOIN observations_fts fts ON o.rowid = fts.rowid
125
+ WHERE observations_fts MATCH ? AND o.project = ? AND o.archived = 0
126
+ ORDER BY bm25_score
127
+ LIMIT ?
128
+ `
129
+ params.push(ftsQuery, project, limit)
130
+ } else {
131
+ sql = `
132
+ SELECT o.*, bm25(observations_fts) as bm25_score
133
+ FROM observations o
134
+ JOIN observations_fts fts ON o.rowid = fts.rowid
135
+ WHERE observations_fts MATCH ? AND o.archived = 0
136
+ ORDER BY bm25_score
137
+ LIMIT ?
138
+ `
139
+ params.push(ftsQuery, limit)
140
+ }
141
+
142
+ const rows = this.db.prepare(sql).all(...params) as any[]
143
+
144
+ return rows.map(row => {
145
+ const result = this.rowToResult(row)
146
+ const bm25 = Math.abs(row.bm25_score as number)
147
+
148
+ // Compute confidence score
149
+ let score = this.normalizeBM25(bm25)
150
+
151
+ // Boost for exact content match
152
+ if (result.content.toLowerCase().includes(queryLower)) {
153
+ score = Math.min(1.0, score + 0.15)
154
+ }
155
+
156
+ // Boost for tag match
157
+ if (result.tags.some(t => queryLower.includes(t.toLowerCase()))) {
158
+ score = Math.min(1.0, score + 0.1)
159
+ }
160
+
161
+ // Boost for project match
162
+ if (project && result.project === project) {
163
+ score = Math.min(1.0, score + 0.05)
164
+ }
165
+
166
+ return { ...result, score }
167
+ }).sort((a, b) => b.score - a.score)
168
+ } catch (error) {
169
+ this.logger.warn({ error, query, ftsQuery }, 'FTS5 confidence search failed, trying fallback')
170
+ return this.fallbackSearch(query, project, limit).map(r => ({ ...r, score: 0.5 }))
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Store a new observation. Returns the generated ID.
176
+ */
177
+ store(observation: NewObservation): string {
178
+ const id = randomUUID()
179
+ const now = new Date().toISOString()
180
+
181
+ const stmt = this.db.prepare(`
182
+ INSERT INTO observations (id, project, category, content, reasoning, context, confidence, source, tags, file_paths, symbols, access_count, last_accessed, created_at, updated_at, archived)
183
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, 0)
184
+ `)
185
+
186
+ stmt.run(
187
+ id,
188
+ observation.project,
189
+ observation.category,
190
+ observation.content,
191
+ observation.reasoning || null,
192
+ observation.context || null,
193
+ observation.confidence ?? 0.8,
194
+ observation.source || 'explicit',
195
+ observation.tags ? JSON.stringify(observation.tags) : null,
196
+ observation.file_paths ? JSON.stringify(observation.file_paths) : null,
197
+ observation.symbols ? JSON.stringify(observation.symbols) : null,
198
+ now,
199
+ now
200
+ )
201
+
202
+ this.logger.debug({ id, category: observation.category, project: observation.project }, 'Observation stored')
203
+ return id
204
+ }
205
+
206
+ /**
207
+ * Check for duplicates via FTS5 text similarity.
208
+ * Returns the best matching duplicate if above threshold, null otherwise.
209
+ */
210
+ searchForDuplicates(content: string, project: string, threshold: number = 0.85): DuplicateResult | null {
211
+ const ftsQuery = this.buildFTSQuery(content)
212
+ if (!ftsQuery.trim()) return null
213
+
214
+ try {
215
+ const rows = this.db.prepare(`
216
+ SELECT o.id, o.content, bm25(observations_fts) as bm25_score
217
+ FROM observations o
218
+ JOIN observations_fts fts ON o.rowid = fts.rowid
219
+ WHERE observations_fts MATCH ? AND o.project = ? AND o.archived = 0
220
+ ORDER BY bm25_score
221
+ LIMIT 3
222
+ `).all(ftsQuery, project) as any[]
223
+
224
+ if (rows.length === 0) return null
225
+
226
+ // Use word overlap as a proxy for semantic similarity
227
+ for (const row of rows) {
228
+ const similarity = this.wordOverlap(content, row.content as string)
229
+ if (similarity >= threshold) {
230
+ return {
231
+ id: row.id as string,
232
+ content: row.content as string,
233
+ score: similarity
234
+ }
235
+ }
236
+ }
237
+
238
+ return null
239
+ } catch (error) {
240
+ this.logger.warn({ error }, 'Duplicate check failed')
241
+ return null
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Fetch all observations for a project, optionally filtered by category.
247
+ */
248
+ fetchAll(project: string, category?: ObservationCategory): ObservationResult[] {
249
+ let sql: string
250
+ const params: any[] = [project]
251
+
252
+ if (category) {
253
+ sql = `SELECT * FROM observations WHERE project = ? AND category = ? AND archived = 0 ORDER BY created_at DESC`
254
+ params.push(category)
255
+ } else {
256
+ sql = `SELECT * FROM observations WHERE project = ? AND archived = 0 ORDER BY created_at DESC`
257
+ }
258
+
259
+ const rows = this.db.prepare(sql).all(...params) as any[]
260
+ return rows.map(row => this.rowToResult(row))
261
+ }
262
+
263
+ /**
264
+ * Update an observation's fields.
265
+ */
266
+ update(id: string, updates: Partial<NewObservation>): void {
267
+ const fields: string[] = []
268
+ const values: any[] = []
269
+
270
+ if (updates.content !== undefined) { fields.push('content = ?'); values.push(updates.content) }
271
+ if (updates.reasoning !== undefined) { fields.push('reasoning = ?'); values.push(updates.reasoning) }
272
+ if (updates.context !== undefined) { fields.push('context = ?'); values.push(updates.context) }
273
+ if (updates.confidence !== undefined) { fields.push('confidence = ?'); values.push(updates.confidence) }
274
+ if (updates.source !== undefined) { fields.push('source = ?'); values.push(updates.source) }
275
+ if (updates.tags !== undefined) { fields.push('tags = ?'); values.push(JSON.stringify(updates.tags)) }
276
+ if (updates.file_paths !== undefined) { fields.push('file_paths = ?'); values.push(JSON.stringify(updates.file_paths)) }
277
+ if (updates.symbols !== undefined) { fields.push('symbols = ?'); values.push(JSON.stringify(updates.symbols)) }
278
+
279
+ if (fields.length === 0) return
280
+
281
+ fields.push('updated_at = ?')
282
+ values.push(new Date().toISOString())
283
+ values.push(id)
284
+
285
+ this.db.prepare(`UPDATE observations SET ${fields.join(', ')} WHERE id = ?`).run(...values)
286
+ }
287
+
288
+ /**
289
+ * Delete an observation by ID.
290
+ */
291
+ delete(id: string): void {
292
+ this.db.prepare('DELETE FROM observations WHERE id = ?').run(id)
293
+ }
294
+
295
+ /**
296
+ * Record an access to bump access_count and last_accessed.
297
+ */
298
+ recordAccess(id: string): void {
299
+ this.db.prepare(`
300
+ UPDATE observations SET access_count = access_count + 1, last_accessed = ? WHERE id = ?
301
+ `).run(new Date().toISOString(), id)
302
+ }
303
+
304
+ /**
305
+ * Get a single observation by ID.
306
+ */
307
+ getById(id: string): ObservationResult | null {
308
+ const row = this.db.prepare('SELECT * FROM observations WHERE id = ?').get(id) as any
309
+ if (!row) return null
310
+ return this.rowToResult(row)
311
+ }
312
+
313
+ /**
314
+ * Phase 27: Fetch observations in a date range for a project.
315
+ * Ordered by created_at descending (most recent first).
316
+ */
317
+ fetchByTimeRange(project: string, start: Date, end: Date, limit: number = 50): ObservationResult[] {
318
+ const startStr = start.toISOString()
319
+ const endStr = end.toISOString()
320
+
321
+ const rows = this.db.prepare(`
322
+ SELECT * FROM observations
323
+ WHERE project = ? AND archived = 0
324
+ AND created_at >= ? AND created_at <= ?
325
+ ORDER BY created_at DESC
326
+ LIMIT ?
327
+ `).all(project, startStr, endStr, limit) as any[]
328
+
329
+ return rows.map(row => this.rowToResult(row))
330
+ }
331
+
332
+ /**
333
+ * Phase 27: Increment access count and update last_accessed timestamp.
334
+ * Alias for recordAccess for API consistency.
335
+ */
336
+ incrementAccess(id: string): void {
337
+ this.recordAccess(id)
338
+ }
339
+
340
+ // --- Private helpers ---
341
+
342
+ /**
343
+ * Build an FTS5 query from a natural language query.
344
+ * Tokenizes words and joins with OR for broad matching.
345
+ */
346
+ private buildFTSQuery(query: string): string {
347
+ // Remove special FTS5 characters that could cause syntax errors
348
+ const cleaned = query.replace(/[":*^~(){}[\]]/g, ' ').trim()
349
+ if (!cleaned) return ''
350
+
351
+ // Split into words, filter short/stop words
352
+ const words = cleaned.split(/\s+/).filter(w => w.length >= 2)
353
+ if (words.length === 0) return ''
354
+
355
+ // Join with OR for broad matching
356
+ return words.map(w => `"${w}"`).join(' OR ')
357
+ }
358
+
359
+ /**
360
+ * Normalize BM25 score to 0-1 range.
361
+ * BM25 returns negative scores in SQLite (lower = better match).
362
+ * Typical range: -20 (excellent) to 0 (poor).
363
+ */
364
+ private normalizeBM25(score: number): number {
365
+ // Map typical BM25 range to 0.5-0.85
366
+ const normalized = Math.min(1, Math.max(0, score / 20))
367
+ return 0.5 + normalized * 0.35
368
+ }
369
+
370
+ /**
371
+ * Compute word overlap between two strings as a similarity proxy.
372
+ * Returns a value between 0 and 1.
373
+ */
374
+ private wordOverlap(a: string, b: string): number {
375
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length >= 3))
376
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length >= 3))
377
+
378
+ if (wordsA.size === 0 || wordsB.size === 0) return 0
379
+
380
+ let overlap = 0
381
+ for (const word of wordsA) {
382
+ if (wordsB.has(word)) overlap++
383
+ }
384
+
385
+ // Jaccard similarity
386
+ const union = new Set([...wordsA, ...wordsB]).size
387
+ return overlap / union
388
+ }
389
+
390
+ /**
391
+ * Fallback LIKE-based search when FTS5 query syntax fails.
392
+ */
393
+ private fallbackSearch(query: string, project?: string, limit: number = 10): ObservationResult[] {
394
+ const pattern = `%${query}%`
395
+
396
+ let sql: string
397
+ const params: any[] = []
398
+
399
+ if (project) {
400
+ sql = `
401
+ SELECT * FROM observations
402
+ WHERE (content LIKE ? OR reasoning LIKE ? OR context LIKE ? OR tags LIKE ?)
403
+ AND project = ? AND archived = 0
404
+ ORDER BY created_at DESC
405
+ LIMIT ?
406
+ `
407
+ params.push(pattern, pattern, pattern, pattern, project, limit)
408
+ } else {
409
+ sql = `
410
+ SELECT * FROM observations
411
+ WHERE (content LIKE ? OR reasoning LIKE ? OR context LIKE ? OR tags LIKE ?)
412
+ AND archived = 0
413
+ ORDER BY created_at DESC
414
+ LIMIT ?
415
+ `
416
+ params.push(pattern, pattern, pattern, pattern, limit)
417
+ }
418
+
419
+ const rows = this.db.prepare(sql).all(...params) as any[]
420
+ return rows.map(row => this.rowToResult(row))
421
+ }
422
+
423
+ /**
424
+ * Convert a raw database row to an ObservationResult.
425
+ */
426
+ private rowToResult(row: any): ObservationResult {
427
+ return {
428
+ id: row.id,
429
+ project: row.project,
430
+ category: row.category,
431
+ content: row.content,
432
+ reasoning: row.reasoning || null,
433
+ context: row.context || null,
434
+ confidence: row.confidence ?? 0.8,
435
+ source: row.source || 'explicit',
436
+ tags: row.tags ? this.parseJsonArray(row.tags) : [],
437
+ file_paths: row.file_paths ? this.parseJsonArray(row.file_paths) : [],
438
+ symbols: row.symbols ? this.parseJsonArray(row.symbols) : [],
439
+ access_count: row.access_count ?? 0,
440
+ last_accessed: row.last_accessed || null,
441
+ created_at: row.created_at,
442
+ updated_at: row.updated_at,
443
+ archived: row.archived === 1
444
+ }
445
+ }
446
+
447
+ private parseJsonArray(value: string): string[] {
448
+ try {
449
+ const parsed = JSON.parse(value)
450
+ return Array.isArray(parsed) ? parsed : []
451
+ } catch {
452
+ // Handle comma-separated strings as fallback
453
+ return value.split(',').map(s => s.trim()).filter(Boolean)
454
+ }
455
+ }
456
+ }