claude-brain 0.22.4 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.22.4",
3
+ "version": "0.23.0",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -5,6 +5,7 @@ import { ClaudeBrainMCPServer } from '@/server'
5
5
  import { initializeServices, shutdownServices, getVaultService, getMemoryService } from '@/server/services'
6
6
  import { createOrchestrator, type Orchestrator } from '@/orchestrator'
7
7
  import { ensureHomeDirectory } from '@/cli/auto-setup'
8
+ import { ServerPidManager } from '@/server/pid-manager'
8
9
 
9
10
  const BANNER = `
10
11
  ╔═══════════════════════════════════════════════════════╗
@@ -17,6 +18,18 @@ export async function runServe() {
17
18
  // Auto-initialize home directory on first run
18
19
  ensureHomeDirectory()
19
20
 
21
+ // Singleton check: prevent multiple server instances
22
+ const pidManager = new ServerPidManager()
23
+ const existingPid = pidManager.getRunningPid()
24
+ if (existingPid) {
25
+ console.error(`[claude-brain] Server already running (PID: ${existingPid}). Exiting.`)
26
+ process.exit(0)
27
+ }
28
+
29
+ // Write PID file and register cleanup handlers
30
+ pidManager.writePidFile()
31
+ pidManager.registerCleanupHandlers()
32
+
20
33
  // Auto-install Claude Code hooks (idempotent, non-fatal)
21
34
  try {
22
35
  const { installHooks } = await import('@/hooks/installer')
@@ -118,6 +131,11 @@ export async function runServe() {
118
131
  await shutdownServices()
119
132
  })
120
133
 
134
+ // Clean up PID file during graceful shutdown
135
+ cleanup.register(async () => {
136
+ pidManager.cleanup()
137
+ })
138
+
121
139
  // Start HTTP API server alongside MCP server
122
140
  const { HttpApiServer } = await import('@/server/http-api')
123
141
  const httpServer = new HttpApiServer(config, logger)
@@ -211,7 +211,7 @@ export class PassiveClassifier {
211
211
  const packages = match[1]?.trim()
212
212
  if (packages) {
213
213
  return {
214
- type: 'decision',
214
+ type: 'progress',
215
215
  confidence: 0.85,
216
216
  content: `Installed package(s): ${packages}`,
217
217
  project: this.extractProjectFromCwd(input.cwd),
@@ -8,6 +8,8 @@ import { randomUUID } from 'crypto'
8
8
  import type { Database } from 'bun:sqlite'
9
9
  import type { Logger } from 'pino'
10
10
  import { expandQuery } from '@/retrieval/query/expander'
11
+ import { embeddingToBuffer, bufferToEmbedding, cosineSimilarity } from './embedding-utils'
12
+ import type { EmbeddingService } from './embeddings'
11
13
 
12
14
  export type ObservationCategory = 'decision' | 'pattern' | 'correction' | 'insight' | 'preference'
13
15
 
@@ -364,6 +366,153 @@ export class FTS5Search {
364
366
  return rows.map(row => this.rowToResult(row))
365
367
  }
366
368
 
369
+ // --- Embedding-based semantic search ---
370
+
371
+ /**
372
+ * Store an embedding for an observation.
373
+ * Called after storing an observation to enable semantic search.
374
+ */
375
+ storeEmbedding(observationId: string, embedding: number[]): void {
376
+ const buffer = embeddingToBuffer(embedding)
377
+ const now = new Date().toISOString()
378
+
379
+ try {
380
+ this.db.prepare(`
381
+ INSERT OR REPLACE INTO observation_embeddings (observation_id, embedding, created_at)
382
+ VALUES (?, ?, ?)
383
+ `).run(observationId, buffer, now)
384
+ } catch (error) {
385
+ this.logger.warn({ error, observationId }, 'Failed to store embedding')
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Delete an embedding for an observation.
391
+ */
392
+ deleteEmbedding(observationId: string): void {
393
+ try {
394
+ this.db.prepare('DELETE FROM observation_embeddings WHERE observation_id = ?').run(observationId)
395
+ } catch (error) {
396
+ this.logger.warn({ error, observationId }, 'Failed to delete embedding')
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Semantic search using stored embeddings.
402
+ * Generates a query embedding, then computes cosine similarity against all
403
+ * stored observation embeddings. Returns top matches above threshold.
404
+ */
405
+ async semanticSearch(
406
+ queryEmbedding: number[],
407
+ project?: string,
408
+ limit: number = 10,
409
+ minSimilarity: number = 0.3
410
+ ): Promise<ScoredResult[]> {
411
+ try {
412
+ // Load all embeddings with their observation IDs
413
+ let sql: string
414
+ const params: any[] = []
415
+
416
+ if (project) {
417
+ sql = `
418
+ SELECT oe.observation_id, oe.embedding, o.*
419
+ FROM observation_embeddings oe
420
+ JOIN observations o ON oe.observation_id = o.id
421
+ WHERE o.archived = 0 AND o.project = ?
422
+ `
423
+ params.push(project)
424
+ } else {
425
+ sql = `
426
+ SELECT oe.observation_id, oe.embedding, o.*
427
+ FROM observation_embeddings oe
428
+ JOIN observations o ON oe.observation_id = o.id
429
+ WHERE o.archived = 0
430
+ `
431
+ }
432
+
433
+ const rows = this.db.prepare(sql).all(...params) as any[]
434
+
435
+ if (rows.length === 0) {
436
+ this.logger.debug('No embeddings found for semantic search')
437
+ return []
438
+ }
439
+
440
+ // Compute cosine similarity for each stored embedding
441
+ const scored: { row: any; similarity: number }[] = []
442
+ for (const row of rows) {
443
+ try {
444
+ const storedEmbedding = bufferToEmbedding(row.embedding)
445
+ const similarity = cosineSimilarity(queryEmbedding, storedEmbedding)
446
+ if (similarity >= minSimilarity) {
447
+ scored.push({ row, similarity })
448
+ }
449
+ } catch {
450
+ // Skip invalid embeddings
451
+ }
452
+ }
453
+
454
+ // Sort by similarity descending and take top results
455
+ scored.sort((a, b) => b.similarity - a.similarity)
456
+ const topResults = scored.slice(0, limit)
457
+
458
+ return topResults.map(({ row, similarity }) => ({
459
+ ...this.rowToResult(row),
460
+ score: similarity
461
+ }))
462
+ } catch (error) {
463
+ this.logger.warn({ error }, 'Semantic search failed')
464
+ return []
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Check if there are any stored embeddings.
470
+ */
471
+ hasEmbeddings(): boolean {
472
+ try {
473
+ const row = this.db.prepare('SELECT COUNT(*) as count FROM observation_embeddings').get() as any
474
+ return (row?.count || 0) > 0
475
+ } catch {
476
+ return false
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Backfill embeddings for observations that don't have them yet.
482
+ * Used for migrating existing data.
483
+ */
484
+ async backfillEmbeddings(embeddingService: EmbeddingService, batchSize: number = 50): Promise<number> {
485
+ try {
486
+ const rows = this.db.prepare(`
487
+ SELECT o.id, o.content, o.reasoning, o.context
488
+ FROM observations o
489
+ LEFT JOIN observation_embeddings oe ON o.id = oe.observation_id
490
+ WHERE oe.observation_id IS NULL AND o.archived = 0
491
+ LIMIT ?
492
+ `).all(batchSize) as any[]
493
+
494
+ if (rows.length === 0) return 0
495
+
496
+ let count = 0
497
+ for (const row of rows) {
498
+ try {
499
+ const text = [row.content, row.reasoning, row.context].filter(Boolean).join(' ')
500
+ const embedding = await embeddingService.generateEmbedding(text)
501
+ this.storeEmbedding(row.id, embedding)
502
+ count++
503
+ } catch {
504
+ // Skip failures, continue with next
505
+ }
506
+ }
507
+
508
+ this.logger.info({ backfilled: count, total: rows.length }, 'Embedding backfill complete')
509
+ return count
510
+ } catch (error) {
511
+ this.logger.warn({ error }, 'Embedding backfill failed')
512
+ return 0
513
+ }
514
+ }
515
+
367
516
  // --- Private helpers ---
368
517
 
369
518
  /**
@@ -113,6 +113,17 @@ export class MemoryManager {
113
113
  addFTS5Tables(db)
114
114
  this._fts5 = new FTS5Search(db, this.logger)
115
115
  this.logger.info('FTS5 search initialized')
116
+
117
+ // Phase 26b: Backfill embeddings for existing observations (async, non-blocking)
118
+ if (this.embeddings.isReady()) {
119
+ this.backfillEmbeddings().then(count => {
120
+ if (count > 0) {
121
+ this.logger.info({ count }, 'Backfilled embeddings for existing observations')
122
+ }
123
+ }).catch(() => {
124
+ // Non-critical, log handled in backfillEmbeddings
125
+ })
126
+ }
116
127
  } catch (error) {
117
128
  this.logger.warn({ error }, 'Failed to initialize FTS5, continuing without it')
118
129
  }
@@ -169,6 +180,30 @@ export class MemoryManager {
169
180
  return this.initialized
170
181
  }
171
182
 
183
+ /**
184
+ * Generate and store an embedding for an observation (fire-and-forget).
185
+ * Runs asynchronously to avoid blocking the store operation.
186
+ */
187
+ private storeObservationEmbedding(observationId: string, text: string): void {
188
+ if (!this._fts5 || !this.embeddings.isReady()) return
189
+
190
+ // Fire-and-forget: don't block the store operation
191
+ this.embeddings.generateEmbedding(text).then(embedding => {
192
+ this._fts5?.storeEmbedding(observationId, embedding)
193
+ }).catch(error => {
194
+ this.logger.debug({ error, observationId }, 'Failed to store observation embedding')
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Backfill embeddings for existing observations that don't have them.
200
+ * Call this after initialization to migrate existing data.
201
+ */
202
+ async backfillEmbeddings(batchSize: number = 50): Promise<number> {
203
+ if (!this._fts5 || !this.embeddings.isReady()) return 0
204
+ return this._fts5.backfillEmbeddings(this.embeddings, batchSize)
205
+ }
206
+
172
207
  close(): void {
173
208
  if (this.useChromaDB) {
174
209
  this.chroma.close()
@@ -267,6 +302,9 @@ export class MemoryManager {
267
302
  context,
268
303
  tags: options?.tags
269
304
  }, sharedId)
305
+
306
+ // Store embedding for semantic search
307
+ this.storeObservationEmbedding(fts5Id, [decision, reasoning, context].filter(Boolean).join(' '))
270
308
  } catch (error) {
271
309
  this.logger.warn({ error }, 'FTS5 store failed, continuing with other backends')
272
310
  }
@@ -314,7 +352,7 @@ export class MemoryManager {
314
352
  }
315
353
 
316
354
  /**
317
- * Get raw search results - uses FTS5 as primary, ChromaDB as enrichment
355
+ * Get raw search results - uses FTS5 as primary, embeddings as semantic fallback
318
356
  * Use this for internal operations that need raw results
319
357
  */
320
358
  async searchRaw(
@@ -322,52 +360,52 @@ export class MemoryManager {
322
360
  options?: { project?: string; limit?: number; minSimilarity?: number }
323
361
  ): Promise<any[]> {
324
362
  const limit = options?.limit || 5
363
+ const LOW_CONFIDENCE_THRESHOLD = 0.4
325
364
 
326
365
  // Phase 26: Try FTS5 first (always available)
327
366
  if (this._fts5) {
328
367
  const ftsResults = this._fts5.searchWithConfidence(query, options?.project, limit)
329
368
 
330
- if (ftsResults.length > 0) {
331
- // Transform FTS5 results to match expected MemorySearchResult structure
332
- const results = ftsResults.map(r => ({
333
- id: r.id,
334
- content: r.content,
335
- memory: {
336
- id: r.id,
337
- project: r.project,
338
- content: r.content,
339
- createdAt: new Date(r.created_at),
340
- metadata: {
341
- project: r.project,
342
- category: r.category,
343
- context: r.context || '',
344
- reasoning: r.reasoning || '',
345
- tags: r.tags,
346
- created_at: r.created_at
369
+ // Check if FTS5 returned good results
370
+ const hasGoodResults = ftsResults.length > 0 &&
371
+ ftsResults.some(r => r.score >= LOW_CONFIDENCE_THRESHOLD)
372
+
373
+ if (hasGoodResults) {
374
+ return this.transformFtsResults(ftsResults)
375
+ }
376
+
377
+ // Phase 26b: FTS5 returned nothing or low-confidence — try semantic search
378
+ if (this.embeddings.isReady() && this._fts5.hasEmbeddings()) {
379
+ try {
380
+ const queryEmbedding = await this.embeddings.generateEmbedding(query)
381
+ const semanticResults = await this._fts5.semanticSearch(
382
+ queryEmbedding,
383
+ options?.project,
384
+ limit,
385
+ options?.minSimilarity || 0.3
386
+ )
387
+
388
+ if (semanticResults.length > 0) {
389
+ this.logger.debug(
390
+ { query, ftsCount: ftsResults.length, semanticCount: semanticResults.length },
391
+ 'Semantic search found results where FTS5 did not'
392
+ )
393
+
394
+ // If we had some low-confidence FTS5 results, merge with semantic results
395
+ if (ftsResults.length > 0) {
396
+ return this.mergeSearchResults(ftsResults, semanticResults, limit)
347
397
  }
348
- },
349
- similarity: r.score,
350
- decision: r.category === 'decision' ? {
351
- id: r.id,
352
- project: r.project,
353
- context: r.context || '',
354
- decision: r.content,
355
- reasoning: r.reasoning || '',
356
- alternatives: '',
357
- tags: r.tags,
358
- createdAt: new Date(r.created_at)
359
- } : undefined,
360
- metadata: {
361
- project: r.project,
362
- category: r.category,
363
- context: r.context || '',
364
- reasoning: r.reasoning || '',
365
- tags: r.tags,
366
- created_at: r.created_at
398
+
399
+ return this.transformFtsResults(semanticResults)
367
400
  }
368
- }))
401
+ } catch (error) {
402
+ this.logger.warn({ error }, 'Semantic search fallback failed')
403
+ }
404
+ }
369
405
 
370
- return results
406
+ // Return whatever FTS5 found (even if low confidence)
407
+ if (ftsResults.length > 0) {
408
+ return this.transformFtsResults(ftsResults)
371
409
  }
372
410
  }
373
411
 
@@ -417,6 +455,82 @@ export class MemoryManager {
417
455
  })
418
456
  }
419
457
 
458
+ /**
459
+ * Transform FTS5/semantic ScoredResults to the expected MemorySearchResult structure
460
+ */
461
+ private transformFtsResults(results: import('./fts5-search').ScoredResult[]): any[] {
462
+ return results.map(r => ({
463
+ id: r.id,
464
+ content: r.content,
465
+ memory: {
466
+ id: r.id,
467
+ project: r.project,
468
+ content: r.content,
469
+ createdAt: new Date(r.created_at),
470
+ metadata: {
471
+ project: r.project,
472
+ category: r.category,
473
+ context: r.context || '',
474
+ reasoning: r.reasoning || '',
475
+ tags: r.tags,
476
+ created_at: r.created_at
477
+ }
478
+ },
479
+ similarity: r.score,
480
+ decision: r.category === 'decision' ? {
481
+ id: r.id,
482
+ project: r.project,
483
+ context: r.context || '',
484
+ decision: r.content,
485
+ reasoning: r.reasoning || '',
486
+ alternatives: '',
487
+ tags: r.tags,
488
+ createdAt: new Date(r.created_at)
489
+ } : undefined,
490
+ metadata: {
491
+ project: r.project,
492
+ category: r.category,
493
+ context: r.context || '',
494
+ reasoning: r.reasoning || '',
495
+ tags: r.tags,
496
+ created_at: r.created_at
497
+ }
498
+ }))
499
+ }
500
+
501
+ /**
502
+ * Merge FTS5 and semantic search results, deduplicating by ID.
503
+ * Semantic results get a small boost since they matched conceptually.
504
+ */
505
+ private mergeSearchResults(
506
+ ftsResults: import('./fts5-search').ScoredResult[],
507
+ semanticResults: import('./fts5-search').ScoredResult[],
508
+ limit: number
509
+ ): any[] {
510
+ const seen = new Set<string>()
511
+ const merged: import('./fts5-search').ScoredResult[] = []
512
+
513
+ // Add semantic results first (they matched conceptually when FTS5 didn't)
514
+ for (const r of semanticResults) {
515
+ if (!seen.has(r.id)) {
516
+ seen.add(r.id)
517
+ merged.push(r)
518
+ }
519
+ }
520
+
521
+ // Then add FTS5 results that aren't already included
522
+ for (const r of ftsResults) {
523
+ if (!seen.has(r.id)) {
524
+ seen.add(r.id)
525
+ merged.push(r)
526
+ }
527
+ }
528
+
529
+ // Sort by score descending and limit
530
+ merged.sort((a, b) => b.score - a.score)
531
+ return this.transformFtsResults(merged.slice(0, limit))
532
+ }
533
+
420
534
  async recallSimilar(
421
535
  query: string,
422
536
  options?: { project?: string; limit?: number; minSimilarity?: number }
@@ -476,6 +590,9 @@ export class MemoryManager {
476
590
  confidence: input.confidence,
477
591
  source: input.source
478
592
  }, sharedId)
593
+
594
+ // Store embedding for semantic search
595
+ this.storeObservationEmbedding(fts5Id, [input.description, input.context].filter(Boolean).join(' '))
479
596
  } catch (error) {
480
597
  this.logger.warn({ error }, 'FTS5 pattern store failed')
481
598
  }
@@ -523,6 +640,9 @@ export class MemoryManager {
523
640
  context: input.context,
524
641
  confidence: input.confidence
525
642
  }, sharedId)
643
+
644
+ // Store embedding for semantic search
645
+ this.storeObservationEmbedding(fts5Id, [input.correction, input.reasoning, input.context].filter(Boolean).join(' '))
526
646
  } catch (error) {
527
647
  this.logger.warn({ error }, 'FTS5 correction store failed')
528
648
  }
@@ -846,13 +966,14 @@ export class MemoryManager {
846
966
  }
847
967
 
848
968
  /**
849
- * Delete a decision by ID — removes from FTS5 + ChromaDB/SQLite
969
+ * Delete a decision by ID — removes from FTS5 + embeddings + ChromaDB/SQLite
850
970
  */
851
971
  async deleteDecision(id: string): Promise<void> {
852
- // Phase 26: Delete from FTS5
972
+ // Phase 26: Delete from FTS5 and embeddings
853
973
  if (this._fts5) {
854
974
  try {
855
975
  this._fts5.delete(id)
976
+ this._fts5.deleteEmbedding(id)
856
977
  } catch (error) {
857
978
  this.logger.warn({ error, id }, 'FTS5 delete failed')
858
979
  }
@@ -95,4 +95,14 @@ export function addFTS5Tables(db: Database): void {
95
95
  PRIMARY KEY (source_id, target_id)
96
96
  )
97
97
  `)
98
+
99
+ // Observation embeddings table for semantic search (Phase 26b)
100
+ db.run(`
101
+ CREATE TABLE IF NOT EXISTS observation_embeddings (
102
+ observation_id TEXT PRIMARY KEY,
103
+ embedding BLOB NOT NULL,
104
+ created_at TEXT NOT NULL,
105
+ FOREIGN KEY (observation_id) REFERENCES observations(id) ON DELETE CASCADE
106
+ )
107
+ `)
98
108
  }
@@ -69,6 +69,8 @@ const STORE_PHRASES = [
69
69
  'i learned that',
70
70
  // Phase 25: Session summary storage
71
71
  'session summary:', 'session summary ',
72
+ // Explicit prefix signal for preference storage
73
+ 'preference:', 'my preference:',
72
74
  ]
73
75
 
74
76
  const REASONING_PHRASES = [
@@ -88,6 +90,8 @@ const MISTAKE_PHRASES = [
88
90
  'turns out that',
89
91
  // BUG-006: "Mistake:" prefix (colon form, e.g. "Mistake: I was using...")
90
92
  'mistake:',
93
+ // Explicit prefix signals for category classification
94
+ 'lesson:', 'lesson learned:',
91
95
  ]
92
96
 
93
97
  // Progress indicators
@@ -404,7 +408,9 @@ export class IntentClassifier {
404
408
 
405
409
  private isPatternFound(lower: string): boolean {
406
410
  const hasPattern = PATTERN_PHRASES.some(p => lower.includes(p))
407
- return hasPattern && lower.length > 50
411
+ // Explicit prefix signals (e.g. "Pattern: ...") need shorter minimum length
412
+ const hasExplicitPrefix = lower.startsWith('pattern:') || lower.startsWith('best practice:') || lower.startsWith('anti-pattern:')
413
+ return hasPattern && (hasExplicitPrefix ? lower.length > 20 : lower.length > 50)
408
414
  }
409
415
 
410
416
  /**
@@ -1065,8 +1065,17 @@ export class HttpApiServer {
1065
1065
  port,
1066
1066
  url: `http://localhost:${port}`,
1067
1067
  }, 'HTTP API server started successfully')
1068
- } catch (error) {
1069
- this.logger.error({ error }, 'Failed to start HTTP API server')
1068
+ } catch (error: any) {
1069
+ const code = error?.code || error?.message || ''
1070
+ if (String(code).includes('EADDRINUSE') || String(error?.message).includes('EADDRINUSE')) {
1071
+ this.logger.error(
1072
+ { port },
1073
+ `Port ${port} is already in use. Another claude-brain instance may be running. ` +
1074
+ `Kill existing instances with: pkill -f "claude-brain serve"`
1075
+ )
1076
+ } else {
1077
+ this.logger.error({ error }, 'Failed to start HTTP API server')
1078
+ }
1070
1079
  throw error
1071
1080
  }
1072
1081
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * PID file manager for the Claude Brain server.
3
+ * Ensures only one server instance runs at a time.
4
+ */
5
+
6
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
7
+ import { join } from 'node:path'
8
+ import { getHomePaths } from '@/config/home'
9
+
10
+ const PID_FILENAME = 'server.pid'
11
+
12
+ export class ServerPidManager {
13
+ private pidFilePath: string
14
+
15
+ constructor() {
16
+ const paths = getHomePaths()
17
+ this.pidFilePath = join(paths.data, PID_FILENAME)
18
+ }
19
+
20
+ /** Check if another server instance is already running. Returns the PID if alive, null otherwise. */
21
+ getRunningPid(): number | null {
22
+ if (!existsSync(this.pidFilePath)) return null
23
+
24
+ try {
25
+ const pid = parseInt(readFileSync(this.pidFilePath, 'utf-8').trim(), 10)
26
+ if (isNaN(pid)) {
27
+ this.cleanup()
28
+ return null
29
+ }
30
+ // Signal 0 tests if process exists without killing it
31
+ process.kill(pid, 0)
32
+ return pid
33
+ } catch {
34
+ // Process not running, clean up stale PID file
35
+ this.cleanup()
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** Write the current process PID to the PID file. */
41
+ writePidFile(): void {
42
+ writeFileSync(this.pidFilePath, String(process.pid), 'utf-8')
43
+ }
44
+
45
+ /** Remove the PID file. Safe to call multiple times. */
46
+ cleanup(): void {
47
+ try {
48
+ unlinkSync(this.pidFilePath)
49
+ } catch {
50
+ // Already removed or doesn't exist
51
+ }
52
+ }
53
+
54
+ /** Register cleanup handlers on SIGINT, SIGTERM, and process exit. */
55
+ registerCleanupHandlers(): void {
56
+ const doCleanup = () => {
57
+ this.cleanup()
58
+ }
59
+
60
+ process.on('exit', doCleanup)
61
+ process.on('SIGINT', doCleanup)
62
+ process.on('SIGTERM', doCleanup)
63
+ }
64
+ }