claude-brain 0.9.3 → 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,18 @@ 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
- const matchId = results[0]?.memory?.id || results[0]?.decision?.id
596
- if (results.length > 0 && matchId) {
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) {
597
651
  const oldId = matchId
598
652
  const newId = await memory.updateDecision(
599
653
  oldId,
@@ -607,20 +661,26 @@ export class BrainRouter {
607
661
  this.lastStoredId = newId
608
662
  this.lastStoredProject = effectiveProject
609
663
 
610
- const oldContent = results[0].decision?.decision || results[0].memory?.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) || ''
611
668
  return {
612
669
  action: 'stored',
613
670
  summary: `Updated decision`,
614
671
  content: `Replaced: "${oldContent.slice(0, 80)}"\n\nWith:\n**New content:** ${message}`,
615
672
  relevantItems: 1
616
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)')
617
677
  }
618
678
  } catch {
619
679
  // Search failed, fall through
620
680
  }
621
681
  }
622
682
 
623
- // Generic update ("actually, use X instead") — use lastStoredId
683
+ // Generic update — use lastStoredId
624
684
  if (this.lastStoredId) {
625
685
  const newId = await memory.updateDecision(
626
686
  this.lastStoredId,
@@ -633,6 +693,7 @@ export class BrainRouter {
633
693
 
634
694
  this.lastStoredId = newId
635
695
  this.lastStoredProject = effectiveProject
696
+ this.searchEngine.invalidateCache(effectiveProject)
636
697
 
637
698
  return {
638
699
  action: 'stored',
@@ -642,29 +703,12 @@ export class BrainRouter {
642
703
  }
643
704
  }
644
705
 
645
- // Fallback: just store as a new decision
646
- const decisionId = await memory.rememberDecision(
647
- effectiveProject,
648
- topic.slice(0, 200),
649
- message,
650
- entities.reasoning || '',
651
- { tags: entities.technologies.length > 0 ? entities.technologies : ['updated'] }
652
- )
653
-
654
- this.lastStoredId = decisionId
655
- this.lastStoredProject = effectiveProject
656
-
657
- return {
658
- action: 'stored',
659
- summary: `Stored as new (no matching decision found to update)`,
660
- content: `Stored as new decision (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Content:** ${message}`,
661
- relevantItems: 1
662
- }
706
+ // Fallback: store as new decision
707
+ return this.storeAsNew(memory, effectiveProject, message, topic, entities, 'No matching decision found to update')
663
708
  }
664
709
 
665
710
  /**
666
- * Delete a memory searches by content to find the right one.
667
- * Only uses lastStoredId for very generic requests like "forget that" with no descriptive words.
711
+ * Phase 19 D4: Delete with higher similarity threshold
668
712
  */
669
713
  private async handleDeleteMemory(
670
714
  message: string,
@@ -679,46 +723,56 @@ export class BrainRouter {
679
723
 
680
724
  const memory = getMemoryService()
681
725
  const topic = entities.topic || message
682
-
683
- // Check if the message has meaningful content to search by
684
- // (strip common delete phrases to see if there's a real subject)
685
726
  const hasSpecificContent = this.hasDescriptiveContent(message)
686
727
 
687
- // If the user described what to delete, ALWAYS search by content first
688
728
  if (hasSpecificContent) {
689
729
  try {
690
730
  const results = await memory.searchRaw(topic, {
691
731
  project: effectiveProject,
692
732
  limit: 3,
693
- minSimilarity: 0.3
733
+ minSimilarity: DELETE_MIN_SIMILARITY
694
734
  })
695
735
 
696
- const matchId = results[0]?.memory?.id || results[0]?.decision?.id
697
- if (results.length > 0 && matchId) {
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) {
698
740
  const targetId = matchId
699
- const content = results[0].decision?.decision || results[0].memory?.content?.slice(0, 100) || ''
741
+ const content = results[0].decision?.decision || results[0].memory?.content?.slice(0, 100) || results[0].content?.slice(0, 100) || ''
700
742
 
701
743
  await memory.deleteDecision(targetId)
702
744
  if (this.lastStoredId === targetId) this.lastStoredId = null
703
745
 
746
+ // Invalidate cache
747
+ this.searchEngine.invalidateCache(effectiveProject)
748
+
704
749
  return {
705
750
  action: 'stored',
706
751
  summary: `Deleted memory`,
707
752
  content: `Deleted: "${content.slice(0, 100)}" (ID: ${targetId})`,
708
753
  relevantItems: 0
709
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
+ }
710
763
  }
711
764
  } catch {
712
765
  // Search failed, fall through
713
766
  }
714
767
  }
715
768
 
716
- // Generic request ("forget that", "delete that") — use lastStoredId
769
+ // Generic request — use lastStoredId
717
770
  if (this.lastStoredId) {
718
771
  try {
719
772
  await memory.deleteDecision(this.lastStoredId)
720
773
  const deletedId = this.lastStoredId
721
774
  this.lastStoredId = null
775
+ this.searchEngine.invalidateCache(effectiveProject)
722
776
  return {
723
777
  action: 'stored',
724
778
  summary: `Deleted most recent memory`,
@@ -738,6 +792,14 @@ export class BrainRouter {
738
792
  }
739
793
  }
740
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
+ */
741
803
  private async handleQuestion(
742
804
  message: string,
743
805
  project: string | undefined,
@@ -748,51 +810,67 @@ export class BrainRouter {
748
810
  return this.servicesNotReady()
749
811
  }
750
812
 
751
- const memory = getMemoryService()
752
813
  const query = entities.topic || message
753
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
+ }
754
845
 
755
- // Parallel search: raw memories + patterns + corrections
756
- const [rawResults, patternResults, correctionResults] = await Promise.all([
757
- memory.searchRaw(query, {
758
- project,
759
- limit: 5,
760
- minSimilarity: 0.3
761
- }).catch(() => [] as any[]),
762
- memory.searchPatterns(query, {
763
- project,
764
- limit: 3,
765
- minSimilarity: 0.3
766
- }).catch(() => [] as any[]),
767
- memory.searchCorrections(query, {
768
- project,
769
- limit: 3,
770
- minSimilarity: 0.3
771
- }).catch(() => [] as any[])
772
- ])
773
-
774
- if (rawResults.length > 0) {
846
+ if (searchResults.length > 0) {
775
847
  tiers.push({
776
848
  label: 'Memories',
777
- results: rawResults.map((r: any) => ({
778
- content: r.decision?.decision
779
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
780
- : r.content?.slice(0, 300) || '',
781
- score: r.similarity || 0,
782
- source: 'Past Decision',
783
- 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>
784
854
  }))
785
855
  })
786
856
  }
787
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
+
788
866
  if (patternResults.length > 0) {
789
867
  tiers.push({
790
868
  label: 'Patterns',
791
- results: patternResults.map((p: any) => ({
792
- content: p.metadata?.description || p.content || '',
793
- score: p.similarity || 0,
794
- source: `Pattern (${p.metadata?.pattern_type || 'general'})`,
795
- 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>
796
874
  }))
797
875
  })
798
876
  }
@@ -800,24 +878,64 @@ export class BrainRouter {
800
878
  if (correctionResults.length > 0) {
801
879
  tiers.push({
802
880
  label: 'Corrections',
803
- results: correctionResults.map((c: any) => ({
804
- content: `Original: ${c.metadata?.original || ''}\nFix: ${c.metadata?.correction || c.content || ''}`,
805
- score: c.similarity || 0,
881
+ results: correctionResults.map(c => ({
882
+ content: c.content,
883
+ score: c.score,
806
884
  source: 'Lesson Learned',
807
- metadata: c.metadata
885
+ metadata: c.metadata as Record<string, unknown>
808
886
  }))
809
887
  })
810
888
  }
811
889
 
812
- // If secondary intent is exploration, also try graph search
813
- if (classification.secondary.includes('exploration')) {
814
- 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
+ })
815
901
  }
816
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
+
817
929
  return this.responseFilter.synthesize(tiers, message, project)
818
930
  }
819
931
 
820
- 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(
821
939
  message: string,
822
940
  project: string | undefined,
823
941
  entities: BrainExtractedEntities
@@ -826,58 +944,106 @@ export class BrainRouter {
826
944
  return this.servicesNotReady()
827
945
  }
828
946
 
829
- const memory = getMemoryService()
830
947
  const query = entities.topic || message
948
+ const lower = message.toLowerCase()
831
949
  const tiers: TierResults[] = []
832
950
 
833
- // Search for related decisions
834
- try {
835
- 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({
836
954
  project,
837
- limit: 5,
838
- minSimilarity: 0.2
955
+ topic: query,
956
+ limit: 20
839
957
  })
840
958
 
841
- if (rawResults.length > 0) {
959
+ if (timeline?.entries?.length) {
842
960
  tiers.push({
843
- label: 'Related Decisions',
844
- results: rawResults.map((r: any) => ({
845
- content: r.decision?.decision
846
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
847
- : r.content?.slice(0, 300) || '',
848
- score: r.similarity || 0,
849
- 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 || {}
850
967
  }))
851
968
  })
852
969
  }
853
- } catch {
854
- // Search failed
855
970
  }
856
971
 
857
- // Try what-if analysis if knowledge graph is available
858
- try {
859
- const kgService = getKnowledgeGraphService()
860
- if (kgService?.search) {
861
- const graphResults = kgService.search.search({ query, limit: 5 })
862
- if (graphResults?.nodes?.length) {
863
- tiers.push({
864
- label: 'Knowledge Graph',
865
- results: graphResults.nodes.map((n: any) => ({
866
- content: `**${n.label || n.name}** (${n.type})\n${n.metadata?.description || ''}`,
867
- score: n.score || 0.5,
868
- source: 'Knowledge Graph'
869
- }))
870
- })
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}`)
871
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
+ })
872
992
  }
873
- } catch {
874
- // 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
+ })
875
1041
  }
876
1042
 
877
1043
  return this.responseFilter.synthesize(tiers, message, project, 'analyzed')
878
1044
  }
879
1045
 
880
- private async handleExploration(
1046
+ private async handleComparison(
881
1047
  message: string,
882
1048
  project: string | undefined,
883
1049
  entities: BrainExtractedEntities
@@ -889,31 +1055,36 @@ export class BrainRouter {
889
1055
  const query = entities.topic || message
890
1056
  const tiers: TierResults[] = []
891
1057
 
892
- await this.addExplorationResults(query, project, tiers)
893
-
894
- // Also do a basic memory search
895
- try {
896
- const memory = getMemoryService()
897
- const rawResults = await memory.searchRaw(query, {
898
- project,
899
- limit: 5,
900
- 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
+ }))
901
1073
  })
1074
+ }
902
1075
 
903
- if (rawResults.length > 0) {
904
- tiers.push({
905
- label: 'Memories',
906
- results: rawResults.map((r: any) => ({
907
- content: r.decision?.decision
908
- ? `**${r.decision.decision}**\n${r.decision.reasoning || ''}`
909
- : r.content?.slice(0, 300) || '',
910
- score: r.similarity || 0,
911
- source: 'Past Decision'
912
- }))
913
- })
914
- }
915
- } catch {
916
- // 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
+ })
917
1088
  }
918
1089
 
919
1090
  return this.responseFilter.synthesize(tiers, message, project, 'analyzed')
@@ -935,35 +1106,89 @@ export class BrainRouter {
935
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`
936
1107
  await vault.writer.appendContent(projectPaths.decisions, entry, '\n')
937
1108
  } catch {
938
- // Vault write failed — memory storage still succeeded
1109
+ // Vault write failed
939
1110
  }
940
1111
  }
941
1112
 
942
- private async addExplorationResults(
943
- query: string,
944
- _project: string | undefined,
945
- tiers: TierResults[]
946
- ): 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 {
947
1148
  try {
948
- const kgService = getKnowledgeGraphService()
949
- if (kgService?.search) {
950
- const graphResults = kgService.search.search({ query, limit: 10 })
951
- if (graphResults?.nodes?.length) {
952
- tiers.push({
953
- label: 'Knowledge Graph',
954
- results: graphResults.nodes.map((n: any) => ({
955
- content: `**${n.label || n.name}** (${n.type})${n.metadata?.description ? `\n${n.metadata.description}` : ''}`,
956
- score: n.score || 0.5,
957
- source: 'Knowledge Graph'
958
- }))
959
- })
960
- }
961
- }
1149
+ const episodeManager = getEpisodeService()
1150
+ if (!episodeManager) return
1151
+ episodeManager.processMessage(
1152
+ { role, content: message, timestamp: new Date().toISOString() },
1153
+ project
1154
+ ).catch(() => {})
962
1155
  } catch {
963
- // Knowledge graph not available
1156
+ // Episode manager not available
964
1157
  }
965
1158
  }
966
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
+
967
1192
  private servicesNotReady(): BrainResponse {
968
1193
  return {
969
1194
  action: 'none',
@@ -975,7 +1200,6 @@ export class BrainRouter {
975
1200
 
976
1201
  /**
977
1202
  * Check if a delete/update message has descriptive content beyond just the command words.
978
- * "forget that" → false (generic), "forget the migrations note" → true (specific)
979
1203
  */
980
1204
  private hasDescriptiveContent(message: string): boolean {
981
1205
  const COMMAND_WORDS = [