claude-brain 0.22.3 → 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/VERSION CHANGED
@@ -1 +1 @@
1
- 0.22.3
1
+ 0.22.4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.22.3",
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
  /**
@@ -15,6 +15,7 @@ export interface FilterableResult {
15
15
  }
16
16
 
17
17
  export interface FilteredResult {
18
+ id?: string
18
19
  content: string
19
20
  score: number
20
21
  source: string
@@ -74,8 +75,9 @@ export class ResponseFilter {
74
75
  // 5. Limit to 5 results
75
76
  filtered = filtered.slice(0, 5)
76
77
 
77
- // 6. Add one-line relevance explanation per result
78
+ // 6. Add one-line relevance explanation per result (preserve id from metadata)
78
79
  return filtered.map(r => ({
80
+ id: (r.metadata as any)?.id || (r.metadata as any)?.decision_id,
79
81
  content: r.content,
80
82
  score: r.score,
81
83
  source: r.source,
@@ -39,6 +39,7 @@ const UPDATE_MIN_SIMILARITY = 0.3
39
39
  export interface BrainInput {
40
40
  message: string
41
41
  project?: string
42
+ action?: 'auto' | 'store' | 'recall' | 'update' | 'delete'
42
43
  }
43
44
 
44
45
  /** Recently stored decision for recency fast path (Bug 4 fix) */
@@ -84,17 +85,44 @@ export class BrainRouter {
84
85
  }
85
86
 
86
87
  async route(input: BrainInput): Promise<BrainResponse> {
87
- const { message, project: inputProject } = input
88
+ const { message, project: inputProject, action } = input
88
89
 
89
- // Classify intent
90
- const classification = this.classifier.classify(message)
91
- this.logger.debug({ intent: classification.primary, confidence: classification.confidence }, 'Intent classified')
92
-
93
- // Extract entities
90
+ // Extract entities (needed for all paths)
94
91
  const entities = await this.entityExtractor.extract(message, inputProject)
95
92
  const project = entities.project || inputProject
96
93
  this.logger.debug({ project, technologies: entities.technologies }, 'Entities extracted')
97
94
 
95
+ // P1: Explicit action override — bypass intent classifier entirely
96
+ if (action && action !== 'auto') {
97
+ this.logger.debug({ action }, 'Using explicit action override')
98
+ try {
99
+ switch (action) {
100
+ case 'store':
101
+ return this.handleStoreThis(message, project, entities)
102
+ case 'recall':
103
+ return this.handleContextNeeded(message, project, entities)
104
+ case 'update':
105
+ return this.handleUpdateMemory(message, project, entities)
106
+ case 'delete':
107
+ return this.handleDeleteMemory(message, project, entities)
108
+ default:
109
+ break
110
+ }
111
+ } catch (error) {
112
+ this.logger.error({ error, action }, 'Router action override error')
113
+ return {
114
+ action: 'none',
115
+ summary: `Error processing ${action} request`,
116
+ content: `Failed to process: ${error instanceof Error ? error.message : 'Unknown error'}`,
117
+ relevantItems: 0
118
+ }
119
+ }
120
+ }
121
+
122
+ // Classify intent
123
+ const classification = this.classifier.classify(message)
124
+ this.logger.debug({ intent: classification.primary, confidence: classification.confidence }, 'Intent classified')
125
+
98
126
  // Route to handler
99
127
  try {
100
128
  switch (classification.primary) {
@@ -383,7 +411,20 @@ export class BrainRouter {
383
411
  })
384
412
  }
385
413
 
386
- return this.responseFilter.synthesize(tiers, message, displayProject)
414
+ const response = this.responseFilter.synthesize(tiers, message, displayProject)
415
+
416
+ // P0: When no results found and message looks like a plain statement (not a question),
417
+ // add a hint that the user may have intended to store it
418
+ if (response.action === 'none' && response.relevantItems === 0) {
419
+ const isPlainStatement = message.length > 15 &&
420
+ !message.trim().endsWith('?') &&
421
+ message.split(/\s+/).length > 3
422
+ if (isPlainStatement) {
423
+ response.content += `\n\n**Tip:** If you meant to save this, try:\n- \`brain("Remember: ${message.slice(0, 60)}")\`\n- Or use \`action: "store"\` to force storage: \`brain({ message: "...", action: "store" })\``
424
+ }
425
+ }
426
+
427
+ return response
387
428
  }
388
429
 
389
430
  /**
@@ -22,19 +22,20 @@ export async function handleBrain(
22
22
  ): Promise<ToolResponse> {
23
23
  try {
24
24
  const input = ToolValidator.validate(args, BrainSchema)
25
- const { message, project } = input
25
+ const { message, project, action } = input
26
26
 
27
27
  logger.debug(
28
28
  {
29
29
  messageLength: message.length,
30
30
  project,
31
+ action,
31
32
  messagePreview: message.slice(0, 80)
32
33
  },
33
34
  'Brain tool called'
34
35
  )
35
36
 
36
37
  const router = getBrainRouter(logger)
37
- const response = await router.route({ message, project })
38
+ const response = await router.route({ message, project, action })
38
39
 
39
40
  logger.info(
40
41
  {
@@ -137,7 +137,8 @@ export const GetPhase12StatusSchema = z.object({})
137
137
 
138
138
  export const BrainSchema = z.object({
139
139
  message: z.string().min(1, 'message is required'),
140
- project: z.string().optional()
140
+ project: z.string().optional(),
141
+ action: z.enum(['auto', 'store', 'recall', 'update', 'delete']).optional()
141
142
  })
142
143
 
143
144
  // ============================================================================
@@ -814,7 +814,7 @@ export class HttpApiServer {
814
814
  memoryService.recallSimilar(projectName || 'recent work', {
815
815
  project: projectName,
816
816
  limit: 5,
817
- minSimilarity: 0.2,
817
+ minSimilarity: 0.65,
818
818
  }),
819
819
  memoryService.getPatterns(projectName, { limit: 5 }),
820
820
  ])
@@ -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
+ }
@@ -16,13 +16,14 @@ const ANSI = {
16
16
 
17
17
  /**
18
18
  * Memory indicator banner shown when Claude Brain provides helpful data
19
+ * Note: No ANSI codes — MCP tool responses should be plain text/markdown
19
20
  */
20
- export const MEMORY_HELPED_BANNER = `${ANSI.brightRed}${ANSI.bold}🧠 MEMORY ASSISTED${ANSI.reset}`
21
+ export const MEMORY_HELPED_BANNER = '🧠 MEMORY ASSISTED'
21
22
 
22
23
  /**
23
24
  * Alternative shorter indicator
24
25
  */
25
- export const MEMORY_HELPED_SHORT = `${ANSI.brightRed}[memory-assisted]${ANSI.reset}`
26
+ export const MEMORY_HELPED_SHORT = '[memory-assisted]'
26
27
 
27
28
  /**
28
29
  * Wrap content with the memory helped indicator
@@ -63,7 +64,7 @@ export function formatMemoryStats(stats: {
63
64
 
64
65
  if (parts.length === 0) return ''
65
66
 
66
- return `${ANSI.red}📊 Retrieved: ${parts.join(', ')}${ANSI.reset}`
67
+ return `📊 Retrieved: ${parts.join(', ')}`
67
68
  }
68
69
 
69
70
  /**
@@ -601,6 +601,11 @@ export const TOOLS = {
601
601
  project: {
602
602
  type: 'string',
603
603
  description: 'Project name (IMPORTANT: pass this to scope memories correctly, e.g. "my-app"). Auto-detected from message if omitted, defaults to "general".'
604
+ },
605
+ action: {
606
+ type: 'string',
607
+ enum: ['auto', 'store', 'recall', 'update', 'delete'],
608
+ description: 'Force a specific action instead of auto-detecting intent. "store" = always save, "recall" = always search, "update" = modify last stored, "delete" = remove. Default: auto (use intent classifier).'
604
609
  }
605
610
  },
606
611
  required: ['message']