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.
- package/VERSION +1 -1
- package/assets/CLAUDE.md +9 -1
- package/package.json +1 -1
- package/src/automation/phase12-manager.ts +456 -0
- package/src/automation/project-detector.ts +13 -0
- package/src/automation/repo-scanner.ts +205 -0
- package/src/cli/bin.ts +30 -0
- package/src/cli/commands/git-hook.ts +189 -0
- package/src/cli/commands/hooks.ts +8 -9
- package/src/cli/commands/init.ts +98 -0
- package/src/cli/commands/serve.ts +7 -20
- package/src/cli/commands/update.ts +3 -3
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +27 -7
- package/src/hooks/brain-hook.ts +8 -6
- package/src/hooks/capture.ts +9 -2
- package/src/hooks/git-capture.ts +94 -0
- package/src/hooks/git-hook-installer.ts +197 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/session-tracker.ts +79 -3
- package/src/hooks/types.ts +1 -1
- package/src/intelligence/index.ts +24 -0
- package/src/knowledge/graph/builder.ts +26 -0
- package/src/memory/chroma/store.ts +18 -2
- package/src/memory/episodic/manager.ts +17 -0
- package/src/memory/index.ts +48 -18
- package/src/phase12/index.ts +3 -454
- package/src/routing/intent-classifier.ts +107 -9
- package/src/routing/response-filter.ts +50 -17
- package/src/routing/router.ts +472 -224
- package/src/routing/search-engine.ts +464 -0
- package/src/routing/types.ts +84 -0
- package/src/server/handlers/call-tool.ts +4 -49
- package/src/server/handlers/tools/analyze-decision-evolution.ts +1 -1
- package/src/server/handlers/tools/detect-trends.ts +1 -1
- package/src/server/handlers/tools/find-cross-project-patterns.ts +1 -1
- package/src/server/handlers/tools/get-decision-timeline.ts +2 -2
- package/src/server/handlers/tools/get-recommendations.ts +1 -1
- package/src/server/handlers/tools/index.ts +5 -7
- package/src/server/handlers/tools/what-if-analysis.ts +1 -1
- package/src/server/providers/resources.ts +195 -0
- package/src/server/services.ts +81 -6
- package/src/tools/schemas.ts +7 -329
- package/src/utils/phase12-helper.ts +2 -2
- package/src/utils/timing.ts +47 -0
- package/src/vault/writer.ts +22 -2
- /package/src/{cross-project → intelligence/cross-project}/affinity.ts +0 -0
- /package/src/{cross-project → intelligence/cross-project}/generalizer.ts +0 -0
- /package/src/{cross-project → intelligence/cross-project}/index.ts +0 -0
- /package/src/{cross-project → intelligence/cross-project}/transfer.ts +0 -0
- /package/src/{optimization → intelligence/optimization}/index.ts +0 -0
- /package/src/{optimization → intelligence/optimization}/precompute.ts +0 -0
- /package/src/{optimization → intelligence/optimization}/semantic-cache.ts +0 -0
- /package/src/{prediction → intelligence/prediction}/context-anticipator.ts +0 -0
- /package/src/{prediction → intelligence/prediction}/decision-predictor.ts +0 -0
- /package/src/{prediction → intelligence/prediction}/index.ts +0 -0
- /package/src/{prediction → intelligence/prediction}/recommender.ts +0 -0
- /package/src/{reasoning → intelligence/reasoning}/chain-retrieval.ts +0 -0
- /package/src/{reasoning → intelligence/reasoning}/counterfactual.ts +0 -0
- /package/src/{reasoning → intelligence/reasoning}/index.ts +0 -0
- /package/src/{reasoning → intelligence/reasoning}/synthesizer.ts +0 -0
- /package/src/{temporal → intelligence/temporal}/evolution.ts +0 -0
- /package/src/{temporal → intelligence/temporal}/index.ts +0 -0
- /package/src/{temporal → intelligence/temporal}/query-processor.ts +0 -0
- /package/src/{temporal → intelligence/temporal}/timeline.ts +0 -0
- /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
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
|
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:
|
|
271
|
+
{ sessionId: session.sessionId, summary: structuredSummary },
|
|
196
272
|
'Session summarized'
|
|
197
273
|
)
|
|
198
274
|
}
|
package/src/hooks/types.ts
CHANGED
|
@@ -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()
|
package/src/memory/index.ts
CHANGED
|
@@ -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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
|