claude-brain 0.17.13 → 0.22.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.
Files changed (46) hide show
  1. package/VERSION +1 -1
  2. package/package.json +3 -1
  3. package/scripts/postinstall.mjs +80 -104
  4. package/src/cli/auto-setup.ts +1 -9
  5. package/src/cli/bin.ts +23 -2
  6. package/src/cli/commands/export.ts +130 -0
  7. package/src/cli/commands/reindex.ts +107 -0
  8. package/src/cli/commands/serve.ts +54 -0
  9. package/src/cli/commands/status.ts +158 -0
  10. package/src/code-intelligence/indexer.ts +315 -0
  11. package/src/code-intelligence/linker.ts +178 -0
  12. package/src/code-intelligence/parser.ts +484 -0
  13. package/src/code-intelligence/query.ts +291 -0
  14. package/src/code-intelligence/schema.ts +83 -0
  15. package/src/code-intelligence/types.ts +95 -0
  16. package/src/config/defaults.ts +3 -3
  17. package/src/config/loader.ts +6 -0
  18. package/src/config/schema.ts +28 -2
  19. package/src/health/index.ts +5 -2
  20. package/src/hooks/brain-hook.ts +4 -1
  21. package/src/hooks/context-hook.ts +69 -10
  22. package/src/hooks/installer.ts +4 -7
  23. package/src/intelligence/cross-project/index.ts +1 -7
  24. package/src/intelligence/prediction/index.ts +1 -7
  25. package/src/intelligence/reasoning/index.ts +1 -7
  26. package/src/memory/compression.ts +105 -0
  27. package/src/memory/fts5-search.ts +456 -0
  28. package/src/memory/index.ts +342 -38
  29. package/src/memory/migrations/add-fts5.ts +98 -0
  30. package/src/memory/pruning.ts +60 -0
  31. package/src/routing/intent-classifier.ts +58 -1
  32. package/src/routing/response-filter.ts +128 -0
  33. package/src/routing/router.ts +457 -54
  34. package/src/server/http-api.ts +319 -1
  35. package/src/server/providers/resources.ts +1 -42
  36. package/src/server/services.ts +113 -12
  37. package/src/server/web-viewer.ts +1115 -0
  38. package/src/setup/index.ts +12 -22
  39. package/src/tools/schemas.ts +1 -1
  40. package/src/intelligence/cross-project/affinity.ts +0 -159
  41. package/src/intelligence/cross-project/transfer.ts +0 -201
  42. package/src/intelligence/prediction/context-anticipator.ts +0 -198
  43. package/src/intelligence/prediction/decision-predictor.ts +0 -184
  44. package/src/intelligence/reasoning/counterfactual.ts +0 -248
  45. package/src/intelligence/reasoning/synthesizer.ts +0 -167
  46. package/src/setup/wizard.ts +0 -459
@@ -13,7 +13,7 @@
13
13
  import type { Logger } from 'pino'
14
14
  import { IntentClassifier, type ClassificationResult } from './intent-classifier'
15
15
  import { BrainEntityExtractor, type BrainExtractedEntities } from './entity-extractor'
16
- import { ResponseFilter, type BrainResponse, type TierResults } from './response-filter'
16
+ import { ResponseFilter, type BrainResponse, type TierResults, formatCompactResponse, formatDetailResponse, formatTimeline, groupByDay } from './response-filter'
17
17
  import { SearchEngine } from './search-engine'
18
18
  import type { NormalizedResult } from './types'
19
19
  import {
@@ -23,9 +23,11 @@ import {
23
23
  getPhase12Service,
24
24
  getEpisodeService,
25
25
  getSessionTracker,
26
+ getCodeLinker,
26
27
  isServicesInitialized
27
28
  } from '@/server/services'
28
29
  import { timed } from '@/utils/timing'
30
+ import type { ObservationCompressor } from '@/memory/compression'
29
31
 
30
32
  /** Default project when none can be detected */
31
33
  const DEFAULT_PROJECT = 'general'
@@ -58,6 +60,9 @@ export class BrainRouter {
58
60
  private searchEngine: SearchEngine
59
61
  private logger: Logger
60
62
 
63
+ /** Phase 30: Optional LLM compressor for long observations */
64
+ private compressor: ObservationCompressor | null = null
65
+
61
66
  /** Track the most recently stored decision ID for update/delete operations */
62
67
  private lastStoredId: string | null = null
63
68
  private lastStoredProject: string | null = null
@@ -73,6 +78,11 @@ export class BrainRouter {
73
78
  this.logger = logger.child({ component: 'brain-router' })
74
79
  }
75
80
 
81
+ /** Phase 30: Set the optional LLM compressor */
82
+ setCompressor(compressor: ObservationCompressor): void {
83
+ this.compressor = compressor
84
+ }
85
+
76
86
  async route(input: BrainInput): Promise<BrainResponse> {
77
87
  const { message, project: inputProject } = input
78
88
 
@@ -130,6 +140,12 @@ export class BrainRouter {
130
140
  case 'delete_memory':
131
141
  return this.handleDeleteMemory(message, project, entities)
132
142
 
143
+ case 'detail_request':
144
+ return this.handleDetailRequest(message, project, entities)
145
+
146
+ case 'timeline':
147
+ return this.handleTimeline(message, project, entities)
148
+
133
149
  default:
134
150
  return this.handleContextNeeded(message, project, entities, classification)
135
151
  }
@@ -385,10 +401,12 @@ export class BrainRouter {
385
401
  }
386
402
 
387
403
  const memory = getMemoryService()
388
- const decision = message
389
404
  const reasoning = entities.reasoning || ''
390
405
  const context = entities.topic || message.slice(0, 200)
391
406
 
407
+ // Phase 30: Optional LLM compression
408
+ const { content: decision, rawContent } = await this.maybeCompress(message, 'store')
409
+
392
410
  const decisionId = await timed('brain:store', () => memory.rememberDecision(
393
411
  effectiveProject,
394
412
  context,
@@ -410,16 +428,18 @@ export class BrainRouter {
410
428
  // Invalidate cache for this project
411
429
  this.searchEngine.invalidateCache(effectiveProject)
412
430
 
413
- // Background: vault write + episode linking (non-critical)
431
+ // Background: vault write + episode linking + code linkage (non-critical)
414
432
  setImmediate(() => {
415
433
  this.writeToVault(effectiveProject, decision, reasoning, context, entities.alternatives)
416
434
  this.linkToActiveEpisode(effectiveProject, decisionId, 'decision')
435
+ this.linkToCodeFiles(decisionId, decision, effectiveProject)
417
436
  })
418
437
 
438
+ const compressedNote = rawContent ? '\n*(compressed)*' : ''
419
439
  return {
420
440
  action: 'stored',
421
441
  summary: `Stored: ${message.slice(0, 60)}`,
422
- content: `Memory stored (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Content:** ${decision}${reasoning ? `\n**Reasoning:** ${reasoning}` : ''}`,
442
+ content: `Memory stored (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Content:** ${decision}${reasoning ? `\n**Reasoning:** ${reasoning}` : ''}${compressedNote}`,
423
443
  relevantItems: 1
424
444
  }
425
445
  }
@@ -436,11 +456,13 @@ export class BrainRouter {
436
456
  }
437
457
 
438
458
  const memory = getMemoryService()
439
- const decision = message
440
459
  const reasoning = entities.reasoning || ''
441
460
  const context = entities.topic || message.slice(0, 200)
442
461
  const alternatives = entities.alternatives
443
462
 
463
+ // Phase 30: Optional LLM compression
464
+ const { content: decision, rawContent } = await this.maybeCompress(message, 'decision')
465
+
444
466
  const decisionId = await timed('brain:store', () => memory.rememberDecision(
445
467
  effectiveProject,
446
468
  context,
@@ -461,16 +483,18 @@ export class BrainRouter {
461
483
  // Invalidate cache
462
484
  this.searchEngine.invalidateCache(effectiveProject)
463
485
 
464
- // Background: vault write + episode linking (non-critical)
486
+ // Background: vault write + episode linking + code linkage (non-critical)
465
487
  setImmediate(() => {
466
488
  this.writeToVault(effectiveProject, decision, reasoning, context, alternatives)
467
489
  this.linkToActiveEpisode(effectiveProject, decisionId, 'decision')
490
+ this.linkToCodeFiles(decisionId, decision, effectiveProject)
468
491
  })
469
492
 
493
+ const compressedNote = rawContent ? '\n*(compressed)*' : ''
470
494
  return {
471
495
  action: 'stored',
472
496
  summary: `Stored decision: ${message.slice(0, 60)}`,
473
- content: `Decision stored (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Decision:** ${decision}\n**Reasoning:** ${reasoning}`,
497
+ content: `Decision stored (ID: ${decisionId})\n\n**Project:** ${effectiveProject}\n**Decision:** ${decision}\n**Reasoning:** ${reasoning}${compressedNote}`,
474
498
  relevantItems: 1
475
499
  }
476
500
  }
@@ -501,8 +525,9 @@ export class BrainRouter {
501
525
  // Invalidate cache
502
526
  this.searchEngine.invalidateCache(effectiveProject)
503
527
 
504
- // Link to active episode
528
+ // Link to active episode + code files
505
529
  this.linkToActiveEpisode(effectiveProject, patternId, 'pattern')
530
+ this.linkToCodeFiles(patternId, description, effectiveProject)
506
531
 
507
532
  return {
508
533
  action: 'stored',
@@ -540,8 +565,9 @@ export class BrainRouter {
540
565
  // Invalidate cache
541
566
  this.searchEngine.invalidateCache(effectiveProject)
542
567
 
543
- // Link to active episode
568
+ // Link to active episode + code files
544
569
  this.linkToActiveEpisode(effectiveProject, correctionId, 'correction')
570
+ this.linkToCodeFiles(correctionId, original, effectiveProject)
545
571
 
546
572
  return {
547
573
  action: 'stored',
@@ -627,44 +653,7 @@ export class BrainRouter {
627
653
  const patterns = await memory.fetchAllPatterns(effectiveProject)
628
654
  const corrections = await memory.fetchAllCorrections(effectiveProject)
629
655
 
630
- const parts: string[] = []
631
656
  const projectLabel = effectiveProject || 'all projects'
632
-
633
- if (decisions.length > 0) {
634
- parts.push(`## Decisions (${decisions.length})`)
635
- for (const d of decisions.slice(0, 20)) {
636
- const rawDecision = d.decision || d.document || d.content || ''
637
- const decision = typeof rawDecision === 'string' ? rawDecision : JSON.stringify(rawDecision)
638
- const rawDate = d.created_at || d.date || ''
639
- const date = rawDate ? ` (${String(rawDate).split('T')[0]})` : ''
640
- parts.push(`- ${decision.slice(0, 150)}${date}`)
641
- }
642
- if (decisions.length > 20) {
643
- parts.push(`_...and ${decisions.length - 20} more_`)
644
- }
645
- parts.push('')
646
- }
647
-
648
- if (patterns.length > 0) {
649
- parts.push(`## Patterns (${patterns.length})`)
650
- for (const p of patterns.slice(0, 10)) {
651
- const rawDesc = p.description || p.document || p.content || ''
652
- const desc = typeof rawDesc === 'string' ? rawDesc : JSON.stringify(rawDesc)
653
- parts.push(`- **${p.pattern_type || 'solution'}**: ${desc.slice(0, 120)}`)
654
- }
655
- parts.push('')
656
- }
657
-
658
- if (corrections.length > 0) {
659
- parts.push(`## Corrections (${corrections.length})`)
660
- for (const c of corrections.slice(0, 10)) {
661
- const rawOrig = c.original || c.document || c.content || ''
662
- const orig = typeof rawOrig === 'string' ? rawOrig : JSON.stringify(rawOrig)
663
- parts.push(`- ${orig.slice(0, 120)}`)
664
- }
665
- parts.push('')
666
- }
667
-
668
657
  const total = decisions.length + patterns.length + corrections.length
669
658
 
670
659
  if (total === 0) {
@@ -676,10 +665,42 @@ export class BrainRouter {
676
665
  }
677
666
  }
678
667
 
668
+ // Phase 27: Use compact format for list_all
669
+ const allItems: any[] = [
670
+ ...decisions.map(d => ({
671
+ id: d.id || (d as any).decision_id,
672
+ content: typeof (d.decision || d.document || d.content) === 'string'
673
+ ? (d.decision || d.document || d.content)
674
+ : JSON.stringify(d.decision || d.document || d.content || ''),
675
+ category: 'decision',
676
+ project: d.project || effectiveProject || 'general',
677
+ created_at: d.created_at || d.date || '',
678
+ })),
679
+ ...patterns.map(p => ({
680
+ id: p.id,
681
+ content: typeof (p.description || p.document || p.content) === 'string'
682
+ ? (p.description || p.document || p.content)
683
+ : JSON.stringify(p.description || p.document || p.content || ''),
684
+ category: p.pattern_type || 'pattern',
685
+ project: p.project || effectiveProject || 'general',
686
+ created_at: p.created_at || '',
687
+ })),
688
+ ...corrections.map(c => ({
689
+ id: c.id,
690
+ content: typeof (c.original || c.document || c.content) === 'string'
691
+ ? (c.original || c.document || c.content)
692
+ : JSON.stringify(c.original || c.document || c.content || ''),
693
+ category: 'correction',
694
+ project: c.project || effectiveProject || 'general',
695
+ created_at: c.created_at || '',
696
+ })),
697
+ ]
698
+
699
+ const compactContent = formatCompactResponse(allItems, `all memories for ${projectLabel}`)
679
700
  return {
680
701
  action: 'retrieved',
681
702
  summary: `${total} memories for ${projectLabel}`,
682
- content: parts.join('\n'),
703
+ content: compactContent,
683
704
  relevantItems: total
684
705
  }
685
706
  } catch (error) {
@@ -798,6 +819,37 @@ export class BrainRouter {
798
819
  const memory = getMemoryService()
799
820
  const topic = entities.topic || message
800
821
  const hasSpecificContent = this.hasDescriptiveContent(message)
822
+ const lower = message.toLowerCase()
823
+
824
+ // BUG-006 T6: Bulk delete — "delete all memories for project X"
825
+ if (lower.includes('delete all') || lower.includes('remove all') || lower.includes('clear all')) {
826
+ const bulkProject = entities.project || project || this.lastStoredProject
827
+ if (bulkProject) {
828
+ try {
829
+ const decisions = await memory.chroma.store.getDecisionsByProject(bulkProject)
830
+ if (decisions.length === 0) {
831
+ return {
832
+ action: 'none',
833
+ summary: 'No memories found',
834
+ content: `No memories found for project "${bulkProject}".`,
835
+ relevantItems: 0
836
+ }
837
+ }
838
+ for (const d of decisions) {
839
+ await memory.deleteDecision(d.id)
840
+ }
841
+ this.searchEngine.invalidateCache(bulkProject)
842
+ return {
843
+ action: 'stored',
844
+ summary: `Bulk deleted ${decisions.length} memories`,
845
+ content: `Deleted all ${decisions.length} memories for project "${bulkProject}".`,
846
+ relevantItems: 0
847
+ }
848
+ } catch {
849
+ // Bulk delete failed, fall through to single-item delete
850
+ }
851
+ }
852
+ }
801
853
 
802
854
  if (hasSpecificContent) {
803
855
  try {
@@ -927,7 +979,12 @@ export class BrainRouter {
927
979
  content: r.content,
928
980
  score: r.score,
929
981
  source: r.source === 'decision' ? 'Past Decision' : r.source,
930
- metadata: r.metadata as Record<string, unknown>
982
+ metadata: {
983
+ ...(r.metadata as Record<string, unknown>),
984
+ id: r.id,
985
+ project: r.project,
986
+ created_at: r.date
987
+ }
931
988
  }))
932
989
  })
933
990
  }
@@ -947,7 +1004,13 @@ export class BrainRouter {
947
1004
  content: p.content,
948
1005
  score: p.score,
949
1006
  source: `Pattern`,
950
- metadata: p.metadata as Record<string, unknown>
1007
+ metadata: {
1008
+ ...(p.metadata as Record<string, unknown>),
1009
+ id: p.id,
1010
+ project: p.project,
1011
+ created_at: p.date,
1012
+ category: 'pattern'
1013
+ }
951
1014
  }))
952
1015
  })
953
1016
  }
@@ -959,7 +1022,13 @@ export class BrainRouter {
959
1022
  content: c.content,
960
1023
  score: c.score,
961
1024
  source: 'Lesson Learned',
962
- metadata: c.metadata as Record<string, unknown>
1025
+ metadata: {
1026
+ ...(c.metadata as Record<string, unknown>),
1027
+ id: c.id,
1028
+ project: c.project,
1029
+ created_at: c.date,
1030
+ category: 'correction'
1031
+ }
963
1032
  }))
964
1033
  })
965
1034
  }
@@ -967,8 +1036,19 @@ export class BrainRouter {
967
1036
  // C11: Add graph results as "Related Concepts"
968
1037
  // When a project filter is active, cap graph results lower (0.25) so they don't
969
1038
  // outrank project-scoped ChromaDB results. Without a project filter, cap at 0.4.
1039
+ // BUG-006 T4: Also require keyword overlap with query to prevent noise
970
1040
  const graphScoreCap = searchProject ? 0.25 : 0.4
971
- const relevantGraphResults = graphResults.filter(g => g.score >= 0.6)
1041
+ const queryWordsForGraph = new Set(query.toLowerCase().split(/\s+/).filter(w => w.length > 2))
1042
+ const relevantGraphResults = graphResults.filter(g => {
1043
+ if (g.score < 0.6) return false
1044
+ // Require at least one query keyword to appear in graph content
1045
+ if (queryWordsForGraph.size > 0) {
1046
+ const contentLower = g.content.toLowerCase()
1047
+ const hasOverlap = [...queryWordsForGraph].some(w => contentLower.includes(w))
1048
+ if (!hasOverlap) return false
1049
+ }
1050
+ return true
1051
+ })
972
1052
  if (relevantGraphResults.length > 0) {
973
1053
  tiers.push({
974
1054
  label: 'Related Concepts',
@@ -1046,7 +1126,44 @@ export class BrainRouter {
1046
1126
  // Register with episode
1047
1127
  this.registerEpisodeMessage(message, displayProject, 'question')
1048
1128
 
1049
- return this.responseFilter.synthesize(tiers, message, displayProject)
1129
+ // Phase 27: Use compact format for progressive disclosure
1130
+ // Combine all tier results, filter, then format compactly
1131
+ const allResults: import('./response-filter').FilterableResult[] = []
1132
+ for (const tier of tiers) {
1133
+ allResults.push(...tier.results)
1134
+ }
1135
+
1136
+ if (allResults.length === 0) {
1137
+ return {
1138
+ action: 'none',
1139
+ summary: 'No relevant information found',
1140
+ content: `No results found for: "${message.slice(0, 100)}"`,
1141
+ relevantItems: 0
1142
+ }
1143
+ }
1144
+
1145
+ const filtered = this.responseFilter.filter(allResults, message, displayProject)
1146
+ if (filtered.length === 0) {
1147
+ return {
1148
+ action: 'none',
1149
+ summary: 'Results filtered out as noise or irrelevant',
1150
+ content: `No relevant results after filtering for: "${message.slice(0, 100)}"`,
1151
+ relevantItems: 0
1152
+ }
1153
+ }
1154
+
1155
+ // Collect result IDs for detail lookups
1156
+ const resultIds = filtered
1157
+ .map(r => (r.metadata as any)?.id || (r.metadata as any)?.decision_id)
1158
+ .filter(Boolean)
1159
+
1160
+ const compactContent = formatCompactResponse(filtered, message)
1161
+ return {
1162
+ action: 'retrieved',
1163
+ summary: `Found ${filtered.length} result${filtered.length === 1 ? '' : 's'}`,
1164
+ content: compactContent,
1165
+ relevantItems: filtered.length
1166
+ }
1050
1167
  }
1051
1168
 
1052
1169
  /**
@@ -1132,12 +1249,21 @@ export class BrainRouter {
1132
1249
  }
1133
1250
 
1134
1251
  // Always include graph exploration — cap scores when project filter is active
1252
+ // BUG-006 T4: Also require keyword overlap with query to prevent noise
1135
1253
  const explorationGraphCap = searchProject ? 0.25 : 0.5
1254
+ const explorationQueryWords = new Set(query.toLowerCase().split(/\s+/).filter(w => w.length > 2))
1136
1255
  const graphResults = await this.searchEngine.searchGraph(query, 10)
1137
- if (graphResults.length > 0) {
1256
+ const filteredGraphResults = graphResults.filter(g => {
1257
+ if (explorationQueryWords.size > 0) {
1258
+ const contentLower = g.content.toLowerCase()
1259
+ return [...explorationQueryWords].some(w => contentLower.includes(w))
1260
+ }
1261
+ return true
1262
+ })
1263
+ if (filteredGraphResults.length > 0) {
1138
1264
  tiers.push({
1139
1265
  label: 'Knowledge Graph',
1140
- results: graphResults.map(g => ({
1266
+ results: filteredGraphResults.map(g => ({
1141
1267
  content: g.content,
1142
1268
  score: Math.min(g.score, explorationGraphCap),
1143
1269
  source: 'Knowledge Graph',
@@ -1240,8 +1366,268 @@ export class BrainRouter {
1240
1366
  return this.responseFilter.synthesize(tiers, message, displayProject, 'analyzed')
1241
1367
  }
1242
1368
 
1369
+ /**
1370
+ * Phase 27: Handle detail requests — "details obs_abc123", "expand <id>"
1371
+ * Looks up a single observation by ID and returns full detail view.
1372
+ */
1373
+ private async handleDetailRequest(
1374
+ message: string,
1375
+ project: string | undefined,
1376
+ _entities: BrainExtractedEntities
1377
+ ): Promise<BrainResponse> {
1378
+ if (!isServicesInitialized()) {
1379
+ return this.servicesNotReady()
1380
+ }
1381
+
1382
+ // Extract ID from message using regex
1383
+ const idMatch = message.match(/\b(obs_\w+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}|[a-f0-9]{8,})\b/i)
1384
+ if (!idMatch) {
1385
+ return {
1386
+ action: 'none',
1387
+ summary: 'No observation ID provided',
1388
+ content: 'Please provide an observation ID. Use brain("details {ID}") with an ID from search results.',
1389
+ relevantItems: 0
1390
+ }
1391
+ }
1392
+
1393
+ const id = idMatch[1]
1394
+ const observation = await this.lookupById(id)
1395
+
1396
+ if (!observation) {
1397
+ return {
1398
+ action: 'none',
1399
+ summary: `No observation found: ${id}`,
1400
+ content: `No observation found with ID: ${id}`,
1401
+ relevantItems: 0
1402
+ }
1403
+ }
1404
+
1405
+ // Increment access count
1406
+ this.incrementAccess(id)
1407
+
1408
+ return {
1409
+ action: 'retrieved',
1410
+ summary: `Details for ${id.slice(0, 12)}...`,
1411
+ content: formatDetailResponse(observation),
1412
+ relevantItems: 1
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Phase 27: Handle timeline requests — "timeline for project", "recent activity"
1418
+ * Fetches observations in a time range and groups by day.
1419
+ */
1420
+ private async handleTimeline(
1421
+ message: string,
1422
+ project: string | undefined,
1423
+ entities: BrainExtractedEntities
1424
+ ): Promise<BrainResponse> {
1425
+ if (!isServicesInitialized()) {
1426
+ return this.servicesNotReady()
1427
+ }
1428
+
1429
+ const effectiveProject = entities.project || project
1430
+ const displayProject = effectiveProject || DEFAULT_PROJECT
1431
+
1432
+ // Parse time range from message (default: last 7 days)
1433
+ const now = new Date()
1434
+ let start: Date
1435
+ let daysBack = 7
1436
+ const lower = message.toLowerCase()
1437
+ if (lower.includes('yesterday')) {
1438
+ daysBack = 1
1439
+ start = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000)
1440
+ } else if (lower.includes('today')) {
1441
+ daysBack = 0
1442
+ // Start of today, not "now"
1443
+ start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
1444
+ } else if (lower.includes('last month') || lower.includes('past month')) {
1445
+ daysBack = 30
1446
+ start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
1447
+ } else if (lower.includes('last week') || lower.includes('past week')) {
1448
+ daysBack = 7
1449
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
1450
+ } else if (lower.includes('last 3 days') || lower.includes('past 3 days')) {
1451
+ daysBack = 3
1452
+ start = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000)
1453
+ } else {
1454
+ start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
1455
+ }
1456
+
1457
+ const observations = await this.fetchRecentObservations(effectiveProject, start, now)
1458
+ if (observations.length === 0) {
1459
+ return {
1460
+ action: 'none',
1461
+ summary: `No activity found for ${displayProject}`,
1462
+ content: `No observations found in the last ${daysBack === 0 ? 'day' : `${daysBack} day(s)`} for ${displayProject}.`,
1463
+ relevantItems: 0
1464
+ }
1465
+ }
1466
+
1467
+ const grouped = groupByDay(observations)
1468
+ const timelineContent = formatTimeline(grouped, displayProject)
1469
+
1470
+ return {
1471
+ action: 'retrieved',
1472
+ summary: `Timeline for ${displayProject} (${observations.length} items)`,
1473
+ content: timelineContent,
1474
+ relevantItems: observations.length
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * Phase 27: Look up a single observation by ID.
1480
+ * Tries FTS5 first, then falls back to ChromaDB search.
1481
+ */
1482
+ private async lookupById(id: string): Promise<any | null> {
1483
+ const memory = getMemoryService()
1484
+
1485
+ // Try FTS5 first (fast, direct lookup)
1486
+ if (memory.fts5) {
1487
+ const result = memory.fts5.getById(id)
1488
+ if (result) return result
1489
+ }
1490
+
1491
+ // Fallback: search ChromaDB by ID if available
1492
+ if (memory.isChromaDBEnabled()) {
1493
+ try {
1494
+ const collections = memory.chroma.collections
1495
+ // Try decisions collection
1496
+ const decisions = await collections.getDecisions()
1497
+ if (decisions) {
1498
+ try {
1499
+ const result = await decisions.get({ ids: [id] })
1500
+ if (result?.documents?.[0]) {
1501
+ return {
1502
+ id,
1503
+ content: result.documents[0],
1504
+ metadata: result.metadatas?.[0] || {},
1505
+ category: 'decision',
1506
+ project: (result.metadatas?.[0] as any)?.project || '',
1507
+ created_at: (result.metadatas?.[0] as any)?.created_at || '',
1508
+ reasoning: (result.metadatas?.[0] as any)?.reasoning || null,
1509
+ tags: [],
1510
+ confidence: 0.8
1511
+ }
1512
+ }
1513
+ } catch { /* ID not in this collection */ }
1514
+ }
1515
+ } catch {
1516
+ // ChromaDB lookup failed
1517
+ }
1518
+ }
1519
+
1520
+ return null
1521
+ }
1522
+
1523
+ /**
1524
+ * Phase 27: Fetch recent observations in a date range.
1525
+ * Tries FTS5 first, then falls back to ChromaDB temporal search.
1526
+ */
1527
+ private async fetchRecentObservations(
1528
+ project: string | undefined,
1529
+ start: Date,
1530
+ end: Date
1531
+ ): Promise<any[]> {
1532
+ const memory = getMemoryService()
1533
+
1534
+ // Try FTS5 first (efficient SQL-level date filtering)
1535
+ if (memory.fts5 && project) {
1536
+ return memory.fts5.fetchByTimeRange(project, start, end)
1537
+ }
1538
+
1539
+ // Also try FTS5 without project filter
1540
+ if (memory.fts5 && !project) {
1541
+ // FTS5 fetchByTimeRange requires project, use fetchAll with date filtering
1542
+ try {
1543
+ const all = memory.fts5.search('*', undefined, 100)
1544
+ return all.filter(r => {
1545
+ const date = new Date(r.created_at)
1546
+ if (isNaN(date.getTime())) return false
1547
+ return date >= start && date <= end
1548
+ }).sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
1549
+ } catch {
1550
+ // FTS5 search failed, fall through
1551
+ }
1552
+ }
1553
+
1554
+ // Fallback: use memory fetchAll with date filtering
1555
+ try {
1556
+ const allDecisions = await memory.fetchAllDecisions(project)
1557
+ const allPatterns = await memory.fetchAllPatterns(project)
1558
+ const allCorrections = await memory.fetchAllCorrections(project)
1559
+
1560
+ const allItems = [
1561
+ ...allDecisions.map((d: any) => ({
1562
+ id: d.id || d.decision_id,
1563
+ content: d.decision || d.document || d.content || '',
1564
+ category: 'decision',
1565
+ project: d.project || project || DEFAULT_PROJECT,
1566
+ created_at: d.created_at || d.date || '',
1567
+ })),
1568
+ ...allPatterns.map((p: any) => ({
1569
+ id: p.id,
1570
+ content: p.description || p.document || p.content || '',
1571
+ category: p.pattern_type || 'pattern',
1572
+ project: p.project || project || DEFAULT_PROJECT,
1573
+ created_at: p.created_at || '',
1574
+ })),
1575
+ ...allCorrections.map((c: any) => ({
1576
+ id: c.id,
1577
+ content: c.original || c.document || c.content || '',
1578
+ category: 'correction',
1579
+ project: c.project || project || DEFAULT_PROJECT,
1580
+ created_at: c.created_at || '',
1581
+ })),
1582
+ ]
1583
+
1584
+ return allItems.filter(item => {
1585
+ if (!item.created_at) return true // include items without dates
1586
+ const date = new Date(item.created_at)
1587
+ if (isNaN(date.getTime())) return true
1588
+ return date >= start && date <= end
1589
+ }).sort((a, b) => {
1590
+ const dateA = new Date(a.created_at || 0).getTime()
1591
+ const dateB = new Date(b.created_at || 0).getTime()
1592
+ return dateB - dateA
1593
+ })
1594
+ } catch {
1595
+ return []
1596
+ }
1597
+ }
1598
+
1599
+ /**
1600
+ * Phase 27: Increment access count for an observation.
1601
+ */
1602
+ private incrementAccess(id: string): void {
1603
+ try {
1604
+ const memory = getMemoryService()
1605
+ if (memory.fts5) {
1606
+ memory.fts5.recordAccess(id)
1607
+ }
1608
+ } catch {
1609
+ // Non-critical
1610
+ }
1611
+ }
1612
+
1243
1613
  // ===== Helpers =====
1244
1614
 
1615
+ /**
1616
+ * Phase 29: Link a stored observation to code files (non-blocking, fire-and-forget).
1617
+ */
1618
+ private linkToCodeFiles(observationId: string, content: string, project: string): void {
1619
+ try {
1620
+ const linker = getCodeLinker()
1621
+ if (linker) {
1622
+ linker.linkObservation(observationId, content, project).catch(err => {
1623
+ this.logger.debug({ err }, 'Code linkage failed (non-fatal)')
1624
+ })
1625
+ }
1626
+ } catch {
1627
+ // Linker not available
1628
+ }
1629
+ }
1630
+
1245
1631
  private async writeToVault(
1246
1632
  project: string,
1247
1633
  decision: string,
@@ -1429,6 +1815,23 @@ export class BrainRouter {
1429
1815
  return matches
1430
1816
  }
1431
1817
 
1818
+ /**
1819
+ * Phase 30: Optionally compress content before storing.
1820
+ * Returns the (possibly compressed) content and original if compressed.
1821
+ */
1822
+ private async maybeCompress(content: string, category: string): Promise<{ content: string; rawContent?: string }> {
1823
+ if (!this.compressor) return { content }
1824
+ try {
1825
+ const result = await this.compressor.compress(content, category)
1826
+ if (result.compressed) {
1827
+ return { content: result.summary, rawContent: result.original }
1828
+ }
1829
+ } catch {
1830
+ // Compression failed, use original
1831
+ }
1832
+ return { content }
1833
+ }
1834
+
1432
1835
  private generateTaskId(title: string): string {
1433
1836
  return title
1434
1837
  .toLowerCase()