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 +1 -1
- package/src/cli/commands/serve.ts +18 -0
- package/src/hooks/passive-classifier.ts +1 -1
- package/src/memory/fts5-search.ts +149 -0
- package/src/memory/index.ts +162 -41
- package/src/memory/migrations/add-fts5.ts +10 -0
- package/src/routing/intent-classifier.ts +7 -1
- package/src/server/http-api.ts +11 -2
- package/src/server/pid-manager.ts +64 -0
package/package.json
CHANGED
|
@@ -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: '
|
|
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
|
/**
|
package/src/memory/index.ts
CHANGED
|
@@ -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,
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|
package/src/server/http-api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|