claude-brain 0.9.2 → 0.10.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.
@@ -1,27 +1,38 @@
1
1
  /**
2
2
  * Brain Router
3
- * Phase 16: Core orchestrator for the unified brain() tool
3
+ * Phase 16 + Phase 19: Core orchestrator for the unified brain() tool
4
4
  *
5
5
  * Routes classified intents to internal service calls and
6
6
  * returns unified BrainResponse objects.
7
+ *
8
+ * Phase 19: Uses SearchEngine for all searches (hybrid, cached, temporal).
9
+ * Wires Timeline, Evolution, Trends, ChainRetrieval, Episodes,
10
+ * Recommender, CrossProject patterns, and KnowledgeGraph enrichment.
7
11
  */
8
12
 
9
13
  import type { Logger } from 'pino'
10
14
  import { IntentClassifier, type ClassificationResult } from './intent-classifier'
11
15
  import { BrainEntityExtractor, type BrainExtractedEntities } from './entity-extractor'
12
16
  import { ResponseFilter, type BrainResponse, type TierResults } from './response-filter'
17
+ import { SearchEngine } from './search-engine'
18
+ import type { NormalizedResult } from './types'
13
19
  import {
14
20
  getMemoryService,
15
21
  getVaultService,
16
22
  getContextService,
17
23
  getPhase12Service,
18
24
  getKnowledgeGraphService,
25
+ getEpisodeService,
19
26
  isServicesInitialized
20
27
  } from '@/server/services'
21
28
 
22
29
  /** Default project when none can be detected */
23
30
  const DEFAULT_PROJECT = 'general'
24
31
 
32
+ /** Phase 19 D4: Minimum similarity for destructive operations */
33
+ const DELETE_MIN_SIMILARITY = 0.5
34
+ const UPDATE_MIN_SIMILARITY = 0.5
35
+
25
36
  export interface BrainInput {
26
37
  message: string
27
38
  project?: string
@@ -31,6 +42,7 @@ export class BrainRouter {
31
42
  private classifier: IntentClassifier
32
43
  private entityExtractor: BrainEntityExtractor
33
44
  private responseFilter: ResponseFilter
45
+ private searchEngine: SearchEngine
34
46
  private logger: Logger
35
47
 
36
48
  /** Track the most recently stored decision ID for update/delete operations */
@@ -41,6 +53,7 @@ export class BrainRouter {
41
53
  this.classifier = new IntentClassifier()
42
54
  this.entityExtractor = new BrainEntityExtractor()
43
55
  this.responseFilter = new ResponseFilter()
56
+ this.searchEngine = new SearchEngine(logger)
44
57
  this.logger = logger.child({ component: 'brain-router' })
45
58
  }
46
59
 
@@ -66,7 +79,7 @@ export class BrainRouter {
66
79
  return this.handleSessionStart(message, project, entities)
67
80
 
68
81
  case 'context_needed':
69
- return this.handleContextNeeded(message, project, entities)
82
+ return this.handleContextNeeded(message, project, entities, classification)
70
83
 
71
84
  case 'decision_made':
72
85
  return this.handleDecisionMade(message, project, entities)
@@ -102,7 +115,7 @@ export class BrainRouter {
102
115
  return this.handleDeleteMemory(message, project, entities)
103
116
 
104
117
  default:
105
- return this.handleContextNeeded(message, project, entities)
118
+ return this.handleContextNeeded(message, project, entities, classification)
106
119
  }
107
120
  } catch (error) {
108
121
  this.logger.error({ error, intent: classification.primary }, 'Router handler error')
@@ -178,11 +191,10 @@ export class BrainRouter {
178
191
  }
179
192
  }
180
193
 
181
- // If Phase 12 found nothing, do a direct search fallback
194
+ // If Phase 12 found nothing, do a direct search fallback via SearchEngine
182
195
  if (!phase12Result.recalledMemories?.memories.length) {
183
196
  try {
184
- const memory = getMemoryService()
185
- const directResults = await memory.searchRaw(entities.topic || message, {
197
+ const directResults = await this.searchEngine.enhancedSearch(entities.topic || message, {
186
198
  project,
187
199
  limit: 5,
188
200
  minSimilarity: 0.3
@@ -190,8 +202,8 @@ export class BrainRouter {
190
202
  if (directResults.length > 0) {
191
203
  parts.push('\n---\n## Related Memories\n')
192
204
  for (const r of directResults) {
193
- const similarity = Math.round((r.similarity || 0) * 100)
194
- parts.push(`**[${similarity}%]** ${r.decision?.decision || r.content?.slice(0, 100) || ''}`)
205
+ const similarity = Math.round(r.score * 100)
206
+ parts.push(`**[${similarity}%]** ${r.content?.slice(0, 200) || ''}`)
195
207
  }
196
208
  }
197
209
  } catch {
@@ -199,6 +211,26 @@ export class BrainRouter {
199
211
  }
200
212
  }
201
213
 
214
+ // C8: Register with episode manager
215
+ this.registerEpisodeMessage(message, project, 'session_start')
216
+
217
+ // C9: Append proactive recommendations
218
+ try {
219
+ const recommendations = await this.searchEngine.getRecommendations(
220
+ entities.topic || message,
221
+ { project, limit: 3 }
222
+ )
223
+ if (recommendations?.recommendations?.length) {
224
+ parts.push('\n---\n## Proactive Recommendations\n')
225
+ for (const rec of recommendations.recommendations) {
226
+ parts.push(`- **${rec.type}**: ${rec.content?.slice(0, 150) || ''}`)
227
+ if (rec.reasoning) parts.push(` _${rec.reasoning}_`)
228
+ }
229
+ }
230
+ } catch {
231
+ // Recommendations not available
232
+ }
233
+
202
234
  const totalRecalled = phase12Result.recalledMemories?.memories.length || 0
203
235
  return {
204
236
  action: 'retrieved',
@@ -211,61 +243,64 @@ export class BrainRouter {
211
243
  private async handleContextNeeded(
212
244
  message: string,
213
245
  project: string | undefined,
214
- entities: BrainExtractedEntities
246
+ entities: BrainExtractedEntities,
247
+ classification?: ClassificationResult
215
248
  ): Promise<BrainResponse> {
216
249
  if (!isServicesInitialized()) {
217
250
  return this.servicesNotReady()
218
251
  }
219
252
 
220
- const memory = getMemoryService()
221
253
  const query = entities.topic || message
222
-
223
- // Search for relevant memories
224
254
  const tiers: TierResults[] = []
255
+ const hasTemporal = classification?.secondary.includes('exploration') ||
256
+ this.classifier.hasTemporalSignal(message.toLowerCase())
225
257
 
226
- try {
227
- const rawResults = await memory.searchRaw(query, {
258
+ // Use temporal search if temporal signals detected
259
+ if (hasTemporal) {
260
+ const { results } = await this.searchEngine.temporalSearch(query, { project, limit: 5 })
261
+ if (results.length > 0) {
262
+ tiers.push({
263
+ label: 'Memories',
264
+ results: results.map(r => ({
265
+ content: r.content,
266
+ score: r.score,
267
+ source: r.source === 'decision' ? 'Past Decision' : r.source,
268
+ metadata: r.metadata as Record<string, unknown>
269
+ }))
270
+ })
271
+ }
272
+ } else {
273
+ // Standard enhanced search
274
+ const searchResults = await this.searchEngine.enhancedSearch(query, {
228
275
  project,
229
276
  limit: 5,
230
277
  minSimilarity: 0.3
231
278
  })
232
-
233
- tiers.push({
234
- label: 'Memories',
235
- results: rawResults.map(r => ({
236
- content: r.decision?.decision
237
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}\n_Context: ${r.decision.context || ''}_`
238
- : r.content?.slice(0, 300) || '',
239
- score: r.similarity || 0,
240
- source: 'Past Decision',
241
- metadata: r.metadata
242
- }))
243
- })
244
- } catch {
245
- // Search failed, continue
246
- }
247
-
248
- // Also search patterns if project is available
249
- try {
250
- const patternResults = await memory.searchPatterns(query, {
251
- project,
252
- limit: 3,
253
- minSimilarity: 0.3
254
- })
255
-
256
- if (patternResults.length > 0) {
279
+ if (searchResults.length > 0) {
257
280
  tiers.push({
258
- label: 'Patterns',
259
- results: patternResults.map(p => ({
260
- content: p.metadata?.description || p.content || '',
261
- score: p.similarity || 0,
262
- source: `Pattern (${p.metadata?.pattern_type || 'general'})`,
263
- metadata: p.metadata
281
+ label: 'Memories',
282
+ results: searchResults.map(r => ({
283
+ content: r.content,
284
+ score: r.score,
285
+ source: r.source === 'decision' ? 'Past Decision' : r.source,
286
+ metadata: r.metadata as Record<string, unknown>
264
287
  }))
265
288
  })
266
289
  }
267
- } catch {
268
- // Pattern search not available
290
+ }
291
+
292
+ // Also search patterns
293
+ const patternResults = await this.searchEngine.searchPatterns(query, { project, limit: 3 })
294
+ if (patternResults.length > 0) {
295
+ tiers.push({
296
+ label: 'Patterns',
297
+ results: patternResults.map(p => ({
298
+ content: p.content,
299
+ score: p.score,
300
+ source: `Pattern`,
301
+ metadata: p.metadata as Record<string, unknown>
302
+ }))
303
+ })
269
304
  }
270
305
 
271
306
  return this.responseFilter.synthesize(tiers, message, project)
@@ -273,14 +308,12 @@ export class BrainRouter {
273
308
 
274
309
  /**
275
310
  * Handle explicit "store this" requests — always uses the FULL message as the decision text.
276
- * This prevents content mangling from entity extraction.
277
311
  */
278
312
  private async handleStoreThis(
279
313
  message: string,
280
314
  project: string | undefined,
281
315
  entities: BrainExtractedEntities
282
316
  ): Promise<BrainResponse> {
283
- // Use "general" as fallback when no project is detected
284
317
  const effectiveProject = project || DEFAULT_PROJECT
285
318
 
286
319
  if (!isServicesInitialized()) {
@@ -288,8 +321,6 @@ export class BrainRouter {
288
321
  }
289
322
 
290
323
  const memory = getMemoryService()
291
-
292
- // ALWAYS use the full message as the decision — never the extracted fragment
293
324
  const decision = message
294
325
  const reasoning = entities.reasoning || ''
295
326
  const context = entities.topic || message.slice(0, 200)
@@ -309,9 +340,15 @@ export class BrainRouter {
309
340
  this.lastStoredId = decisionId
310
341
  this.lastStoredProject = effectiveProject
311
342
 
312
- // Also write to vault
343
+ // Invalidate cache for this project
344
+ this.searchEngine.invalidateCache(effectiveProject)
345
+
346
+ // Write to vault
313
347
  this.writeToVault(effectiveProject, decision, reasoning, context, entities.alternatives)
314
348
 
349
+ // Link to active episode
350
+ this.linkToActiveEpisode(effectiveProject, decisionId, 'decision')
351
+
315
352
  return {
316
353
  action: 'stored',
317
354
  summary: `Stored: ${message.slice(0, 60)}`,
@@ -325,7 +362,6 @@ export class BrainRouter {
325
362
  project: string | undefined,
326
363
  entities: BrainExtractedEntities
327
364
  ): Promise<BrainResponse> {
328
- // Use "general" as fallback when no project is detected
329
365
  const effectiveProject = project || DEFAULT_PROJECT
330
366
 
331
367
  if (!isServicesInitialized()) {
@@ -333,9 +369,6 @@ export class BrainRouter {
333
369
  }
334
370
 
335
371
  const memory = getMemoryService()
336
-
337
- // ALWAYS use the full message as the decision text to prevent content mangling.
338
- // The extracted entities are used only for structured metadata.
339
372
  const decision = message
340
373
  const reasoning = entities.reasoning || ''
341
374
  const context = entities.topic || message.slice(0, 200)
@@ -352,13 +385,18 @@ export class BrainRouter {
352
385
  }
353
386
  )
354
387
 
355
- // Track for potential update/delete
356
388
  this.lastStoredId = decisionId
357
389
  this.lastStoredProject = effectiveProject
358
390
 
359
- // Also write to vault
391
+ // Invalidate cache
392
+ this.searchEngine.invalidateCache(effectiveProject)
393
+
394
+ // Write to vault
360
395
  this.writeToVault(effectiveProject, decision, reasoning, context, alternatives)
361
396
 
397
+ // Link to active episode
398
+ this.linkToActiveEpisode(effectiveProject, decisionId, 'decision')
399
+
362
400
  return {
363
401
  action: 'stored',
364
402
  summary: `Stored decision: ${message.slice(0, 60)}`,
@@ -380,7 +418,7 @@ export class BrainRouter {
380
418
 
381
419
  const memory = getMemoryService()
382
420
  const patternType = entities.patternType || 'solution'
383
- const description = message // Use full message
421
+ const description = message
384
422
 
385
423
  const patternId = await memory.storePattern({
386
424
  project: effectiveProject,
@@ -390,6 +428,12 @@ export class BrainRouter {
390
428
  context: message.slice(0, 300)
391
429
  })
392
430
 
431
+ // Invalidate cache
432
+ this.searchEngine.invalidateCache(effectiveProject)
433
+
434
+ // Link to active episode
435
+ this.linkToActiveEpisode(effectiveProject, patternId, 'pattern')
436
+
393
437
  return {
394
438
  action: 'stored',
395
439
  summary: `Stored ${patternType}: ${description.slice(0, 60)}`,
@@ -410,8 +454,6 @@ export class BrainRouter {
410
454
  }
411
455
 
412
456
  const memory = getMemoryService()
413
-
414
- // Use full message as the original content to prevent mangling
415
457
  const original = entities.original || message
416
458
  const correction = entities.correction || ''
417
459
  const reasoning = entities.reasoning || 'Lesson learned from experience'
@@ -425,6 +467,12 @@ export class BrainRouter {
425
467
  confidence: 0.9
426
468
  })
427
469
 
470
+ // Invalidate cache
471
+ this.searchEngine.invalidateCache(effectiveProject)
472
+
473
+ // Link to active episode
474
+ this.linkToActiveEpisode(effectiveProject, correctionId, 'correction')
475
+
428
476
  return {
429
477
  action: 'stored',
430
478
  summary: `Stored correction: ${original.slice(0, 60)}`,
@@ -459,10 +507,10 @@ export class BrainRouter {
459
507
  completedAt: new Date()
460
508
  })
461
509
  } catch {
462
- // Progress update failed — still report what we found
510
+ // Progress update failed
463
511
  }
464
512
 
465
- // Also store as a searchable memory in ChromaDB so it appears in search results
513
+ // Also store as a searchable memory
466
514
  try {
467
515
  await memory.rememberDecision(
468
516
  effectiveProject,
@@ -471,10 +519,16 @@ export class BrainRouter {
471
519
  'Progress tracking',
472
520
  { tags: ['progress', ...entities.technologies] }
473
521
  )
522
+
523
+ // Invalidate cache
524
+ this.searchEngine.invalidateCache(effectiveProject)
474
525
  } catch {
475
- // Memory storage failed, context-only storage still succeeded
526
+ // Memory storage failed
476
527
  }
477
528
 
529
+ // Register with episode
530
+ this.registerEpisodeMessage(message, effectiveProject, 'progress_update')
531
+
478
532
  return {
479
533
  action: 'stored',
480
534
  summary: `Progress: ${completedTask.slice(0, 60)}`,
@@ -496,7 +550,7 @@ export class BrainRouter {
496
550
  }
497
551
 
498
552
  const memory = getMemoryService()
499
- const effectiveProject = project // No fallback — list all if no project
553
+ const effectiveProject = project
500
554
 
501
555
  try {
502
556
  const decisions = await memory.fetchAllDecisions(effectiveProject)
@@ -565,8 +619,7 @@ export class BrainRouter {
565
619
  }
566
620
 
567
621
  /**
568
- * Update a previous decision searches by content to find the right one, then replaces it.
569
- * Uses lastStoredId only for generic "actually, X" with no descriptive subject.
622
+ * Phase 19 D4: Update with higher similarity threshold
570
623
  */
571
624
  private async handleUpdateMemory(
572
625
  message: string,
@@ -583,17 +636,19 @@ export class BrainRouter {
583
636
  const topic = entities.topic || message
584
637
  const hasSpecificContent = this.hasDescriptiveContent(message)
585
638
 
586
- // If the message has specific content, search for the matching decision first
587
639
  if (hasSpecificContent) {
588
640
  try {
589
641
  const results = await memory.searchRaw(topic, {
590
642
  project: effectiveProject,
591
643
  limit: 1,
592
- minSimilarity: 0.3
644
+ minSimilarity: UPDATE_MIN_SIMILARITY
593
645
  })
594
646
 
595
- if (results.length > 0 && results[0].id) {
596
- const oldId = results[0].id
647
+ const matchId = results[0]?.memory?.id || results[0]?.decision?.id || results[0]?.id
648
+ const matchSimilarity = results[0]?.similarity || 0
649
+
650
+ if (results.length > 0 && matchId && matchSimilarity >= UPDATE_MIN_SIMILARITY) {
651
+ const oldId = matchId
597
652
  const newId = await memory.updateDecision(
598
653
  oldId,
599
654
  effectiveProject,
@@ -606,20 +661,26 @@ export class BrainRouter {
606
661
  this.lastStoredId = newId
607
662
  this.lastStoredProject = effectiveProject
608
663
 
609
- const oldContent = results[0].decision?.decision || results[0].content?.slice(0, 100) || ''
664
+ // Invalidate cache
665
+ this.searchEngine.invalidateCache(effectiveProject)
666
+
667
+ const oldContent = results[0].decision?.decision || results[0].memory?.content?.slice(0, 100) || results[0].content?.slice(0, 100) || ''
610
668
  return {
611
669
  action: 'stored',
612
670
  summary: `Updated decision`,
613
671
  content: `Replaced: "${oldContent.slice(0, 80)}"\n\nWith:\n**New content:** ${message}`,
614
672
  relevantItems: 1
615
673
  }
674
+ } else if (results.length > 0 && matchSimilarity < UPDATE_MIN_SIMILARITY) {
675
+ // D4: No confident match — store as new instead of updating wrong memory
676
+ return this.storeAsNew(memory, effectiveProject, message, topic, entities, 'No confident match to update (similarity too low)')
616
677
  }
617
678
  } catch {
618
679
  // Search failed, fall through
619
680
  }
620
681
  }
621
682
 
622
- // Generic update ("actually, use X instead") — use lastStoredId
683
+ // Generic update — use lastStoredId
623
684
  if (this.lastStoredId) {
624
685
  const newId = await memory.updateDecision(
625
686
  this.lastStoredId,
@@ -632,6 +693,7 @@ export class BrainRouter {
632
693
 
633
694
  this.lastStoredId = newId
634
695
  this.lastStoredProject = effectiveProject
696
+ this.searchEngine.invalidateCache(effectiveProject)
635
697
 
636
698
  return {
637
699
  action: 'stored',
@@ -641,29 +703,12 @@ export class BrainRouter {
641
703
  }
642
704
  }
643
705
 
644
- // Fallback: just store as a new decision
645
- const decisionId = await memory.rememberDecision(
646
- effectiveProject,
647
- topic.slice(0, 200),
648
- message,
649
- entities.reasoning || '',
650
- { tags: entities.technologies.length > 0 ? entities.technologies : ['updated'] }
651
- )
652
-
653
- this.lastStoredId = decisionId
654
- this.lastStoredProject = effectiveProject
655
-
656
- return {
657
- action: 'stored',
658
- summary: `Stored as new (no matching decision found to update)`,
659
- content: `Stored as new decision (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Content:** ${message}`,
660
- relevantItems: 1
661
- }
706
+ // Fallback: store as new decision
707
+ return this.storeAsNew(memory, effectiveProject, message, topic, entities, 'No matching decision found to update')
662
708
  }
663
709
 
664
710
  /**
665
- * Delete a memory searches by content to find the right one.
666
- * Only uses lastStoredId for very generic requests like "forget that" with no descriptive words.
711
+ * Phase 19 D4: Delete with higher similarity threshold
667
712
  */
668
713
  private async handleDeleteMemory(
669
714
  message: string,
@@ -678,45 +723,56 @@ export class BrainRouter {
678
723
 
679
724
  const memory = getMemoryService()
680
725
  const topic = entities.topic || message
681
-
682
- // Check if the message has meaningful content to search by
683
- // (strip common delete phrases to see if there's a real subject)
684
726
  const hasSpecificContent = this.hasDescriptiveContent(message)
685
727
 
686
- // If the user described what to delete, ALWAYS search by content first
687
728
  if (hasSpecificContent) {
688
729
  try {
689
730
  const results = await memory.searchRaw(topic, {
690
731
  project: effectiveProject,
691
732
  limit: 3,
692
- minSimilarity: 0.3
733
+ minSimilarity: DELETE_MIN_SIMILARITY
693
734
  })
694
735
 
695
- if (results.length > 0 && results[0].id) {
696
- const targetId = results[0].id
697
- const content = results[0].decision?.decision || results[0].content?.slice(0, 100) || ''
736
+ const matchId = results[0]?.memory?.id || results[0]?.decision?.id || results[0]?.id
737
+ const matchSimilarity = results[0]?.similarity || 0
738
+
739
+ if (results.length > 0 && matchId && matchSimilarity >= DELETE_MIN_SIMILARITY) {
740
+ const targetId = matchId
741
+ const content = results[0].decision?.decision || results[0].memory?.content?.slice(0, 100) || results[0].content?.slice(0, 100) || ''
698
742
 
699
743
  await memory.deleteDecision(targetId)
700
744
  if (this.lastStoredId === targetId) this.lastStoredId = null
701
745
 
746
+ // Invalidate cache
747
+ this.searchEngine.invalidateCache(effectiveProject)
748
+
702
749
  return {
703
750
  action: 'stored',
704
751
  summary: `Deleted memory`,
705
752
  content: `Deleted: "${content.slice(0, 100)}" (ID: ${targetId})`,
706
753
  relevantItems: 0
707
754
  }
755
+ } else if (results.length > 0 && matchSimilarity < DELETE_MIN_SIMILARITY) {
756
+ // D4: No confident match
757
+ return {
758
+ action: 'none',
759
+ summary: 'No confident match to delete',
760
+ content: `Found a possible match but similarity (${Math.round(matchSimilarity * 100)}%) is too low for safe deletion. Try being more specific about what to delete.`,
761
+ relevantItems: 0
762
+ }
708
763
  }
709
764
  } catch {
710
765
  // Search failed, fall through
711
766
  }
712
767
  }
713
768
 
714
- // Generic request ("forget that", "delete that") — use lastStoredId
769
+ // Generic request — use lastStoredId
715
770
  if (this.lastStoredId) {
716
771
  try {
717
772
  await memory.deleteDecision(this.lastStoredId)
718
773
  const deletedId = this.lastStoredId
719
774
  this.lastStoredId = null
775
+ this.searchEngine.invalidateCache(effectiveProject)
720
776
  return {
721
777
  action: 'stored',
722
778
  summary: `Deleted most recent memory`,
@@ -736,6 +792,14 @@ export class BrainRouter {
736
792
  }
737
793
  }
738
794
 
795
+ /**
796
+ * Phase 19 C7+C10+C11: Enhanced question handler
797
+ * - Uses SearchEngine for all searches (hybrid, cached)
798
+ * - Temporal search when temporal signals detected
799
+ * - ChainRetrieval for complex multi-part questions
800
+ * - CrossProject patterns for general/unspecified project
801
+ * - KnowledgeGraph enrichment
802
+ */
739
803
  private async handleQuestion(
740
804
  message: string,
741
805
  project: string | undefined,
@@ -746,51 +810,67 @@ export class BrainRouter {
746
810
  return this.servicesNotReady()
747
811
  }
748
812
 
749
- const memory = getMemoryService()
750
813
  const query = entities.topic || message
751
814
  const tiers: TierResults[] = []
815
+ const hasTemporal = classification.secondary.includes('exploration') ||
816
+ this.classifier.hasTemporalSignal(message.toLowerCase())
817
+
818
+ // C7: Detect multi-part questions for chain retrieval
819
+ const isComplex = this.isComplexQuestion(message)
820
+
821
+ // Main search — temporal or standard
822
+ let searchResults: NormalizedResult[]
823
+ if (hasTemporal) {
824
+ const { results } = await this.searchEngine.temporalSearch(query, { project, limit: 5 })
825
+ searchResults = results
826
+ } else if (isComplex) {
827
+ // C7: Try multi-hop chain retrieval
828
+ const chainResult = await this.searchEngine.chainSearch(query, { project })
829
+ if (chainResult?.allResults?.length) {
830
+ searchResults = chainResult.allResults.map((r: any) => ({
831
+ id: r.id || '',
832
+ content: r.content || '',
833
+ score: r.similarity || 0,
834
+ source: 'decision' as const,
835
+ project: r.metadata?.project || project || '',
836
+ date: r.metadata?.created_at || '',
837
+ metadata: r.metadata || {}
838
+ }))
839
+ } else {
840
+ searchResults = await this.searchEngine.enhancedSearch(query, { project, limit: 5 })
841
+ }
842
+ } else {
843
+ searchResults = await this.searchEngine.enhancedSearch(query, { project, limit: 5 })
844
+ }
752
845
 
753
- // Parallel search: raw memories + patterns + corrections
754
- const [rawResults, patternResults, correctionResults] = await Promise.all([
755
- memory.searchRaw(query, {
756
- project,
757
- limit: 5,
758
- minSimilarity: 0.3
759
- }).catch(() => [] as any[]),
760
- memory.searchPatterns(query, {
761
- project,
762
- limit: 3,
763
- minSimilarity: 0.3
764
- }).catch(() => [] as any[]),
765
- memory.searchCorrections(query, {
766
- project,
767
- limit: 3,
768
- minSimilarity: 0.3
769
- }).catch(() => [] as any[])
770
- ])
771
-
772
- if (rawResults.length > 0) {
846
+ if (searchResults.length > 0) {
773
847
  tiers.push({
774
848
  label: 'Memories',
775
- results: rawResults.map((r: any) => ({
776
- content: r.decision?.decision
777
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
778
- : r.content?.slice(0, 300) || '',
779
- score: r.similarity || 0,
780
- source: 'Past Decision',
781
- metadata: r.metadata
849
+ results: searchResults.map(r => ({
850
+ content: r.content,
851
+ score: r.score,
852
+ source: r.source === 'decision' ? 'Past Decision' : r.source,
853
+ metadata: r.metadata as Record<string, unknown>
782
854
  }))
783
855
  })
784
856
  }
785
857
 
858
+ // Parallel: patterns + corrections + graph
859
+ const [patternResults, correctionResults, graphResults] = await Promise.all([
860
+ this.searchEngine.searchPatterns(query, { project, limit: 3 }),
861
+ this.searchEngine.searchCorrections(query, { project, limit: 3 }),
862
+ // C11: KnowledgeGraph enrichment for all questions
863
+ this.searchEngine.searchGraph(query, 5)
864
+ ])
865
+
786
866
  if (patternResults.length > 0) {
787
867
  tiers.push({
788
868
  label: 'Patterns',
789
- results: patternResults.map((p: any) => ({
790
- content: p.metadata?.description || p.content || '',
791
- score: p.similarity || 0,
792
- source: `Pattern (${p.metadata?.pattern_type || 'general'})`,
793
- metadata: p.metadata
869
+ results: patternResults.map(p => ({
870
+ content: p.content,
871
+ score: p.score,
872
+ source: `Pattern`,
873
+ metadata: p.metadata as Record<string, unknown>
794
874
  }))
795
875
  })
796
876
  }
@@ -798,24 +878,64 @@ export class BrainRouter {
798
878
  if (correctionResults.length > 0) {
799
879
  tiers.push({
800
880
  label: 'Corrections',
801
- results: correctionResults.map((c: any) => ({
802
- content: `Original: ${c.metadata?.original || ''}\nFix: ${c.metadata?.correction || c.content || ''}`,
803
- score: c.similarity || 0,
881
+ results: correctionResults.map(c => ({
882
+ content: c.content,
883
+ score: c.score,
804
884
  source: 'Lesson Learned',
805
- metadata: c.metadata
885
+ metadata: c.metadata as Record<string, unknown>
806
886
  }))
807
887
  })
808
888
  }
809
889
 
810
- // If secondary intent is exploration, also try graph search
811
- if (classification.secondary.includes('exploration')) {
812
- await this.addExplorationResults(query, project, tiers)
890
+ // C11: Add graph results as "Related Concepts"
891
+ if (graphResults.length > 0) {
892
+ tiers.push({
893
+ label: 'Related Concepts',
894
+ results: graphResults.map(g => ({
895
+ content: g.content,
896
+ score: g.score,
897
+ source: 'Knowledge Graph',
898
+ metadata: g.metadata as Record<string, unknown>
899
+ }))
900
+ })
813
901
  }
814
902
 
903
+ // C10: CrossProject patterns for general/unspecified project queries
904
+ if (!project || project === 'general') {
905
+ try {
906
+ const crossProject = await this.searchEngine.findCrossProjectPatterns({
907
+ query,
908
+ limit: 3
909
+ })
910
+ if (crossProject?.patterns?.length) {
911
+ tiers.push({
912
+ label: 'Cross-Project Patterns',
913
+ results: crossProject.patterns.map((p: any) => ({
914
+ content: `${p.description || ''} (${p.projects?.join(', ') || 'multiple projects'})`,
915
+ score: p.confidence || 0.5,
916
+ source: 'Cross-Project',
917
+ metadata: {}
918
+ }))
919
+ })
920
+ }
921
+ } catch {
922
+ // Cross-project not available
923
+ }
924
+ }
925
+
926
+ // Register with episode
927
+ this.registerEpisodeMessage(message, project, 'question')
928
+
815
929
  return this.responseFilter.synthesize(tiers, message, project)
816
930
  }
817
931
 
818
- private async handleComparison(
932
+ /**
933
+ * Phase 19 C6: Enhanced exploration handler
934
+ * - Timeline for "timeline"/"chronological" queries
935
+ * - DecisionEvolutionTracker for "how has X changed" queries
936
+ * - TrendDetector for "trends"/"emerging" queries
937
+ */
938
+ private async handleExploration(
819
939
  message: string,
820
940
  project: string | undefined,
821
941
  entities: BrainExtractedEntities
@@ -824,58 +944,106 @@ export class BrainRouter {
824
944
  return this.servicesNotReady()
825
945
  }
826
946
 
827
- const memory = getMemoryService()
828
947
  const query = entities.topic || message
948
+ const lower = message.toLowerCase()
829
949
  const tiers: TierResults[] = []
830
950
 
831
- // Search for related decisions
832
- try {
833
- const rawResults = await memory.searchRaw(query, {
951
+ // C6: Timeline queries
952
+ if (lower.includes('timeline') || lower.includes('chronological') || lower.includes('history of')) {
953
+ const timeline = await this.searchEngine.buildTimeline({
834
954
  project,
835
- limit: 5,
836
- minSimilarity: 0.2
955
+ topic: query,
956
+ limit: 20
837
957
  })
838
958
 
839
- if (rawResults.length > 0) {
959
+ if (timeline?.entries?.length) {
840
960
  tiers.push({
841
- label: 'Related Decisions',
842
- results: rawResults.map((r: any) => ({
843
- content: r.decision?.decision
844
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
845
- : r.content?.slice(0, 300) || '',
846
- score: r.similarity || 0,
847
- source: 'Past Decision'
961
+ label: 'Timeline',
962
+ results: timeline.entries.map((e: any) => ({
963
+ content: `**${e.date || ''}** — ${e.content || ''}`,
964
+ score: 0.8,
965
+ source: `Timeline (${e.type || 'event'})`,
966
+ metadata: e.metadata || {}
848
967
  }))
849
968
  })
850
969
  }
851
- } catch {
852
- // Search failed
853
970
  }
854
971
 
855
- // Try what-if analysis if knowledge graph is available
856
- try {
857
- const kgService = getKnowledgeGraphService()
858
- if (kgService?.search) {
859
- const graphResults = kgService.search.search({ query, limit: 5 })
860
- if (graphResults?.nodes?.length) {
861
- tiers.push({
862
- label: 'Knowledge Graph',
863
- results: graphResults.nodes.map((n: any) => ({
864
- content: `**${n.label || n.name}** (${n.type})\n${n.metadata?.description || ''}`,
865
- score: n.score || 0.5,
866
- source: 'Knowledge Graph'
867
- }))
868
- })
972
+ // C6: Evolution queries
973
+ if (lower.includes('evolution') || lower.includes('how has') || lower.includes('changed') || lower.includes('evolved')) {
974
+ const evolution = await this.searchEngine.analyzeEvolution(query, { project })
975
+ if (evolution?.timeline?.length) {
976
+ const parts = []
977
+ parts.push(`**Stability:** ${evolution.stability || 'unknown'}`)
978
+ if (evolution.currentState) parts.push(`**Current:** ${evolution.currentState}`)
979
+ for (const change of (evolution.changes || []).slice(0, 5)) {
980
+ parts.push(`- ${change.description || change}`)
869
981
  }
982
+
983
+ tiers.push({
984
+ label: 'Decision Evolution',
985
+ results: [{
986
+ content: parts.join('\n'),
987
+ score: 0.8,
988
+ source: 'Evolution Analysis',
989
+ metadata: {}
990
+ }]
991
+ })
870
992
  }
871
- } catch {
872
- // Knowledge graph not available
993
+ }
994
+
995
+ // C6: Trend queries
996
+ if (lower.includes('trend') || lower.includes('emerging') || lower.includes('declining')) {
997
+ const trends = await this.searchEngine.detectTrends({ project })
998
+ if (trends?.topTrends?.length) {
999
+ tiers.push({
1000
+ label: 'Trends',
1001
+ results: trends.topTrends.slice(0, 5).map((t: any) => ({
1002
+ content: `**${t.term}** — ${t.trend} (${t.occurrences} occurrences, momentum: ${t.momentum || 'unknown'})`,
1003
+ score: t.occurrences > 5 ? 0.9 : 0.7,
1004
+ source: `Trend (${t.trend})`,
1005
+ metadata: {}
1006
+ }))
1007
+ })
1008
+ }
1009
+ }
1010
+
1011
+ // Always include graph exploration
1012
+ const graphResults = await this.searchEngine.searchGraph(query, 10)
1013
+ if (graphResults.length > 0) {
1014
+ tiers.push({
1015
+ label: 'Knowledge Graph',
1016
+ results: graphResults.map(g => ({
1017
+ content: g.content,
1018
+ score: g.score,
1019
+ source: 'Knowledge Graph',
1020
+ metadata: g.metadata as Record<string, unknown>
1021
+ }))
1022
+ })
1023
+ }
1024
+
1025
+ // Also do a basic memory search as fallback
1026
+ const searchResults = await this.searchEngine.enhancedSearch(query, {
1027
+ project,
1028
+ limit: 5,
1029
+ minSimilarity: 0.2
1030
+ })
1031
+ if (searchResults.length > 0) {
1032
+ tiers.push({
1033
+ label: 'Memories',
1034
+ results: searchResults.map(r => ({
1035
+ content: r.content,
1036
+ score: r.score,
1037
+ source: r.source === 'decision' ? 'Past Decision' : r.source,
1038
+ metadata: r.metadata as Record<string, unknown>
1039
+ }))
1040
+ })
873
1041
  }
874
1042
 
875
1043
  return this.responseFilter.synthesize(tiers, message, project, 'analyzed')
876
1044
  }
877
1045
 
878
- private async handleExploration(
1046
+ private async handleComparison(
879
1047
  message: string,
880
1048
  project: string | undefined,
881
1049
  entities: BrainExtractedEntities
@@ -887,31 +1055,36 @@ export class BrainRouter {
887
1055
  const query = entities.topic || message
888
1056
  const tiers: TierResults[] = []
889
1057
 
890
- await this.addExplorationResults(query, project, tiers)
891
-
892
- // Also do a basic memory search
893
- try {
894
- const memory = getMemoryService()
895
- const rawResults = await memory.searchRaw(query, {
896
- project,
897
- limit: 5,
898
- minSimilarity: 0.2
1058
+ // Search for related decisions
1059
+ const searchResults = await this.searchEngine.enhancedSearch(query, {
1060
+ project,
1061
+ limit: 5,
1062
+ minSimilarity: 0.2
1063
+ })
1064
+ if (searchResults.length > 0) {
1065
+ tiers.push({
1066
+ label: 'Related Decisions',
1067
+ results: searchResults.map(r => ({
1068
+ content: r.content,
1069
+ score: r.score,
1070
+ source: 'Past Decision',
1071
+ metadata: r.metadata as Record<string, unknown>
1072
+ }))
899
1073
  })
1074
+ }
900
1075
 
901
- if (rawResults.length > 0) {
902
- tiers.push({
903
- label: 'Memories',
904
- results: rawResults.map((r: any) => ({
905
- content: r.decision?.decision
906
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
907
- : r.content?.slice(0, 300) || '',
908
- score: r.similarity || 0,
909
- source: 'Past Decision'
910
- }))
911
- })
912
- }
913
- } catch {
914
- // Search failed
1076
+ // Graph search for comparison context
1077
+ const graphResults = await this.searchEngine.searchGraph(query, 5)
1078
+ if (graphResults.length > 0) {
1079
+ tiers.push({
1080
+ label: 'Knowledge Graph',
1081
+ results: graphResults.map(g => ({
1082
+ content: g.content,
1083
+ score: g.score,
1084
+ source: 'Knowledge Graph',
1085
+ metadata: g.metadata as Record<string, unknown>
1086
+ }))
1087
+ })
915
1088
  }
916
1089
 
917
1090
  return this.responseFilter.synthesize(tiers, message, project, 'analyzed')
@@ -933,35 +1106,89 @@ export class BrainRouter {
933
1106
  const entry = `### Decision: ${decision.slice(0, 100)}\n\n**Date:** ${date}\n**Context:** ${context}\n**Decision:** ${decision}\n**Reasoning:** ${reasoning}\n${alternatives ? `**Alternatives:** ${alternatives}\n` : ''}\n---\n\n`
934
1107
  await vault.writer.appendContent(projectPaths.decisions, entry, '\n')
935
1108
  } catch {
936
- // Vault write failed — memory storage still succeeded
1109
+ // Vault write failed
937
1110
  }
938
1111
  }
939
1112
 
940
- private async addExplorationResults(
941
- query: string,
942
- _project: string | undefined,
943
- tiers: TierResults[]
944
- ): Promise<void> {
1113
+ /**
1114
+ * Store as a new decision (fallback for update when no match found)
1115
+ */
1116
+ private async storeAsNew(
1117
+ memory: any,
1118
+ project: string,
1119
+ message: string,
1120
+ topic: string,
1121
+ entities: BrainExtractedEntities,
1122
+ reason: string
1123
+ ): Promise<BrainResponse> {
1124
+ const decisionId = await memory.rememberDecision(
1125
+ project,
1126
+ topic.slice(0, 200),
1127
+ message,
1128
+ entities.reasoning || '',
1129
+ { tags: entities.technologies.length > 0 ? entities.technologies : ['updated'] }
1130
+ )
1131
+
1132
+ this.lastStoredId = decisionId
1133
+ this.lastStoredProject = project
1134
+ this.searchEngine.invalidateCache(project)
1135
+
1136
+ return {
1137
+ action: 'stored',
1138
+ summary: `Stored as new (${reason})`,
1139
+ content: `Stored as new decision (ID: ${decisionId})\n\n**Project:** ${project}\n**Content:** ${message}`,
1140
+ relevantItems: 1
1141
+ }
1142
+ }
1143
+
1144
+ /**
1145
+ * C8: Register a message with the episode manager
1146
+ */
1147
+ private registerEpisodeMessage(message: string, project?: string, role: string = 'user'): void {
945
1148
  try {
946
- const kgService = getKnowledgeGraphService()
947
- if (kgService?.search) {
948
- const graphResults = kgService.search.search({ query, limit: 10 })
949
- if (graphResults?.nodes?.length) {
950
- tiers.push({
951
- label: 'Knowledge Graph',
952
- results: graphResults.nodes.map((n: any) => ({
953
- content: `**${n.label || n.name}** (${n.type})${n.metadata?.description ? `\n${n.metadata.description}` : ''}`,
954
- score: n.score || 0.5,
955
- source: 'Knowledge Graph'
956
- }))
957
- })
958
- }
959
- }
1149
+ const episodeManager = getEpisodeService()
1150
+ if (!episodeManager) return
1151
+ episodeManager.processMessage(
1152
+ { role, content: message, timestamp: new Date().toISOString() },
1153
+ project
1154
+ ).catch(() => {})
960
1155
  } catch {
961
- // Knowledge graph not available
1156
+ // Episode manager not available
962
1157
  }
963
1158
  }
964
1159
 
1160
+ /**
1161
+ * C8: Link a stored decision/pattern/correction to the active episode
1162
+ */
1163
+ private linkToActiveEpisode(project: string, id: string, type: 'decision' | 'pattern' | 'correction'): void {
1164
+ try {
1165
+ const episodeManager = getEpisodeService()
1166
+ if (!episodeManager) return
1167
+ const activeEpisode = episodeManager.getActiveEpisode(project)
1168
+ if (!activeEpisode) return
1169
+
1170
+ if (type === 'decision') episodeManager.linkDecision(activeEpisode.id, id)
1171
+ else if (type === 'pattern') episodeManager.linkPattern(activeEpisode.id, id)
1172
+ else if (type === 'correction') episodeManager.linkCorrection(activeEpisode.id, id)
1173
+ } catch {
1174
+ // Episode manager not available
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * C7: Detect complex multi-part questions
1180
+ */
1181
+ private isComplexQuestion(message: string): boolean {
1182
+ // Multiple question marks
1183
+ const questionMarks = (message.match(/\?/g) || []).length
1184
+ if (questionMarks >= 2) return true
1185
+ // Very long question (likely multi-part)
1186
+ if (message.length > 200 && message.includes('?')) return true
1187
+ // Multiple clauses with "and" or "also"
1188
+ if (message.includes(' and ') && message.includes('?') && message.length > 100) return true
1189
+ return false
1190
+ }
1191
+
965
1192
  private servicesNotReady(): BrainResponse {
966
1193
  return {
967
1194
  action: 'none',
@@ -973,7 +1200,6 @@ export class BrainRouter {
973
1200
 
974
1201
  /**
975
1202
  * Check if a delete/update message has descriptive content beyond just the command words.
976
- * "forget that" → false (generic), "forget the migrations note" → true (specific)
977
1203
  */
978
1204
  private hasDescriptiveContent(message: string): boolean {
979
1205
  const COMMAND_WORDS = [