claude-brain 0.30.2 → 0.30.3

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 (236) hide show
  1. package/README.md +241 -191
  2. package/VERSION +1 -1
  3. package/assets/CLAUDE-unified.md +11 -11
  4. package/assets/CLAUDE.md +29 -29
  5. package/package.json +7 -3
  6. package/packs/backend/node.json +173 -173
  7. package/packs/core/javascript.json +176 -176
  8. package/packs/core/typescript.json +222 -222
  9. package/packs/frontend/react.json +254 -254
  10. package/packs/meta/testing.json +172 -172
  11. package/scripts/postinstall.mjs +531 -531
  12. package/src/automation/decision-detector.ts +452 -452
  13. package/src/automation/phase12-manager.ts +456 -456
  14. package/src/automation/proactive-recall.ts +373 -373
  15. package/src/automation/project-detector.ts +310 -310
  16. package/src/automation/repo-scanner.ts +210 -205
  17. package/src/cli/auto-setup.ts +75 -75
  18. package/src/cli/auto-start.ts +266 -266
  19. package/src/cli/bin.ts +264 -264
  20. package/src/cli/commands/autostart.ts +90 -90
  21. package/src/cli/commands/chroma.ts +578 -577
  22. package/src/cli/commands/export-training.ts +70 -70
  23. package/src/cli/commands/export.ts +130 -130
  24. package/src/cli/commands/git-hook.ts +183 -183
  25. package/src/cli/commands/hooks.ts +217 -217
  26. package/src/cli/commands/init.ts +123 -123
  27. package/src/cli/commands/install-mcp.ts +122 -111
  28. package/src/cli/commands/models.ts +979 -979
  29. package/src/cli/commands/pack.ts +200 -200
  30. package/src/cli/commands/refresh.ts +344 -339
  31. package/src/cli/commands/reindex.ts +120 -120
  32. package/src/cli/commands/serve.ts +466 -463
  33. package/src/cli/commands/start.ts +44 -44
  34. package/src/cli/commands/status.ts +220 -203
  35. package/src/cli/commands/uninstall-mcp.ts +45 -41
  36. package/src/cli/commands/update.ts +130 -124
  37. package/src/cli/migrate-chroma.ts +106 -106
  38. package/src/cli/ui/animations.ts +80 -80
  39. package/src/cli/ui/components.ts +82 -82
  40. package/src/cli/ui/index.ts +4 -4
  41. package/src/cli/ui/logo.ts +36 -36
  42. package/src/cli/ui/theme.ts +55 -55
  43. package/src/code-intelligence/indexer.ts +352 -352
  44. package/src/code-intelligence/linker.ts +178 -178
  45. package/src/code-intelligence/parser.ts +484 -484
  46. package/src/code-intelligence/query.ts +291 -291
  47. package/src/code-intelligence/schema.ts +83 -83
  48. package/src/code-intelligence/types.ts +95 -95
  49. package/src/config/defaults.ts +52 -52
  50. package/src/config/home.ts +56 -56
  51. package/src/config/index.ts +5 -5
  52. package/src/config/loader.ts +192 -192
  53. package/src/config/schema.ts +446 -415
  54. package/src/config/validator.ts +182 -182
  55. package/src/context/assembler.ts +407 -400
  56. package/src/context/index.ts +79 -79
  57. package/src/context/progress-tracker.ts +174 -174
  58. package/src/context/standards-manager.ts +287 -287
  59. package/src/context/validator.ts +58 -58
  60. package/src/diagnostics/index.ts +122 -121
  61. package/src/health/index.ts +233 -232
  62. package/src/hooks/brain-hook.ts +134 -131
  63. package/src/hooks/capture.ts +168 -168
  64. package/src/hooks/claude-code-mastery.md +112 -112
  65. package/src/hooks/context-hook.ts +260 -245
  66. package/src/hooks/deduplicator.ts +72 -72
  67. package/src/hooks/git-capture.ts +109 -109
  68. package/src/hooks/git-hook-installer.ts +211 -207
  69. package/src/hooks/index.ts +20 -20
  70. package/src/hooks/installer.ts +306 -288
  71. package/src/hooks/interceptor-hook.ts +204 -201
  72. package/src/hooks/passive-classifier.ts +397 -397
  73. package/src/hooks/queue.ts +160 -129
  74. package/src/hooks/session-tracker.ts +312 -312
  75. package/src/hooks/types.ts +52 -52
  76. package/src/index.ts +7 -7
  77. package/src/intelligence/cross-project/generalizer.ts +283 -283
  78. package/src/intelligence/cross-project/index.ts +7 -7
  79. package/src/intelligence/hf-downloader.ts +222 -222
  80. package/src/intelligence/hf-manifest.json +78 -78
  81. package/src/intelligence/index.ts +24 -24
  82. package/src/intelligence/inference-router.ts +762 -762
  83. package/src/intelligence/model-manager.ts +263 -245
  84. package/src/intelligence/optimization/index.ts +10 -10
  85. package/src/intelligence/optimization/precompute.ts +202 -202
  86. package/src/intelligence/optimization/semantic-cache.ts +213 -207
  87. package/src/intelligence/prediction/index.ts +7 -7
  88. package/src/intelligence/prediction/recommender.ts +276 -268
  89. package/src/intelligence/reasoning/chain-retrieval.ts +243 -247
  90. package/src/intelligence/reasoning/index.ts +7 -7
  91. package/src/intelligence/temporal/evolution.ts +193 -197
  92. package/src/intelligence/temporal/index.ts +16 -16
  93. package/src/intelligence/temporal/query-processor.ts +190 -190
  94. package/src/intelligence/temporal/timeline.ts +272 -259
  95. package/src/intelligence/temporal/trends.ts +263 -263
  96. package/src/intelligence/tokenizer.ts +118 -118
  97. package/src/knowledge/entity-extractor.ts +447 -443
  98. package/src/knowledge/graph/builder.ts +185 -185
  99. package/src/knowledge/graph/linker.ts +201 -201
  100. package/src/knowledge/graph/memory-graph.ts +359 -359
  101. package/src/knowledge/graph/schema.ts +99 -99
  102. package/src/knowledge/graph/search.ts +166 -166
  103. package/src/knowledge/relationship-extractor.ts +108 -108
  104. package/src/memory/chroma/client.ts +211 -192
  105. package/src/memory/chroma/collection-manager.ts +92 -92
  106. package/src/memory/chroma/config.ts +57 -57
  107. package/src/memory/chroma/embeddings.ts +177 -175
  108. package/src/memory/chroma/index.ts +82 -82
  109. package/src/memory/chroma/migration.ts +270 -270
  110. package/src/memory/chroma/schemas.ts +69 -69
  111. package/src/memory/chroma/search.ts +319 -315
  112. package/src/memory/chroma/store.ts +755 -747
  113. package/src/memory/compression.ts +121 -121
  114. package/src/memory/consolidation/archiver.ts +162 -165
  115. package/src/memory/consolidation/merger.ts +182 -186
  116. package/src/memory/consolidation/scorer.ts +136 -136
  117. package/src/memory/database.ts +9 -0
  118. package/src/memory/dual-write.ts +145 -0
  119. package/src/memory/embeddings.ts +226 -226
  120. package/src/memory/episodic/detector.ts +108 -108
  121. package/src/memory/episodic/manager.ts +347 -351
  122. package/src/memory/episodic/summarizer.ts +179 -179
  123. package/src/memory/episodic/types.ts +52 -52
  124. package/src/memory/fts5-search.ts +692 -633
  125. package/src/memory/index.ts +943 -1060
  126. package/src/memory/migrations/add-fts5.ts +118 -108
  127. package/src/memory/patterns.ts +438 -438
  128. package/src/memory/pruning.ts +60 -60
  129. package/src/memory/schema.ts +88 -88
  130. package/src/memory/store.ts +911 -787
  131. package/src/orchestrator/handlers/decision-handler.ts +204 -204
  132. package/src/packs/index.ts +9 -9
  133. package/src/packs/loader.ts +134 -134
  134. package/src/packs/manager.ts +204 -204
  135. package/src/packs/ranker.ts +78 -78
  136. package/src/packs/types.ts +81 -81
  137. package/src/phase12/index.ts +5 -5
  138. package/src/retrieval/bm25/index.ts +300 -297
  139. package/src/retrieval/bm25/tokenizer.ts +184 -184
  140. package/src/retrieval/feedback/adaptive.ts +221 -221
  141. package/src/retrieval/feedback/index.ts +16 -16
  142. package/src/retrieval/feedback/metrics.ts +221 -221
  143. package/src/retrieval/feedback/store.ts +283 -283
  144. package/src/retrieval/fusion/index.ts +194 -194
  145. package/src/retrieval/fusion/rrf.ts +165 -165
  146. package/src/retrieval/index.ts +12 -12
  147. package/src/retrieval/pipeline.ts +375 -375
  148. package/src/retrieval/query/expander.ts +203 -203
  149. package/src/retrieval/query/index.ts +27 -27
  150. package/src/retrieval/query/intent-classifier.ts +252 -252
  151. package/src/retrieval/query/temporal-parser.ts +295 -295
  152. package/src/retrieval/reranker/index.ts +189 -188
  153. package/src/retrieval/reranker/model.ts +99 -95
  154. package/src/retrieval/service.ts +125 -125
  155. package/src/retrieval/types.ts +162 -162
  156. package/src/routing/entity-extractor.ts +454 -454
  157. package/src/routing/handlers/exploration-handler.ts +369 -0
  158. package/src/routing/handlers/index.ts +19 -0
  159. package/src/routing/handlers/memory-handler.ts +273 -0
  160. package/src/routing/handlers/mutation-handler.ts +241 -0
  161. package/src/routing/handlers/recall-handler.ts +642 -0
  162. package/src/routing/handlers/shared.ts +515 -0
  163. package/src/routing/handlers/types.ts +48 -0
  164. package/src/routing/intent-classifier.ts +552 -552
  165. package/src/routing/response-filter.ts +399 -391
  166. package/src/routing/router.ts +245 -2193
  167. package/src/routing/search-engine.ts +521 -514
  168. package/src/routing/types.ts +104 -94
  169. package/src/scripts/health-check.ts +118 -118
  170. package/src/scripts/setup.ts +122 -122
  171. package/src/server/auto-updater.ts +283 -276
  172. package/src/server/handlers/call-tool.ts +159 -159
  173. package/src/server/handlers/list-tools.ts +35 -35
  174. package/src/server/handlers/tools/auto-remember.ts +165 -165
  175. package/src/server/handlers/tools/brain.ts +86 -86
  176. package/src/server/handlers/tools/create-project.ts +135 -135
  177. package/src/server/handlers/tools/get-code-standards.ts +123 -123
  178. package/src/server/handlers/tools/get-corrections.ts +152 -152
  179. package/src/server/handlers/tools/get-patterns.ts +156 -156
  180. package/src/server/handlers/tools/get-project-context.ts +75 -75
  181. package/src/server/handlers/tools/index.ts +30 -30
  182. package/src/server/handlers/tools/init-project.ts +756 -756
  183. package/src/server/handlers/tools/list-projects.ts +126 -126
  184. package/src/server/handlers/tools/recall-similar.ts +87 -87
  185. package/src/server/handlers/tools/recognize-pattern.ts +132 -132
  186. package/src/server/handlers/tools/record-correction.ts +131 -131
  187. package/src/server/handlers/tools/remember-decision.ts +168 -168
  188. package/src/server/handlers/tools/schemas.ts +179 -179
  189. package/src/server/handlers/tools/search-code.ts +122 -122
  190. package/src/server/handlers/tools/smart-context.ts +146 -146
  191. package/src/server/handlers/tools/update-progress.ts +131 -131
  192. package/src/server/http-api.ts +215 -1229
  193. package/src/server/mcp-proxy.ts +85 -84
  194. package/src/server/mcp-server.ts +285 -284
  195. package/src/server/middleware/auth.ts +39 -0
  196. package/src/server/middleware/error-handler.ts +37 -0
  197. package/src/server/middleware/rate-limit.ts +53 -0
  198. package/src/server/middleware/validate.ts +42 -0
  199. package/src/server/pid-manager.ts +137 -136
  200. package/src/server/providers/resources.ts +581 -581
  201. package/src/server/routes/code.ts +228 -0
  202. package/src/server/routes/context.ts +26 -0
  203. package/src/server/routes/health.ts +19 -0
  204. package/src/server/routes/helpers.ts +100 -0
  205. package/src/server/routes/hooks.ts +197 -0
  206. package/src/server/routes/mcp.ts +47 -0
  207. package/src/server/routes/memory.ts +397 -0
  208. package/src/server/routes/models.ts +96 -0
  209. package/src/server/routes/projects.ts +89 -0
  210. package/src/server/routes/types.ts +21 -0
  211. package/src/server/schemas/api-schemas.ts +202 -0
  212. package/src/server/services.ts +720 -720
  213. package/src/server/utils/memory-indicator.ts +84 -84
  214. package/src/server/utils/response-formatter.ts +129 -129
  215. package/src/server/web-viewer.ts +1145 -1115
  216. package/src/setup/index.ts +38 -38
  217. package/src/tools/registry.ts +115 -115
  218. package/src/tools/schemas.ts +666 -666
  219. package/src/tools/types.ts +412 -412
  220. package/src/training/data-store.ts +320 -298
  221. package/src/training/retrain-pipeline.ts +399 -394
  222. package/src/utils/error-handler.ts +136 -136
  223. package/src/utils/index.ts +58 -58
  224. package/src/utils/kill-port.ts +55 -53
  225. package/src/utils/phase12-helper.ts +56 -56
  226. package/src/utils/safe-path.ts +43 -0
  227. package/src/utils/timing.ts +47 -47
  228. package/src/utils/transaction.ts +63 -63
  229. package/src/vault/index.ts +4 -3
  230. package/src/vault/paths.ts +106 -106
  231. package/src/vault/query.ts +4 -1
  232. package/src/vault/reader.ts +44 -1
  233. package/src/vault/watcher.ts +24 -1
  234. package/src/vault/writer.ts +487 -413
  235. package/skills/persistent-memory/SKILL.md +0 -148
  236. package/skills/persistent-memory/references/tool-reference.md +0 -90
@@ -1,391 +1,399 @@
1
- /**
2
- * Brain Response Filter
3
- * Phase 16 + Phase 19: Filters noise, deduplicates, ranks, and synthesizes results
4
- *
5
- * Phase 19 changes:
6
- * - D2: Lower word overlap threshold 0.85 → 0.75, add ID-based and prefix-based dedup
7
- * - D3: Split INFRA_NOISE into strong (1 hit = filter) and weak (2+ hits = filter)
8
- */
9
-
10
- export interface FilterableResult {
11
- content: string
12
- score: number
13
- source: string
14
- metadata?: Record<string, unknown>
15
- }
16
-
17
- export interface FilteredResult {
18
- id?: string
19
- content: string
20
- score: number
21
- source: string
22
- relevanceNote: string
23
- metadata?: Record<string, unknown>
24
- }
25
-
26
- export interface BrainResponse {
27
- action: 'retrieved' | 'stored' | 'analyzed' | 'none'
28
- summary: string
29
- content: string
30
- relevantItems: number
31
- }
32
-
33
- export interface TierResults {
34
- label: string
35
- results: FilterableResult[]
36
- }
37
-
38
- export class ResponseFilter {
39
- // D3: Strong noise — 1 hit is enough to filter (very specific internal terms)
40
- private readonly STRONG_NOISE = [
41
- 'mcp-server', 'model-context-protocol', 'embedding-service', 'vector-database',
42
- 'mcp tool', 'tool handler', 'precompute engine', 'knowledge graph builder',
43
- 'semantic cache', 'bun:test'
44
- ]
45
-
46
- // D3: Weak noise — need 2+ hits to filter (terms that could appear in legitimate decisions)
47
- private readonly WEAK_NOISE = [
48
- 'chromadb', 'minisearch', 'compromise', 'better-sqlite3',
49
- 'pino', 'hono', 'zod', 'claude-brain',
50
- 'phase 12', 'phase 13', 'phase 14', 'phase 15', 'phase 16',
51
- 'phase 17', 'phase 18', 'phase 19'
52
- ]
53
-
54
- /**
55
- * Filter a set of results to remove noise and improve relevance
56
- */
57
- filter(results: FilterableResult[], query: string, project?: string): FilteredResult[] {
58
- let filtered = results
59
-
60
- // 1. Remove infrastructure noise (if project !== 'claude-brain')
61
- if (project !== 'claude-brain') {
62
- const queryLower = query.toLowerCase()
63
- filtered = filtered.filter(r => !this.isInfrastructureNoise(r.content, queryLower))
64
- }
65
-
66
- // 2. Remove near-duplicates (Phase 19 D2: improved dedup)
67
- filtered = this.deduplicateResults(filtered)
68
-
69
- // 3. Apply dynamic threshold (at least 70% of median similarity)
70
- filtered = this.applyDynamicThreshold(filtered)
71
-
72
- // 4. Sort by score descending
73
- filtered.sort((a, b) => b.score - a.score)
74
-
75
- // 5. Limit to 5 results
76
- filtered = filtered.slice(0, 5)
77
-
78
- // 6. Add one-line relevance explanation per result (preserve id from metadata)
79
- return filtered.map(r => ({
80
- id: (r.metadata as any)?.id || (r.metadata as any)?.decision_id,
81
- content: r.content,
82
- score: r.score,
83
- source: r.source,
84
- relevanceNote: this.generateRelevanceNote(r, query),
85
- metadata: r.metadata
86
- }))
87
- }
88
-
89
- /**
90
- * Synthesize results from multiple tiers into a unified BrainResponse
91
- */
92
- synthesize(
93
- tiers: TierResults[],
94
- message: string,
95
- project?: string,
96
- action: BrainResponse['action'] = 'retrieved'
97
- ): BrainResponse {
98
- // Combine all results from all tiers
99
- const allResults: FilterableResult[] = []
100
- for (const tier of tiers) {
101
- allResults.push(...tier.results)
102
- }
103
-
104
- if (allResults.length === 0) {
105
- return {
106
- action: 'none',
107
- summary: 'No relevant information found',
108
- content: `No results found for: "${message.slice(0, 100)}"`,
109
- relevantItems: 0
110
- }
111
- }
112
-
113
- // Filter the combined results
114
- const filtered = this.filter(allResults, message, project)
115
-
116
- if (filtered.length === 0) {
117
- return {
118
- action: 'none',
119
- summary: 'Results filtered out as noise or irrelevant',
120
- content: `No relevant results after filtering for: "${message.slice(0, 100)}"`,
121
- relevantItems: 0
122
- }
123
- }
124
-
125
- // Format filtered results
126
- const contentParts: string[] = []
127
- for (const result of filtered) {
128
- const scoreStr = result.score > 0 ? ` [${Math.round(result.score * 100)}%]` : ''
129
- // Defensive: ensure content is always a string (prevents [object Object])
130
- const contentStr = typeof result.content === 'string'
131
- ? result.content
132
- : JSON.stringify(result.content ?? '')
133
- contentParts.push(`**${result.source}**${scoreStr}\n${contentStr}`)
134
- if (result.relevanceNote) {
135
- contentParts.push(`_${result.relevanceNote}_`)
136
- }
137
- contentParts.push('')
138
- }
139
-
140
- const summary = filtered.length === 1
141
- ? `Found 1 relevant result`
142
- : `Found ${filtered.length} relevant results`
143
-
144
- return {
145
- action,
146
- summary,
147
- content: contentParts.join('\n'),
148
- relevantItems: filtered.length
149
- }
150
- }
151
-
152
- /**
153
- * D3: Check if content is infrastructure/internal noise
154
- * Strong noise: 1 hit filters (unless the term appears in the query)
155
- * Weak noise: 2+ hits filters (skipping terms that appear in the query)
156
- */
157
- private isInfrastructureNoise(content: string, queryLower?: string): boolean {
158
- const lower = content.toLowerCase()
159
-
160
- // Strong noise: a single hit is enough (but skip terms the user asked about)
161
- for (const term of this.STRONG_NOISE) {
162
- if (queryLower && queryLower.includes(term)) continue
163
- if (lower.includes(term)) {
164
- return true
165
- }
166
- }
167
-
168
- // Weak noise: need 2+ hits (skip terms the user asked about)
169
- let weakHits = 0
170
- for (const term of this.WEAK_NOISE) {
171
- if (queryLower && queryLower.includes(term)) continue
172
- if (lower.includes(term)) {
173
- weakHits++
174
- }
175
- }
176
- return weakHits >= 2
177
- }
178
-
179
- /**
180
- * D2: Improved deduplication
181
- * - ID-based dedup (if metadata has an ID)
182
- * - Prefix-based dedup (first 100 chars match)
183
- * - Word overlap dedup (lowered from 0.85 to 0.75)
184
- */
185
- private deduplicateResults(results: FilterableResult[]): FilterableResult[] {
186
- const kept: FilterableResult[] = []
187
- const seenIds = new Set<string>()
188
- const seenPrefixes = new Set<string>()
189
-
190
- for (const result of results) {
191
- // ID-based dedup
192
- const id = (result.metadata as any)?.id || (result.metadata as any)?.decision_id
193
- if (id && seenIds.has(id)) continue
194
- if (id) seenIds.add(id)
195
-
196
- // Prefix-based dedup (first 100 chars, normalized)
197
- const prefix = result.content.slice(0, 100).toLowerCase().replace(/\s+/g, ' ').trim()
198
- if (prefix.length > 20 && seenPrefixes.has(prefix)) continue
199
- if (prefix.length > 20) seenPrefixes.add(prefix)
200
-
201
- // Word overlap dedup (D2: lowered to 0.75)
202
- const isDuplicate = kept.some(existing => {
203
- const overlap = this.calculateWordOverlap(existing.content, result.content)
204
- return overlap > 0.75
205
- })
206
-
207
- if (!isDuplicate) {
208
- kept.push(result)
209
- }
210
- }
211
-
212
- return kept
213
- }
214
-
215
- /**
216
- * Apply dynamic threshold — results must be at least 70% of median score
217
- */
218
- private applyDynamicThreshold(results: FilterableResult[]): FilterableResult[] {
219
- if (results.length <= 1) return results
220
-
221
- // Calculate median score
222
- const scores = results.map(r => r.score).sort((a, b) => a - b)
223
- const mid = Math.floor(scores.length / 2)
224
- const left = scores[mid - 1] ?? 0
225
- const right = scores[mid] ?? 0
226
- const median = scores.length % 2 === 0
227
- ? (left + right) / 2
228
- : right
229
-
230
- const threshold = Math.max(median * 0.7, 0.45)
231
-
232
- return results.filter(r => r.score >= threshold)
233
- }
234
-
235
- /**
236
- * Calculate word overlap between two strings (0-1)
237
- */
238
- private calculateWordOverlap(a: string, b: string): number {
239
- const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2))
240
- const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2))
241
-
242
- if (wordsA.size === 0 || wordsB.size === 0) return 0
243
-
244
- let intersection = 0
245
- for (const word of wordsA) {
246
- if (wordsB.has(word)) intersection++
247
- }
248
-
249
- const smaller = Math.min(wordsA.size, wordsB.size)
250
- return intersection / smaller
251
- }
252
-
253
- /**
254
- * Generate a one-line relevance explanation
255
- */
256
- private generateRelevanceNote(result: FilterableResult, _query: string): string {
257
- const score = Math.round(result.score * 100)
258
- if (score >= 90) return `Highly relevant match (${score}%)`
259
- if (score >= 70) return `Good match (${score}%)`
260
- if (score >= 50) return `Partial match (${score}%)`
261
- return `Low relevance match (${score}%)`
262
- }
263
- }
264
-
265
- // =============================================================================
266
- // Phase 27: Progressive Disclosure Compact Response Formatters
267
- // =============================================================================
268
-
269
- /**
270
- * Truncate text to maxLen chars, appending "..." if truncated
271
- */
272
- function truncate(text: string, maxLen: number): string {
273
- if (!text) return ''
274
- if (text.length <= maxLen) return text
275
- return text.substring(0, maxLen).trimEnd() + '...'
276
- }
277
-
278
- /**
279
- * Format a date as a human-readable relative time string
280
- */
281
- function formatRelativeTime(dateStr: string | Date | undefined): string {
282
- if (!dateStr) return 'unknown'
283
- const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
284
- if (isNaN(date.getTime())) return 'unknown'
285
- const now = new Date()
286
- const diffMs = now.getTime() - date.getTime()
287
- const diffMins = Math.floor(diffMs / 60000)
288
- const diffHours = Math.floor(diffMs / 3600000)
289
- const diffDays = Math.floor(diffMs / 86400000)
290
-
291
- if (diffMins < 1) return 'just now'
292
- if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`
293
- if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
294
- if (diffDays === 1) return 'yesterday'
295
- if (diffDays < 7) return `${diffDays} days ago`
296
- if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? '' : 's'} ago`
297
- return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? '' : 's'} ago`
298
- }
299
-
300
- /**
301
- * Layer 1: Format a single result as a compact one-liner with metadata
302
- */
303
- export function formatCompactResult(result: any, index: number): string {
304
- const meta = result.metadata || {}
305
- const category = result.category || result.type || meta.category || result.source || 'memory'
306
- const summary = truncate(result.content || result.text || result.decision || '', 80)
307
- const project = result.project || meta.project || 'general'
308
- const timeAgo = formatRelativeTime(
309
- result.createdAt || result.created_at || result.timestamp ||
310
- result.date || meta.created_at || meta.createdAt || meta.date
311
- )
312
- const id = result.id || meta.id || meta.decision_id || 'unknown'
313
-
314
- return `${index}. [${category}] ${summary}\n Project: ${project} | ${timeAgo}\n ID: ${id}`
315
- }
316
-
317
- /**
318
- * Layer 1: Full compact response with header, compact items, and footer
319
- */
320
- export function formatCompactResponse(results: any[], query: string): string {
321
- if (results.length === 0) {
322
- return `No memories found for "${query}".`
323
- }
324
-
325
- const header = `Found ${results.length} relevant ${results.length === 1 ? 'memory' : 'memories'} for "${query}":`
326
- const items = results.map((r, i) => formatCompactResult(r, i + 1)).join('\n\n')
327
- const footer = `\nUse brain("details {ID}") for full context.`
328
-
329
- return header + '\n\n' + items + footer
330
- }
331
-
332
- /**
333
- * Layer 2: Full detail view for a single observation/memory
334
- */
335
- export function formatDetailResponse(observation: any): string {
336
- const category = observation.category || observation.type || 'Memory'
337
- const content = observation.content || observation.text || observation.decision || ''
338
- const context = observation.context || observation.metadata?.context || ''
339
- const reasoning = observation.reasoning || observation.metadata?.reasoning || ''
340
- const tags = observation.tags || observation.metadata?.tags || []
341
- const created = observation.createdAt || observation.created_at || observation.timestamp || ''
342
-
343
- const lines: string[] = []
344
- lines.push(`${category.charAt(0).toUpperCase() + category.slice(1)}: ${content}`)
345
- if (context) lines.push(`\nContext: ${context}`)
346
- if (reasoning) lines.push(`Reasoning: ${reasoning}`)
347
- if (tags.length > 0) lines.push(`Tags: ${Array.isArray(tags) ? tags.join(', ') : tags}`)
348
- if (created) lines.push(`Created: ${created}`)
349
-
350
- return lines.join('\n')
351
- }
352
-
353
- /**
354
- * Layer 3: Group observations by day for timeline view
355
- */
356
- export function groupByDay(observations: any[]): Map<string, any[]> {
357
- const groups = new Map<string, any[]>()
358
- for (const obs of observations) {
359
- const date = new Date(obs.createdAt || obs.created_at || obs.timestamp || Date.now())
360
- const dayKey = isNaN(date.getTime())
361
- ? 'Unknown'
362
- : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
363
- if (!groups.has(dayKey)) groups.set(dayKey, [])
364
- groups.get(dayKey)!.push(obs)
365
- }
366
- return groups
367
- }
368
-
369
- /**
370
- * Layer 3: Format a timeline from day-grouped observations
371
- */
372
- export function formatTimeline(grouped: Map<string, any[]>, project?: string): string {
373
- if (grouped.size === 0) {
374
- return project ? `No activity found for "${project}".` : 'No recent activity found.'
375
- }
376
-
377
- const header = project ? `Timeline for ${project}:` : 'Recent timeline:'
378
- const sections: string[] = [header, '']
379
-
380
- for (const [day, observations] of grouped) {
381
- sections.push(`${day}:`)
382
- for (const obs of observations) {
383
- const category = obs.category || obs.type || 'memory'
384
- const summary = truncate(obs.content || obs.text || obs.decision || '', 60)
385
- sections.push(` - [${category}] ${summary}`)
386
- }
387
- sections.push('')
388
- }
389
-
390
- return sections.join('\n').trim()
391
- }
1
+ /**
2
+ * Brain Response Filter
3
+ * Phase 16 + Phase 19: Filters noise, deduplicates, ranks, and synthesizes results
4
+ *
5
+ * Phase 19 changes:
6
+ * - D2: Lower word overlap threshold 0.85 → 0.75, add ID-based and prefix-based dedup
7
+ * - D3: Split INFRA_NOISE into strong (1 hit = filter) and weak (2+ hits = filter)
8
+ */
9
+
10
+ export interface FilterableResult {
11
+ content: string
12
+ score: number
13
+ source: string
14
+ metadata?: Record<string, unknown>
15
+ }
16
+
17
+ export interface FilteredResult {
18
+ id?: string
19
+ content: string
20
+ score: number
21
+ source: string
22
+ relevanceNote: string
23
+ metadata?: Record<string, unknown>
24
+ }
25
+
26
+ export interface BrainResponse {
27
+ action: 'retrieved' | 'stored' | 'analyzed' | 'none'
28
+ summary: string
29
+ content: string
30
+ relevantItems: number
31
+ }
32
+
33
+ export interface TierResults {
34
+ label: string
35
+ results: FilterableResult[]
36
+ }
37
+
38
+ export class ResponseFilter {
39
+ // D3: Strong noise — 1 hit is enough to filter (very specific internal terms)
40
+ private readonly STRONG_NOISE = [
41
+ 'mcp-server', 'model-context-protocol', 'embedding-service', 'vector-database',
42
+ 'mcp tool', 'tool handler', 'precompute engine', 'knowledge graph builder',
43
+ 'semantic cache', 'bun:test'
44
+ ]
45
+
46
+ // D3: Weak noise — need 2+ hits to filter (terms that could appear in legitimate decisions)
47
+ private readonly WEAK_NOISE = [
48
+ 'chromadb', 'minisearch', 'compromise', 'better-sqlite3',
49
+ 'pino', 'claude-brain',
50
+ 'phase 12', 'phase 13', 'phase 14', 'phase 15', 'phase 16',
51
+ 'phase 17', 'phase 18', 'phase 19'
52
+ ]
53
+
54
+ /**
55
+ * Filter a set of results to remove noise and improve relevance
56
+ */
57
+ filter(results: FilterableResult[], query: string, project?: string): FilteredResult[] {
58
+ let filtered = results
59
+
60
+ // 1. Remove infrastructure noise (if project !== 'claude-brain')
61
+ if (project !== 'claude-brain') {
62
+ const queryLower = query.toLowerCase()
63
+ filtered = filtered.filter(r => !this.isInfrastructureNoise(r.content, queryLower))
64
+ }
65
+
66
+ // 2. Remove near-duplicates (Phase 19 D2: improved dedup)
67
+ filtered = this.deduplicateResults(filtered)
68
+
69
+ // 3. Apply dynamic threshold (at least 70% of median similarity)
70
+ const preThreshold = filtered
71
+ filtered = this.applyDynamicThreshold(filtered)
72
+
73
+ // Safety valve: if threshold killed everything but we had results, keep the best one
74
+ if (filtered.length === 0 && preThreshold.length > 0) {
75
+ const best = preThreshold.reduce((a, b) => a.score > b.score ? a : b)
76
+ filtered = [best]
77
+ }
78
+
79
+ // 4. Sort by score descending
80
+ filtered.sort((a, b) => b.score - a.score)
81
+
82
+ // 5. Limit to 5 results
83
+ filtered = filtered.slice(0, 5)
84
+
85
+ // 6. Add one-line relevance explanation per result (preserve id from metadata)
86
+ return filtered.map(r => ({
87
+ id: (r.metadata as Record<string, unknown> | undefined)?.id as string | undefined || (r.metadata as Record<string, unknown> | undefined)?.decision_id as string | undefined,
88
+ content: r.content,
89
+ score: r.score,
90
+ source: r.source,
91
+ relevanceNote: this.generateRelevanceNote(r, query),
92
+ metadata: r.metadata
93
+ }))
94
+ }
95
+
96
+ /**
97
+ * Synthesize results from multiple tiers into a unified BrainResponse
98
+ */
99
+ synthesize(
100
+ tiers: TierResults[],
101
+ message: string,
102
+ project?: string,
103
+ action: BrainResponse['action'] = 'retrieved'
104
+ ): BrainResponse {
105
+ // Combine all results from all tiers
106
+ const allResults: FilterableResult[] = []
107
+ for (const tier of tiers) {
108
+ allResults.push(...tier.results)
109
+ }
110
+
111
+ if (allResults.length === 0) {
112
+ return {
113
+ action: 'none',
114
+ summary: 'No relevant information found',
115
+ content: `No results found for: "${message.slice(0, 100)}"`,
116
+ relevantItems: 0
117
+ }
118
+ }
119
+
120
+ // Filter the combined results
121
+ const filtered = this.filter(allResults, message, project)
122
+
123
+ if (filtered.length === 0) {
124
+ return {
125
+ action: 'none',
126
+ summary: 'Results filtered out as noise or irrelevant',
127
+ content: `No relevant results after filtering for: "${message.slice(0, 100)}"`,
128
+ relevantItems: 0
129
+ }
130
+ }
131
+
132
+ // Format filtered results
133
+ const contentParts: string[] = []
134
+ for (const result of filtered) {
135
+ const scoreStr = result.score > 0 ? ` [${Math.round(result.score * 100)}%]` : ''
136
+ // Defensive: ensure content is always a string (prevents [object Object])
137
+ const contentStr = typeof result.content === 'string'
138
+ ? result.content
139
+ : JSON.stringify(result.content ?? '')
140
+ contentParts.push(`**${result.source}**${scoreStr}\n${contentStr}`)
141
+ if (result.relevanceNote) {
142
+ contentParts.push(`_${result.relevanceNote}_`)
143
+ }
144
+ contentParts.push('')
145
+ }
146
+
147
+ const summary = filtered.length === 1
148
+ ? `Found 1 relevant result`
149
+ : `Found ${filtered.length} relevant results`
150
+
151
+ return {
152
+ action,
153
+ summary,
154
+ content: contentParts.join('\n'),
155
+ relevantItems: filtered.length
156
+ }
157
+ }
158
+
159
+ /**
160
+ * D3: Check if content is infrastructure/internal noise
161
+ * Strong noise: 1 hit filters (unless the term appears in the query)
162
+ * Weak noise: 2+ hits filters (skipping terms that appear in the query)
163
+ */
164
+ private isInfrastructureNoise(content: string, queryLower?: string): boolean {
165
+ const lower = content.toLowerCase()
166
+
167
+ // Strong noise: a single hit is enough (but skip terms the user asked about)
168
+ for (const term of this.STRONG_NOISE) {
169
+ if (queryLower && queryLower.includes(term)) continue
170
+ if (lower.includes(term)) {
171
+ return true
172
+ }
173
+ }
174
+
175
+ // Weak noise: need 2+ hits (skip terms the user asked about)
176
+ let weakHits = 0
177
+ for (const term of this.WEAK_NOISE) {
178
+ if (queryLower && queryLower.includes(term)) continue
179
+ if (lower.includes(term)) {
180
+ weakHits++
181
+ }
182
+ }
183
+ return weakHits >= 2
184
+ }
185
+
186
+ /**
187
+ * D2: Improved deduplication
188
+ * - ID-based dedup (if metadata has an ID)
189
+ * - Prefix-based dedup (first 100 chars match)
190
+ * - Word overlap dedup (lowered from 0.85 to 0.75)
191
+ */
192
+ private deduplicateResults(results: FilterableResult[]): FilterableResult[] {
193
+ const kept: FilterableResult[] = []
194
+ const seenIds = new Set<string>()
195
+ const seenPrefixes = new Set<string>()
196
+
197
+ for (const result of results) {
198
+ // ID-based dedup
199
+ const id = (result.metadata as Record<string, unknown> | undefined)?.id as string | undefined || (result.metadata as Record<string, unknown> | undefined)?.decision_id as string | undefined
200
+ if (id && seenIds.has(id)) continue
201
+ if (id) seenIds.add(id)
202
+
203
+ // Prefix-based dedup (first 100 chars, normalized)
204
+ const prefix = result.content.slice(0, 100).toLowerCase().replace(/\s+/g, ' ').trim()
205
+ if (prefix.length > 20 && seenPrefixes.has(prefix)) continue
206
+ if (prefix.length > 20) seenPrefixes.add(prefix)
207
+
208
+ // Word overlap dedup (D2: lowered to 0.75)
209
+ const isDuplicate = kept.some(existing => {
210
+ const overlap = this.calculateWordOverlap(existing.content, result.content)
211
+ return overlap > 0.75
212
+ })
213
+
214
+ if (!isDuplicate) {
215
+ kept.push(result)
216
+ }
217
+ }
218
+
219
+ return kept
220
+ }
221
+
222
+ /**
223
+ * Apply dynamic threshold results must be at least 70% of median score
224
+ */
225
+ private applyDynamicThreshold(results: FilterableResult[]): FilterableResult[] {
226
+ if (results.length <= 1) return results
227
+
228
+ // Calculate median score
229
+ const scores = results.map(r => r.score).sort((a, b) => a - b)
230
+ const mid = Math.floor(scores.length / 2)
231
+ const left = scores[mid - 1] ?? 0
232
+ const right = scores[mid] ?? 0
233
+ const median = scores.length % 2 === 0
234
+ ? (left + right) / 2
235
+ : right
236
+
237
+ const threshold = Math.max(median * 0.7, 0.25)
238
+
239
+ return results.filter(r => r.score >= threshold)
240
+ }
241
+
242
+ /**
243
+ * Calculate word overlap between two strings (0-1)
244
+ */
245
+ private calculateWordOverlap(a: string, b: string): number {
246
+ const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2))
247
+ const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2))
248
+
249
+ if (wordsA.size === 0 || wordsB.size === 0) return 0
250
+
251
+ let intersection = 0
252
+ for (const word of wordsA) {
253
+ if (wordsB.has(word)) intersection++
254
+ }
255
+
256
+ const smaller = Math.min(wordsA.size, wordsB.size)
257
+ return intersection / smaller
258
+ }
259
+
260
+ /**
261
+ * Generate a one-line relevance explanation
262
+ */
263
+ private generateRelevanceNote(result: FilterableResult, _query: string): string {
264
+ const score = Math.round(result.score * 100)
265
+ if (score >= 90) return `Highly relevant match (${score}%)`
266
+ if (score >= 70) return `Good match (${score}%)`
267
+ if (score >= 50) return `Partial match (${score}%)`
268
+ return `Low relevance match (${score}%)`
269
+ }
270
+ }
271
+
272
+ // =============================================================================
273
+ // Phase 27: Progressive Disclosure — Compact Response Formatters
274
+ // =============================================================================
275
+
276
+ /**
277
+ * Truncate text to maxLen chars, appending "..." if truncated
278
+ */
279
+ function truncate(text: string, maxLen: number): string {
280
+ if (!text) return ''
281
+ if (text.length <= maxLen) return text
282
+ return text.substring(0, maxLen).trimEnd() + '...'
283
+ }
284
+
285
+ /**
286
+ * Format a date as a human-readable relative time string
287
+ */
288
+ function formatRelativeTime(dateStr: string | Date | undefined): string {
289
+ if (!dateStr) return 'unknown'
290
+ const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
291
+ if (isNaN(date.getTime())) return 'unknown'
292
+ const now = new Date()
293
+ const diffMs = now.getTime() - date.getTime()
294
+ const diffMins = Math.floor(diffMs / 60000)
295
+ const diffHours = Math.floor(diffMs / 3600000)
296
+ const diffDays = Math.floor(diffMs / 86400000)
297
+
298
+ if (diffMins < 1) return 'just now'
299
+ if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`
300
+ if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
301
+ if (diffDays === 1) return 'yesterday'
302
+ if (diffDays < 7) return `${diffDays} days ago`
303
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) === 1 ? '' : 's'} ago`
304
+ return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) === 1 ? '' : 's'} ago`
305
+ }
306
+
307
+ /**
308
+ * Layer 1: Format a single result as a compact one-liner with metadata
309
+ */
310
+ export function formatCompactResult(result: Record<string, unknown>, index: number): string {
311
+ const meta = (result.metadata || {}) as Record<string, unknown>
312
+ const category = result.category || result.type || meta.category || result.source || 'memory'
313
+ const summary = truncate(String(result.content || result.text || result.decision || ''), 80)
314
+ const project = result.project || meta.project || 'general'
315
+ const timeAgo = formatRelativeTime(
316
+ (result.createdAt || result.created_at || result.timestamp ||
317
+ result.date || meta.created_at || meta.createdAt || meta.date) as string | Date | undefined
318
+ )
319
+ const id = result.id || meta.id || meta.decision_id || 'unknown'
320
+
321
+ return `${index}. [${category}] ${summary}\n Project: ${project} | ${timeAgo}\n ID: ${id}`
322
+ }
323
+
324
+ /**
325
+ * Layer 1: Full compact response with header, compact items, and footer
326
+ */
327
+ export function formatCompactResponse(results: Record<string, unknown>[], query: string): string {
328
+ if (results.length === 0) {
329
+ return `No memories found for "${query}".`
330
+ }
331
+
332
+ const header = `Found ${results.length} relevant ${results.length === 1 ? 'memory' : 'memories'} for "${query}":`
333
+ const items = results.map((r, i) => formatCompactResult(r, i + 1)).join('\n\n')
334
+ const footer = `\nUse brain("details {ID}") for full context.`
335
+
336
+ return header + '\n\n' + items + footer
337
+ }
338
+
339
+ /**
340
+ * Layer 2: Full detail view for a single observation/memory
341
+ */
342
+ export function formatDetailResponse(observation: Record<string, unknown>): string {
343
+ const meta = (observation.metadata || {}) as Record<string, unknown>
344
+ const category = String(observation.category || observation.type || 'Memory')
345
+ const content = String(observation.content || observation.text || observation.decision || '')
346
+ const context = String(observation.context || meta.context || '')
347
+ const reasoning = String(observation.reasoning || meta.reasoning || '')
348
+ const tags = (observation.tags || meta.tags || []) as unknown
349
+ const created = String(observation.createdAt || observation.created_at || observation.timestamp || '')
350
+
351
+ const lines: string[] = []
352
+ lines.push(`${category.charAt(0).toUpperCase() + category.slice(1)}: ${content}`)
353
+ if (context) lines.push(`\nContext: ${context}`)
354
+ if (reasoning) lines.push(`Reasoning: ${reasoning}`)
355
+ if (tags.length > 0) lines.push(`Tags: ${Array.isArray(tags) ? tags.join(', ') : tags}`)
356
+ if (created) lines.push(`Created: ${created}`)
357
+
358
+ return lines.join('\n')
359
+ }
360
+
361
+ /**
362
+ * Layer 3: Group observations by day for timeline view
363
+ */
364
+ export function groupByDay(observations: Record<string, unknown>[]): Map<string, Record<string, unknown>[]> {
365
+ const groups = new Map<string, Record<string, unknown>[]>()
366
+ for (const obs of observations) {
367
+ const date = new Date((obs.createdAt || obs.created_at || obs.timestamp || Date.now()) as string | number)
368
+ const dayKey = isNaN(date.getTime())
369
+ ? 'Unknown'
370
+ : date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
371
+ if (!groups.has(dayKey)) groups.set(dayKey, [])
372
+ groups.get(dayKey)!.push(obs)
373
+ }
374
+ return groups
375
+ }
376
+
377
+ /**
378
+ * Layer 3: Format a timeline from day-grouped observations
379
+ */
380
+ export function formatTimeline(grouped: Map<string, Record<string, unknown>[]>, project?: string): string {
381
+ if (grouped.size === 0) {
382
+ return project ? `No activity found for "${project}".` : 'No recent activity found.'
383
+ }
384
+
385
+ const header = project ? `Timeline for ${project}:` : 'Recent timeline:'
386
+ const sections: string[] = [header, '']
387
+
388
+ for (const [day, observations] of grouped) {
389
+ sections.push(`${day}:`)
390
+ for (const obs of observations) {
391
+ const category = obs.category || obs.type || 'memory'
392
+ const summary = truncate(String(obs.content || obs.text || obs.decision || ''), 60)
393
+ sections.push(` - [${category}] ${summary}`)
394
+ }
395
+ sections.push('')
396
+ }
397
+
398
+ return sections.join('\n').trim()
399
+ }