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.
- package/VERSION +1 -1
- package/package.json +3 -1
- package/scripts/postinstall.mjs +80 -104
- package/src/cli/auto-setup.ts +1 -9
- package/src/cli/bin.ts +23 -2
- package/src/cli/commands/export.ts +130 -0
- package/src/cli/commands/reindex.ts +107 -0
- package/src/cli/commands/serve.ts +54 -0
- package/src/cli/commands/status.ts +158 -0
- package/src/code-intelligence/indexer.ts +315 -0
- package/src/code-intelligence/linker.ts +178 -0
- package/src/code-intelligence/parser.ts +484 -0
- package/src/code-intelligence/query.ts +291 -0
- package/src/code-intelligence/schema.ts +83 -0
- package/src/code-intelligence/types.ts +95 -0
- package/src/config/defaults.ts +3 -3
- package/src/config/loader.ts +6 -0
- package/src/config/schema.ts +28 -2
- package/src/health/index.ts +5 -2
- package/src/hooks/brain-hook.ts +4 -1
- package/src/hooks/context-hook.ts +69 -10
- package/src/hooks/installer.ts +4 -7
- package/src/intelligence/cross-project/index.ts +1 -7
- package/src/intelligence/prediction/index.ts +1 -7
- package/src/intelligence/reasoning/index.ts +1 -7
- package/src/memory/compression.ts +105 -0
- package/src/memory/fts5-search.ts +456 -0
- package/src/memory/index.ts +342 -38
- package/src/memory/migrations/add-fts5.ts +98 -0
- package/src/memory/pruning.ts +60 -0
- package/src/routing/intent-classifier.ts +58 -1
- package/src/routing/response-filter.ts +128 -0
- package/src/routing/router.ts +457 -54
- package/src/server/http-api.ts +319 -1
- package/src/server/providers/resources.ts +1 -42
- package/src/server/services.ts +113 -12
- package/src/server/web-viewer.ts +1115 -0
- package/src/setup/index.ts +12 -22
- package/src/tools/schemas.ts +1 -1
- package/src/intelligence/cross-project/affinity.ts +0 -159
- package/src/intelligence/cross-project/transfer.ts +0 -201
- package/src/intelligence/prediction/context-anticipator.ts +0 -198
- package/src/intelligence/prediction/decision-predictor.ts +0 -184
- package/src/intelligence/reasoning/counterfactual.ts +0 -248
- package/src/intelligence/reasoning/synthesizer.ts +0 -167
- package/src/setup/wizard.ts +0 -459
package/src/routing/router.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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()
|