claude-brain 0.9.3 → 0.13.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 (66) hide show
  1. package/VERSION +1 -1
  2. package/assets/CLAUDE.md +9 -1
  3. package/package.json +1 -1
  4. package/src/automation/phase12-manager.ts +456 -0
  5. package/src/automation/project-detector.ts +13 -0
  6. package/src/automation/repo-scanner.ts +205 -0
  7. package/src/cli/bin.ts +30 -0
  8. package/src/cli/commands/git-hook.ts +189 -0
  9. package/src/cli/commands/hooks.ts +8 -9
  10. package/src/cli/commands/init.ts +98 -0
  11. package/src/cli/commands/serve.ts +7 -20
  12. package/src/cli/commands/update.ts +3 -3
  13. package/src/config/defaults.ts +4 -1
  14. package/src/config/schema.ts +27 -7
  15. package/src/hooks/brain-hook.ts +8 -6
  16. package/src/hooks/capture.ts +9 -2
  17. package/src/hooks/git-capture.ts +94 -0
  18. package/src/hooks/git-hook-installer.ts +197 -0
  19. package/src/hooks/index.ts +1 -0
  20. package/src/hooks/session-tracker.ts +79 -3
  21. package/src/hooks/types.ts +1 -1
  22. package/src/intelligence/index.ts +24 -0
  23. package/src/knowledge/graph/builder.ts +26 -0
  24. package/src/memory/chroma/store.ts +18 -2
  25. package/src/memory/episodic/manager.ts +17 -0
  26. package/src/memory/index.ts +48 -18
  27. package/src/phase12/index.ts +3 -454
  28. package/src/routing/intent-classifier.ts +107 -9
  29. package/src/routing/response-filter.ts +50 -17
  30. package/src/routing/router.ts +472 -224
  31. package/src/routing/search-engine.ts +464 -0
  32. package/src/routing/types.ts +84 -0
  33. package/src/server/handlers/call-tool.ts +4 -49
  34. package/src/server/handlers/tools/analyze-decision-evolution.ts +1 -1
  35. package/src/server/handlers/tools/detect-trends.ts +1 -1
  36. package/src/server/handlers/tools/find-cross-project-patterns.ts +1 -1
  37. package/src/server/handlers/tools/get-decision-timeline.ts +2 -2
  38. package/src/server/handlers/tools/get-recommendations.ts +1 -1
  39. package/src/server/handlers/tools/index.ts +5 -7
  40. package/src/server/handlers/tools/what-if-analysis.ts +1 -1
  41. package/src/server/providers/resources.ts +195 -0
  42. package/src/server/services.ts +81 -6
  43. package/src/tools/schemas.ts +7 -329
  44. package/src/utils/phase12-helper.ts +2 -2
  45. package/src/utils/timing.ts +47 -0
  46. package/src/vault/writer.ts +22 -2
  47. /package/src/{cross-project → intelligence/cross-project}/affinity.ts +0 -0
  48. /package/src/{cross-project → intelligence/cross-project}/generalizer.ts +0 -0
  49. /package/src/{cross-project → intelligence/cross-project}/index.ts +0 -0
  50. /package/src/{cross-project → intelligence/cross-project}/transfer.ts +0 -0
  51. /package/src/{optimization → intelligence/optimization}/index.ts +0 -0
  52. /package/src/{optimization → intelligence/optimization}/precompute.ts +0 -0
  53. /package/src/{optimization → intelligence/optimization}/semantic-cache.ts +0 -0
  54. /package/src/{prediction → intelligence/prediction}/context-anticipator.ts +0 -0
  55. /package/src/{prediction → intelligence/prediction}/decision-predictor.ts +0 -0
  56. /package/src/{prediction → intelligence/prediction}/index.ts +0 -0
  57. /package/src/{prediction → intelligence/prediction}/recommender.ts +0 -0
  58. /package/src/{reasoning → intelligence/reasoning}/chain-retrieval.ts +0 -0
  59. /package/src/{reasoning → intelligence/reasoning}/counterfactual.ts +0 -0
  60. /package/src/{reasoning → intelligence/reasoning}/index.ts +0 -0
  61. /package/src/{reasoning → intelligence/reasoning}/synthesizer.ts +0 -0
  62. /package/src/{temporal → intelligence/temporal}/evolution.ts +0 -0
  63. /package/src/{temporal → intelligence/temporal}/index.ts +0 -0
  64. /package/src/{temporal → intelligence/temporal}/query-processor.ts +0 -0
  65. /package/src/{temporal → intelligence/temporal}/timeline.ts +0 -0
  66. /package/src/{temporal → intelligence/temporal}/trends.ts +0 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Phase 21: Git Hook Installer
3
+ * Installs/uninstalls a post-commit git hook that captures commit data into brain.
4
+ */
5
+
6
+ import {
7
+ readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync
8
+ } from 'node:fs'
9
+ import { join, resolve } from 'node:path'
10
+ import { execSync } from 'node:child_process'
11
+
12
+ const HOOK_MARKER = '# claude-brain-git-hook'
13
+ const HOOK_FILENAME = 'post-commit'
14
+
15
+ /**
16
+ * Build the hook script content that gets appended to .git/hooks/post-commit.
17
+ * Runs in background (&) so it doesn't slow down git.
18
+ */
19
+ function buildHookScript(): string {
20
+ const lines = [
21
+ '',
22
+ HOOK_MARKER,
23
+ '# Capture commit data into Claude Brain (runs in background)',
24
+ '(',
25
+ ' BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)',
26
+ ' MESSAGE=$(git log -1 --pretty=%B 2>/dev/null)',
27
+ ' FILES=$(git diff-tree --no-commit-id --name-only -r HEAD 2>/dev/null | tr "\\n" "," | sed "s/,$//")',
28
+ ' PROJECT=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)")',
29
+ ' PORT=${CLAUDE_BRAIN_PORT:-3000}',
30
+ ' claude-brain git-capture "$PROJECT" "$BRANCH" "$MESSAGE" "$FILES" "$PORT" 2>/dev/null',
31
+ ') &',
32
+ `${HOOK_MARKER}-end`,
33
+ '',
34
+ ]
35
+ return lines.join('\n')
36
+ }
37
+
38
+ /**
39
+ * Find the .git directory for a given path.
40
+ * Returns null if not in a git repo.
41
+ */
42
+ function findGitDir(repoPath?: string): string | null {
43
+ const cwd = repoPath ? resolve(repoPath) : process.cwd()
44
+ try {
45
+ const gitDir = execSync('git rev-parse --git-dir', {
46
+ cwd,
47
+ encoding: 'utf-8',
48
+ stdio: ['pipe', 'pipe', 'pipe'],
49
+ }).trim()
50
+ // git-dir can be relative; resolve against cwd
51
+ return resolve(cwd, gitDir)
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Install the post-commit hook into a git repository.
59
+ * Appends to existing hook if one exists; creates new if not.
60
+ */
61
+ export function installGitHook(repoPath?: string): { installed: boolean; message: string; hookPath: string } {
62
+ const gitDir = findGitDir(repoPath)
63
+ if (!gitDir) {
64
+ return { installed: false, message: 'Not a git repository', hookPath: '' }
65
+ }
66
+
67
+ const hooksDir = join(gitDir, 'hooks')
68
+ const hookPath = join(hooksDir, HOOK_FILENAME)
69
+
70
+ // Already installed?
71
+ if (isGitHookInstalled(repoPath)) {
72
+ return { installed: true, message: 'Git hook already installed', hookPath }
73
+ }
74
+
75
+ // Ensure hooks dir exists
76
+ if (!existsSync(hooksDir)) {
77
+ mkdirSync(hooksDir, { recursive: true })
78
+ }
79
+
80
+ // Read existing hook or start fresh
81
+ let existing = ''
82
+ if (existsSync(hookPath)) {
83
+ existing = readFileSync(hookPath, 'utf-8')
84
+ } else {
85
+ existing = '#!/bin/sh\n'
86
+ }
87
+
88
+ // Append our section
89
+ const combined = existing.trimEnd() + '\n' + buildHookScript()
90
+ writeFileSync(hookPath, combined, 'utf-8')
91
+ chmodSync(hookPath, 0o755)
92
+
93
+ return { installed: true, message: 'Git hook installed successfully', hookPath }
94
+ }
95
+
96
+ /**
97
+ * Uninstall the post-commit hook from a git repository.
98
+ * Removes only our section, preserving the rest.
99
+ */
100
+ export function uninstallGitHook(repoPath?: string): { uninstalled: boolean; message: string } {
101
+ const gitDir = findGitDir(repoPath)
102
+ if (!gitDir) {
103
+ return { uninstalled: false, message: 'Not a git repository' }
104
+ }
105
+
106
+ const hookPath = join(gitDir, 'hooks', HOOK_FILENAME)
107
+
108
+ if (!existsSync(hookPath)) {
109
+ return { uninstalled: true, message: 'No post-commit hook found' }
110
+ }
111
+
112
+ const content = readFileSync(hookPath, 'utf-8')
113
+ if (!content.includes(HOOK_MARKER)) {
114
+ return { uninstalled: true, message: 'Claude Brain hook not found in post-commit' }
115
+ }
116
+
117
+ // Remove our section (from marker to end-marker)
118
+ const lines = content.split('\n')
119
+ const filtered: string[] = []
120
+ let inOurSection = false
121
+
122
+ for (const line of lines) {
123
+ if (line.trim() === HOOK_MARKER) {
124
+ inOurSection = true
125
+ continue
126
+ }
127
+ if (line.trim() === `${HOOK_MARKER}-end`) {
128
+ inOurSection = false
129
+ continue
130
+ }
131
+ if (!inOurSection) {
132
+ filtered.push(line)
133
+ }
134
+ }
135
+
136
+ const cleaned = filtered.join('\n').trimEnd() + '\n'
137
+
138
+ // If only shebang remains, remove the hook file entirely
139
+ if (cleaned.trim() === '#!/bin/sh' || cleaned.trim() === '#!/bin/bash' || cleaned.trim() === '') {
140
+ unlinkSync(hookPath)
141
+ return { uninstalled: true, message: 'Git hook removed (file deleted — was empty)' }
142
+ }
143
+
144
+ writeFileSync(hookPath, cleaned, 'utf-8')
145
+ return { uninstalled: true, message: 'Git hook removed successfully' }
146
+ }
147
+
148
+ /**
149
+ * Check if our hook is installed in a git repository.
150
+ */
151
+ export function isGitHookInstalled(repoPath?: string): boolean {
152
+ const gitDir = findGitDir(repoPath)
153
+ if (!gitDir) return false
154
+
155
+ const hookPath = join(gitDir, 'hooks', HOOK_FILENAME)
156
+ if (!existsSync(hookPath)) return false
157
+
158
+ const content = readFileSync(hookPath, 'utf-8')
159
+ return content.includes(HOOK_MARKER)
160
+ }
161
+
162
+ /**
163
+ * Scan a directory for git repos and install hooks in all of them.
164
+ */
165
+ export async function installGitHookAll(dir: string): Promise<{ installed: number; skipped: number; errors: number }> {
166
+ const results = { installed: 0, skipped: 0, errors: 0 }
167
+ const absDir = resolve(dir)
168
+
169
+ if (!existsSync(absDir)) {
170
+ return results
171
+ }
172
+
173
+ // Find directories with .git
174
+ const entries = readdirSync(absDir, { withFileTypes: true })
175
+
176
+ for (const entry of entries) {
177
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
178
+
179
+ const candidatePath = join(absDir, entry.name)
180
+ const gitPath = join(candidatePath, '.git')
181
+
182
+ if (existsSync(gitPath)) {
183
+ try {
184
+ const result = installGitHook(candidatePath)
185
+ if (result.installed && result.message !== 'Git hook already installed') {
186
+ results.installed++
187
+ } else {
188
+ results.skipped++
189
+ }
190
+ } catch {
191
+ results.errors++
192
+ }
193
+ }
194
+ }
195
+
196
+ return results
197
+ }
@@ -17,3 +17,4 @@ export { SmartDeduplicator } from './deduplicator'
17
17
  export { HookSessionTracker } from './session-tracker'
18
18
  export { appendToQueue, readQueue, clearQueue, drainQueue, getQueuePath } from './queue'
19
19
  export { installHooks, uninstallHooks, isHooksInstalled, getHookScriptPath } from './installer'
20
+ export { main as captureMain } from './brain-hook'
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { Logger } from 'pino'
8
8
  import type { EpisodeManager } from '@/memory/episodic/manager'
9
+ import type { MemoryManager } from '@/memory'
9
10
  import { ExtractiveSummarizer } from '@/memory/episodic/summarizer'
10
11
  import type { CapturedKnowledge } from './types'
11
12
  import type { HooksConfig } from '@/config/schema'
@@ -22,6 +23,7 @@ interface SessionState {
22
23
  export class HookSessionTracker {
23
24
  private logger: Logger
24
25
  private episodeManager: EpisodeManager | null
26
+ private memoryManager: MemoryManager | null
25
27
  private summarizer: ExtractiveSummarizer
26
28
  private sessions: Map<string, SessionState> = new Map()
27
29
  private idleTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
@@ -31,10 +33,12 @@ export class HookSessionTracker {
31
33
  constructor(
32
34
  logger: Logger,
33
35
  episodeManager: EpisodeManager | null,
34
- config?: HooksConfig['sessions']
36
+ config?: HooksConfig['sessions'],
37
+ memoryManager?: MemoryManager | null
35
38
  ) {
36
39
  this.logger = logger.child({ component: 'hook-session-tracker' })
37
40
  this.episodeManager = episodeManager
41
+ this.memoryManager = memoryManager ?? null
38
42
  this.summarizer = new ExtractiveSummarizer()
39
43
  this.idleTimeoutMs = (config?.idleTimeoutMinutes ?? 30) * 60 * 1000
40
44
  this.minEventsForSummary = config?.minEventsForSummary ?? 3
@@ -128,6 +132,50 @@ export class HookSessionTracker {
128
132
  await this.endSession(sessionId)
129
133
  }
130
134
 
135
+ /**
136
+ * Build a structured summary from session items.
137
+ * Format: "Did: ...; Decided: ...; Files: ..."
138
+ */
139
+ private buildStructuredSummary(session: SessionState): string {
140
+ const actions: string[] = []
141
+ const decisions: string[] = []
142
+ const files = new Set<string>()
143
+
144
+ for (const item of session.items) {
145
+ // Extract file paths from metadata
146
+ if (item.metadata?.file_path) {
147
+ files.add(item.metadata.file_path)
148
+ }
149
+ if (item.metadata?.files) {
150
+ for (const f of Array.isArray(item.metadata.files) ? item.metadata.files : [item.metadata.files]) {
151
+ files.add(f)
152
+ }
153
+ }
154
+
155
+ if (item.type === 'decision') {
156
+ decisions.push(item.content.slice(0, 80))
157
+ } else if (item.type === 'progress') {
158
+ actions.push(item.content.slice(0, 80))
159
+ } else if (item.type === 'pattern' || item.type === 'correction') {
160
+ actions.push(`[${item.type}] ${item.content.slice(0, 60)}`)
161
+ }
162
+ }
163
+
164
+ const parts: string[] = []
165
+ if (actions.length > 0) {
166
+ parts.push(`Did: ${actions.slice(0, 5).join('; ')}`)
167
+ }
168
+ if (decisions.length > 0) {
169
+ parts.push(`Decided: ${decisions.slice(0, 3).join('; ')}`)
170
+ }
171
+ if (files.size > 0) {
172
+ const fileList = [...files].slice(0, 8)
173
+ parts.push(`Files: ${fileList.join(', ')}`)
174
+ }
175
+
176
+ return parts.join('. ') || `Session with ${session.items.length} events`
177
+ }
178
+
131
179
  private async summarizeAndPersist(session: SessionState): Promise<void> {
132
180
  if (session.items.length < this.minEventsForSummary) {
133
181
  this.logger.debug(
@@ -176,7 +224,10 @@ export class HookSessionTracker {
176
224
  message_count: messages.length,
177
225
  }
178
226
 
179
- const summary = this.summarizer.summarize(syntheticEpisode)
227
+ const extractiveSummary = this.summarizer.summarize(syntheticEpisode)
228
+
229
+ // Phase 21: Build structured summary alongside extractive
230
+ const structuredSummary = this.buildStructuredSummary(session)
180
231
 
181
232
  // End the episode with summary attached
182
233
  if (session.episodeId && this.episodeManager) {
@@ -191,8 +242,33 @@ export class HookSessionTracker {
191
242
  }
192
243
  }
193
244
 
245
+ // Phase 20+21: Store session summary as a searchable decision
246
+ // Uses structured summary for better auto-context readability
247
+ if (this.memoryManager && (extractiveSummary.brief || structuredSummary)) {
248
+ try {
249
+ const project = session.project || 'general'
250
+ const technologies = [...new Set(session.items.flatMap(i => i.technologies))]
251
+ const summaryContent = `Session summary: ${structuredSummary}${extractiveSummary.key_topics.length > 0 ? ` (Topics: ${extractiveSummary.key_topics.join(', ')})` : ''}`
252
+
253
+ await this.memoryManager.rememberDecision(
254
+ project,
255
+ 'Auto-captured session summary',
256
+ summaryContent,
257
+ 'Session ended — auto-summarized by passive hooks',
258
+ { tags: ['session-summary', ...technologies] }
259
+ )
260
+
261
+ this.logger.info(
262
+ { sessionId: session.sessionId, project },
263
+ 'Session summary stored as searchable memory'
264
+ )
265
+ } catch (err) {
266
+ this.logger.warn({ err, sessionId: session.sessionId }, 'Failed to store session summary')
267
+ }
268
+ }
269
+
194
270
  this.logger.info(
195
- { sessionId: session.sessionId, summary: summary.brief },
271
+ { sessionId: session.sessionId, summary: structuredSummary },
196
272
  'Session summarized'
197
273
  )
198
274
  }
@@ -5,7 +5,7 @@
5
5
  /** Claude Code hook stdin JSON format */
6
6
  export interface HookInput {
7
7
  session_id: string
8
- hook_event_name: 'PostToolUse' | 'Stop' | 'PreToolUse'
8
+ hook_event_name: 'PostToolUse' | 'Stop' | 'PreToolUse' | 'GitCommit'
9
9
  cwd: string
10
10
  tool_name?: string
11
11
  tool_input?: Record<string, any>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Intelligence Module
3
+ * Phase 22: Consolidates advanced intelligence features under one namespace.
4
+ *
5
+ * Features are lazy-loaded to avoid startup cost when not needed.
6
+ * Gate on config.intelligence?.enabled for opt-in activation.
7
+ */
8
+
9
+ import type { Config } from '@/config'
10
+
11
+ export async function loadIntelligence(config: Config) {
12
+ if (!(config as any).intelligence?.enabled) return null
13
+ return {
14
+ prediction: await import('./prediction'),
15
+ reasoning: await import('./reasoning'),
16
+ temporal: await import('./temporal'),
17
+ optimization: await import('./optimization'),
18
+ crossProject: await import('./cross-project')
19
+ }
20
+ }
21
+
22
+ // Re-export submodule paths for direct imports
23
+ export * from './optimization/semantic-cache'
24
+ export * from './optimization/precompute'
@@ -27,8 +27,34 @@ export class KnowledgeGraphBuilder {
27
27
  await this.entityExtractor.initialize()
28
28
  }
29
29
 
30
+ /**
31
+ * Remove a decision node and its edges from the knowledge graph by decision ID.
32
+ * Used when a decision is deleted from the store.
33
+ */
34
+ removeDecision(decisionId: string): void {
35
+ try {
36
+ const nodes = this.graph.findNodes({
37
+ type: 'decision',
38
+ properties: { decision_id: decisionId }
39
+ })
40
+
41
+ for (const node of nodes) {
42
+ this.graph.deleteNode(node.id)
43
+ }
44
+
45
+ if (nodes.length > 0) {
46
+ this.logger.debug({ decisionId, nodesRemoved: nodes.length }, 'Decision removed from knowledge graph')
47
+ }
48
+ } catch (error) {
49
+ this.logger.error({ error, decisionId }, 'Failed to remove decision from graph')
50
+ }
51
+ }
52
+
30
53
  processDecision(input: StoreDecisionInput & { id: string }): void {
31
54
  try {
55
+ // Dedup guard: remove existing node with same decision_id before re-creating
56
+ this.removeDecision(input.id)
57
+
32
58
  const fullText = `${input.decision} ${input.context} ${input.reasoning}`
33
59
  const entities = this.entityExtractor.extract(fullText)
34
60
  const relationships = this.relationshipExtractor.extract(fullText, entities)
@@ -179,7 +179,12 @@ export class ChromaMemoryStore {
179
179
  confidence: 1.0,
180
180
  created_at: now,
181
181
  updated_at: now,
182
- decision_id: id
182
+ decision_id: id,
183
+ // Phase 19: Include decision fields so memories collection results
184
+ // can surface decision content without cross-collection lookup
185
+ decision: input.decision,
186
+ reasoning: input.reasoning,
187
+ context: input.context
183
188
  }
184
189
 
185
190
  await memoriesCollection.add({
@@ -493,6 +498,17 @@ export class ChromaMemoryStore {
493
498
  const collection = await this.collections.getDecisions()
494
499
  await collection.delete({ ids: [id] })
495
500
 
501
+ // Verify deletion succeeded
502
+ try {
503
+ const verify = await collection.get({ ids: [id] })
504
+ if (verify.ids.length > 0) {
505
+ this.logger.warn({ id }, 'Decision still exists after delete — retrying')
506
+ await collection.delete({ ids: [id] })
507
+ }
508
+ } catch {
509
+ // Verification query failed, assume delete succeeded
510
+ }
511
+
496
512
  // ALSO delete from memories collection (dual storage uses same ID)
497
513
  try {
498
514
  const memoriesCollection = await this.collections.getMemories()
@@ -502,7 +518,7 @@ export class ChromaMemoryStore {
502
518
  // Memories collection entry may not exist, that's ok
503
519
  }
504
520
 
505
- this.logger.info({ id }, 'Decision deleted')
521
+ this.logger.info({ id }, 'Decision deleted from all collections')
506
522
 
507
523
  } catch (error) {
508
524
  this.logger.error({ error, id }, 'Failed to delete decision')
@@ -268,6 +268,23 @@ export class EpisodeManager {
268
268
  }
269
269
  }
270
270
 
271
+ /**
272
+ * Remove a decision reference from all active episodes.
273
+ * Called when a decision is deleted to prevent stale references.
274
+ */
275
+ unlinkDecision(decisionId: string): void {
276
+ for (const episode of this.activeEpisodes.values()) {
277
+ const idx = episode.related_decisions.indexOf(decisionId)
278
+ if (idx !== -1) {
279
+ episode.related_decisions.splice(idx, 1)
280
+ this.logger.debug(
281
+ { episodeId: episode.id, decisionId },
282
+ 'Decision unlinked from episode'
283
+ )
284
+ }
285
+ }
286
+ }
287
+
271
288
  private async persistEpisode(episode: Episode): Promise<void> {
272
289
  try {
273
290
  const collection = await this.collections.getEpisodes()
@@ -56,6 +56,7 @@ export class MemoryManager {
56
56
  private initialized: boolean = false
57
57
  private useChromaDB: boolean = true
58
58
  private onDecisionStoredCallbacks: ((input: any) => void)[] = []
59
+ private onDecisionDeletedCallbacks: ((id: string) => void)[] = []
59
60
 
60
61
  constructor(
61
62
  dbPath: string,
@@ -195,6 +196,13 @@ export class MemoryManager {
195
196
  }
196
197
  }
197
198
 
199
+ /**
200
+ * Add a listener that fires when a decision is deleted
201
+ */
202
+ addDecisionDeletedListener(callback: (id: string) => void): void {
203
+ this.onDecisionDeletedCallbacks.push(callback)
204
+ }
205
+
198
206
  async rememberDecision(
199
207
  project: string,
200
208
  context: string,
@@ -244,27 +252,38 @@ export class MemoryManager {
244
252
  minSimilarity: options?.minSimilarity || 0.5
245
253
  })
246
254
  // Transform ChromaDB results to match MemorySearchResult structure
247
- return chromaResults.map(r => ({
248
- memory: {
249
- id: r.id,
250
- project: r.metadata.project || options?.project || 'unknown',
251
- content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
252
- createdAt: r.metadata.created_at ? new Date(r.metadata.created_at) : new Date(),
253
- metadata: r.metadata
254
- },
255
- similarity: r.similarity,
256
- decision: r.metadata.decision ? {
255
+ // Includes flat `content` field for direct access (Phase 19)
256
+ return chromaResults.map(r => {
257
+ const memoryContent = typeof r.content === 'string' ? r.content : JSON.stringify(r.content)
258
+ const decisionObj = r.metadata.decision ? {
257
259
  id: r.id,
258
260
  project: r.metadata.project || options?.project || 'unknown',
259
261
  context: r.metadata.context || '',
260
- decision: r.metadata.decision || (typeof r.content === 'string' ? r.content : ''),
262
+ decision: r.metadata.decision || memoryContent,
261
263
  reasoning: r.metadata.reasoning || '',
262
264
  alternatives: r.metadata.alternatives_considered || '',
263
265
  tags: r.metadata.tags || [],
264
266
  outcome: r.metadata.outcome,
265
267
  createdAt: r.metadata.created_at ? new Date(r.metadata.created_at) : new Date()
266
268
  } : undefined
267
- }))
269
+
270
+ return {
271
+ // Flat fields for direct access (Phase 19)
272
+ id: r.id,
273
+ content: decisionObj ? decisionObj.decision : memoryContent,
274
+ // Nested fields for backward compatibility
275
+ memory: {
276
+ id: r.id,
277
+ project: r.metadata.project || options?.project || 'unknown',
278
+ content: memoryContent,
279
+ createdAt: r.metadata.created_at ? new Date(r.metadata.created_at) : new Date(),
280
+ metadata: r.metadata
281
+ },
282
+ similarity: r.similarity,
283
+ decision: decisionObj,
284
+ metadata: r.metadata
285
+ }
286
+ })
268
287
  } else {
269
288
  return await this.search.search(query, {
270
289
  project: options?.project,
@@ -520,10 +539,18 @@ export class MemoryManager {
520
539
  } else {
521
540
  this.store.deleteMemory(id)
522
541
  }
542
+
543
+ // Notify listeners (e.g., knowledge graph builder) about deletion
544
+ for (const cb of this.onDecisionDeletedCallbacks) {
545
+ try {
546
+ cb(id)
547
+ } catch {}
548
+ }
523
549
  }
524
550
 
525
551
  /**
526
- * Update a decision by storing a new version and deleting the old one
552
+ * Update a decision by deleting the old one and storing a new version.
553
+ * Phase 20: Ensures both ChromaDB and knowledge graph are atomically updated.
527
554
  */
528
555
  async updateDecision(
529
556
  oldId: string,
@@ -533,14 +560,17 @@ export class MemoryManager {
533
560
  reasoning: string,
534
561
  options?: { alternatives?: string; tags?: string[] }
535
562
  ): Promise<string> {
536
- // Delete old version
563
+ // Delete old version — fires onDecisionDeletedCallbacks (graph + episode cleanup)
537
564
  try {
538
565
  await this.deleteDecision(oldId)
539
- } catch {
540
- // Old ID might not exist, continue with storing new version
566
+ this.logger.debug({ oldId }, 'Old decision deleted for update')
567
+ } catch (error) {
568
+ this.logger.warn({ error, oldId }, 'Failed to delete old decision during update, storing new version anyway')
541
569
  }
542
- // Store new version
543
- return this.rememberDecision(project, context, decision, reasoning, options)
570
+ // Store new version — fires onDecisionStoredCallbacks (graph rebuild)
571
+ const newId = await this.rememberDecision(project, context, decision, reasoning, options)
572
+ this.logger.debug({ oldId, newId }, 'Decision updated: old deleted, new stored')
573
+ return newId
544
574
  }
545
575
  }
546
576