claude-brain 0.15.1 → 0.16.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 +25 -7
- package/package.json +1 -1
- package/src/cli/bin.ts +7 -0
- package/src/cli/commands/refresh.ts +323 -0
- package/src/cli/commands/serve.ts +0 -6
- package/src/cli/commands/update.ts +3 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +1 -1
- package/src/hooks/brain-hook.ts +17 -1
- package/src/hooks/passive-classifier.ts +34 -37
- package/src/hooks/session-tracker.ts +60 -23
- package/src/routing/intent-classifier.ts +15 -1
- package/src/routing/response-filter.ts +11 -8
- package/src/routing/router.ts +151 -32
- package/src/routing/search-engine.ts +44 -4
- package/src/cli/auto-update.ts +0 -157
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.16.0
|
package/assets/CLAUDE.md
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
# Claude Brain
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
You have persistent memory via the `brain` tool. It remembers across sessions.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
-
|
|
8
|
-
- Update something: "Changed my mind, use Postgres instead"
|
|
9
|
-
- Delete something: "Remove the note about migrations"
|
|
5
|
+
## Session Start
|
|
6
|
+
At the beginning of significant work sessions, check what you know:
|
|
7
|
+
- `brain("What do I know about this project?")` — recalls past decisions, preferences, lessons
|
|
10
8
|
|
|
11
|
-
|
|
9
|
+
## During Work
|
|
10
|
+
Call brain when you make or encounter something worth remembering:
|
|
11
|
+
- Decisions: `brain("I decided to use JWT over sessions because the app is stateless")`
|
|
12
|
+
- Mistakes: `brain("The bug was caused by missing CORS credentials — fixed with credentials: include")`
|
|
13
|
+
- Preferences: `brain("User prefers explicit error messages over generic 500s")`
|
|
14
|
+
- Updates: `brain("Changed my mind, use Postgres instead of MySQL")`
|
|
15
|
+
|
|
16
|
+
## Session End
|
|
17
|
+
Before finishing significant work, store a 2-3 sentence summary:
|
|
18
|
+
- `brain("Session summary: Built auth flow for expense tracker. Chose JWT for stateless architecture. Hit CORS issue on /api/login, fixed with credentials: include. User prefers Tailwind and explicit error handling.")`
|
|
19
|
+
|
|
20
|
+
## What NOT to store
|
|
21
|
+
- File paths or creation events (captured automatically)
|
|
22
|
+
- Progress like "read file X" or "ran tests" (too granular)
|
|
23
|
+
- Anything already in the codebase (just read the code)
|
|
24
|
+
|
|
25
|
+
## Background
|
|
26
|
+
Tool events are captured automatically via invisible PostToolUse/Stop hooks
|
|
27
|
+
in ~/.claude/settings.json. These hooks capture installs, git commits, and
|
|
28
|
+
build failures without any explicit brain() call. Your job is to add the
|
|
29
|
+
WHY — the reasoning, preferences, and lessons that hooks cannot see.
|
package/package.json
CHANGED
package/src/cli/bin.ts
CHANGED
|
@@ -32,6 +32,7 @@ function printHelp() {
|
|
|
32
32
|
['install', 'Register as MCP server in Claude Code'],
|
|
33
33
|
['uninstall', 'Remove MCP server from Claude Code'],
|
|
34
34
|
['update', 'Update package and refresh CLAUDE.md'],
|
|
35
|
+
['refresh', 'Kill all, update to latest, and setup everything'],
|
|
35
36
|
['chroma', 'Manage ChromaDB server (start/stop/status)'],
|
|
36
37
|
['hooks', 'Manage passive learning hooks (install/uninstall/status)'],
|
|
37
38
|
['git-hook', 'Manage git post-commit hook (install/uninstall/status)'],
|
|
@@ -115,6 +116,12 @@ async function main() {
|
|
|
115
116
|
break
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
case 'refresh': {
|
|
120
|
+
const { runRefresh } = await import('./commands/refresh')
|
|
121
|
+
await runRefresh()
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
|
|
118
125
|
case 'chroma': {
|
|
119
126
|
const { runChroma } = await import('./commands/chroma')
|
|
120
127
|
await runChroma()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claude-brain refresh — One-command update & restart.
|
|
3
|
+
* Kill all instances → install latest → setup ChromaDB/MCP/hooks → verify.
|
|
4
|
+
* Cross-platform: macOS/Linux and Windows.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execSync } from 'node:child_process'
|
|
8
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs'
|
|
9
|
+
import fs from 'fs/promises'
|
|
10
|
+
import path from 'path'
|
|
11
|
+
import os from 'os'
|
|
12
|
+
import { fileURLToPath } from 'url'
|
|
13
|
+
import { getHomePaths } from '@/config/home'
|
|
14
|
+
import { ensureHomeDirectory } from '@/cli/auto-setup'
|
|
15
|
+
import { ensureChromaRunning } from '@/cli/commands/chroma'
|
|
16
|
+
import { installHooks, isHooksInstalled } from '@/hooks/installer'
|
|
17
|
+
import {
|
|
18
|
+
renderLogo, theme, heading, successText, errorText, warningText, dimText,
|
|
19
|
+
box, withSpinner,
|
|
20
|
+
} from '@/cli/ui/index.js'
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
23
|
+
const __dirname = path.dirname(__filename)
|
|
24
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..', '..')
|
|
25
|
+
|
|
26
|
+
const isWindows = process.platform === 'win32'
|
|
27
|
+
|
|
28
|
+
function getInstalledVersion(): string {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf-8'))
|
|
31
|
+
return pkg.version || 'unknown'
|
|
32
|
+
} catch {
|
|
33
|
+
return 'unknown'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getLatestVersion(): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const result = execSync('npm view claude-brain version', {
|
|
40
|
+
encoding: 'utf-8',
|
|
41
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
42
|
+
timeout: 15_000,
|
|
43
|
+
})
|
|
44
|
+
return result.trim()
|
|
45
|
+
} catch {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Kill processes matching a pattern, excluding our own PID. */
|
|
51
|
+
function killByPattern(pattern: string): boolean {
|
|
52
|
+
const myPid = process.pid
|
|
53
|
+
try {
|
|
54
|
+
if (isWindows) {
|
|
55
|
+
// Get matching PIDs, filter out ourselves, then kill
|
|
56
|
+
const result = execSync(
|
|
57
|
+
`wmic process where "commandline like '%${pattern}%' and processid != ${myPid}" get processid /format:list`,
|
|
58
|
+
{ encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }
|
|
59
|
+
)
|
|
60
|
+
const pids = result.match(/ProcessId=(\d+)/g)?.map(m => m.split('=')[1]) || []
|
|
61
|
+
for (const pid of pids) {
|
|
62
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
|
|
63
|
+
}
|
|
64
|
+
return pids.length > 0
|
|
65
|
+
} else {
|
|
66
|
+
// Get matching PIDs via pgrep, exclude our own, then kill each
|
|
67
|
+
const result = execSync(`pgrep -f "${pattern}"`, {
|
|
68
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
69
|
+
})
|
|
70
|
+
const pids = result.trim().split('\n').filter(p => p && Number(p) !== myPid)
|
|
71
|
+
for (const pid of pids) {
|
|
72
|
+
try { process.kill(Number(pid), 'SIGKILL') } catch {}
|
|
73
|
+
}
|
|
74
|
+
return pids.length > 0
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Kill any process listening on a given port, excluding our own PID. */
|
|
82
|
+
function killByPort(port: number): boolean {
|
|
83
|
+
const myPid = process.pid
|
|
84
|
+
try {
|
|
85
|
+
if (isWindows) {
|
|
86
|
+
const result = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, {
|
|
87
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
88
|
+
})
|
|
89
|
+
const pids = new Set(
|
|
90
|
+
result.split('\n')
|
|
91
|
+
.map(line => line.trim().split(/\s+/).pop())
|
|
92
|
+
.filter(p => p && Number(p) !== myPid)
|
|
93
|
+
)
|
|
94
|
+
for (const pid of pids) {
|
|
95
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
|
|
96
|
+
}
|
|
97
|
+
return pids.size > 0
|
|
98
|
+
} else {
|
|
99
|
+
const raw = execSync(`lsof -ti :${port}`, {
|
|
100
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
101
|
+
}).trim()
|
|
102
|
+
if (raw) {
|
|
103
|
+
const pids = raw.split('\n').filter(p => p && Number(p) !== myPid)
|
|
104
|
+
for (const pid of pids) {
|
|
105
|
+
try { process.kill(Number(pid), 'SIGKILL') } catch {}
|
|
106
|
+
}
|
|
107
|
+
return pids.length > 0
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function sleep(ms: number): Promise<void> {
|
|
115
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isMcpConfigured(): boolean {
|
|
119
|
+
try {
|
|
120
|
+
const result = execSync('claude mcp list', {
|
|
121
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000,
|
|
122
|
+
})
|
|
123
|
+
return result.includes('claude-brain')
|
|
124
|
+
} catch {
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function registerMcp(): boolean {
|
|
130
|
+
try {
|
|
131
|
+
execSync('claude mcp add claude-brain -- claude-brain serve', {
|
|
132
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10_000,
|
|
133
|
+
})
|
|
134
|
+
return true
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
137
|
+
// Already registered is fine
|
|
138
|
+
return msg.includes('already') || msg.includes('exists')
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isChromaReachable(): boolean {
|
|
143
|
+
for (const apiVersion of ['v2', 'v1']) {
|
|
144
|
+
try {
|
|
145
|
+
execSync(`curl -sf http://localhost:8000/api/${apiVersion}/heartbeat`, {
|
|
146
|
+
stdio: 'pipe', timeout: 3000,
|
|
147
|
+
})
|
|
148
|
+
return true
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Main ──────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
export async function runRefresh() {
|
|
157
|
+
console.log()
|
|
158
|
+
console.log(renderLogo())
|
|
159
|
+
console.log()
|
|
160
|
+
console.log(heading('Refresh — Kill, Update, Setup'))
|
|
161
|
+
console.log()
|
|
162
|
+
|
|
163
|
+
const versionBefore = getInstalledVersion()
|
|
164
|
+
const results: Array<[string, 'ok' | 'warn' | 'fail', string]> = []
|
|
165
|
+
|
|
166
|
+
// ── Step 1: Kill all running instances ──────────────────
|
|
167
|
+
|
|
168
|
+
await withSpinner('Stopping all claude-brain processes', async () => {
|
|
169
|
+
// Kill claude-brain processes
|
|
170
|
+
killByPattern('claude-brain')
|
|
171
|
+
|
|
172
|
+
// Kill ChromaDB
|
|
173
|
+
if (isWindows) {
|
|
174
|
+
killByPattern('chroma.exe')
|
|
175
|
+
} else {
|
|
176
|
+
killByPattern('chroma run')
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Kill by port (HTTP API 3333, ChromaDB 8000)
|
|
180
|
+
killByPort(3333)
|
|
181
|
+
killByPort(8000)
|
|
182
|
+
|
|
183
|
+
// Clean up stale PID file
|
|
184
|
+
const pidPath = path.join(getHomePaths().data, 'chroma.pid')
|
|
185
|
+
if (existsSync(pidPath)) {
|
|
186
|
+
try { unlinkSync(pidPath) } catch {}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Let ports release
|
|
190
|
+
await sleep(2000)
|
|
191
|
+
})
|
|
192
|
+
results.push(['Kill processes', 'ok', 'All instances stopped'])
|
|
193
|
+
console.log(successText(' All instances stopped'))
|
|
194
|
+
console.log()
|
|
195
|
+
|
|
196
|
+
// ── Step 2: Install latest version ──────────────────────
|
|
197
|
+
|
|
198
|
+
const latestVersion = await withSpinner('Checking npm for latest version', async () => getLatestVersion())
|
|
199
|
+
|
|
200
|
+
if (latestVersion) {
|
|
201
|
+
console.log(dimText(` Installed: v${versionBefore} → Latest: v${latestVersion}`))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (latestVersion && latestVersion !== versionBefore) {
|
|
205
|
+
try {
|
|
206
|
+
await withSpinner(`Installing claude-brain@${latestVersion}`, async () => {
|
|
207
|
+
try {
|
|
208
|
+
execSync('bun install -g claude-brain@latest', {
|
|
209
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000,
|
|
210
|
+
})
|
|
211
|
+
} catch {
|
|
212
|
+
// Fallback to npm
|
|
213
|
+
execSync('npm install -g claude-brain@latest', {
|
|
214
|
+
encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 120_000,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
results.push(['Update', 'ok', `v${versionBefore} → v${latestVersion}`])
|
|
219
|
+
console.log(successText(` Updated: v${versionBefore} → v${latestVersion}`))
|
|
220
|
+
} catch {
|
|
221
|
+
results.push(['Update', 'fail', 'Install failed — try manually: bun install -g claude-brain@latest'])
|
|
222
|
+
console.log(warningText(' Update failed. Try manually: bun install -g claude-brain@latest'))
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
results.push(['Update', 'ok', `v${versionBefore} is latest`])
|
|
226
|
+
console.log(successText(` Already on latest (v${versionBefore})`))
|
|
227
|
+
}
|
|
228
|
+
console.log()
|
|
229
|
+
|
|
230
|
+
// ── Step 3: Full setup ──────────────────────────────────
|
|
231
|
+
|
|
232
|
+
// 3a. Home directory
|
|
233
|
+
await withSpinner('Ensuring home directory', async () => {
|
|
234
|
+
ensureHomeDirectory()
|
|
235
|
+
})
|
|
236
|
+
results.push(['Home dir', 'ok', '~/.claude-brain/'])
|
|
237
|
+
console.log(successText(' Home directory ready'))
|
|
238
|
+
|
|
239
|
+
// 3b. ChromaDB
|
|
240
|
+
const chromaOk = await withSpinner('Starting ChromaDB', async () => {
|
|
241
|
+
return ensureChromaRunning({ silent: true })
|
|
242
|
+
})
|
|
243
|
+
if (chromaOk) {
|
|
244
|
+
results.push(['ChromaDB', 'ok', 'Running on port 8000'])
|
|
245
|
+
console.log(successText(' ChromaDB running'))
|
|
246
|
+
} else {
|
|
247
|
+
results.push(['ChromaDB', 'warn', 'Not available — install with: claude-brain chroma install'])
|
|
248
|
+
console.log(warningText(' ChromaDB not available (install with: claude-brain chroma install)'))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3c. Hooks
|
|
252
|
+
await withSpinner('Installing hooks', async () => {
|
|
253
|
+
installHooks()
|
|
254
|
+
})
|
|
255
|
+
const hooksOk = isHooksInstalled()
|
|
256
|
+
if (hooksOk) {
|
|
257
|
+
results.push(['Hooks', 'ok', 'Installed in ~/.claude/settings.json'])
|
|
258
|
+
console.log(successText(' Hooks installed'))
|
|
259
|
+
} else {
|
|
260
|
+
results.push(['Hooks', 'warn', 'Hook installation issue'])
|
|
261
|
+
console.log(warningText(' Hooks may not be fully installed'))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 3d. CLAUDE.md
|
|
265
|
+
const assetsPath = path.join(PACKAGE_ROOT, 'assets', 'CLAUDE.md')
|
|
266
|
+
const claudeDir = path.join(os.homedir(), '.claude')
|
|
267
|
+
const destPath = path.join(claudeDir, 'CLAUDE.md')
|
|
268
|
+
|
|
269
|
+
if (existsSync(assetsPath)) {
|
|
270
|
+
await withSpinner('Updating CLAUDE.md', async () => {
|
|
271
|
+
await fs.mkdir(claudeDir, { recursive: true })
|
|
272
|
+
await fs.copyFile(assetsPath, destPath)
|
|
273
|
+
})
|
|
274
|
+
results.push(['CLAUDE.md', 'ok', '~/.claude/CLAUDE.md'])
|
|
275
|
+
console.log(successText(' CLAUDE.md updated'))
|
|
276
|
+
} else {
|
|
277
|
+
results.push(['CLAUDE.md', 'warn', 'Asset not found in package'])
|
|
278
|
+
console.log(warningText(' CLAUDE.md asset not found, skipping'))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 3e. MCP registration
|
|
282
|
+
const mcpOk = await withSpinner('Checking MCP registration', async () => {
|
|
283
|
+
if (isMcpConfigured()) return true
|
|
284
|
+
return registerMcp()
|
|
285
|
+
})
|
|
286
|
+
if (mcpOk) {
|
|
287
|
+
results.push(['MCP', 'ok', 'Registered in Claude Code'])
|
|
288
|
+
console.log(successText(' MCP server registered'))
|
|
289
|
+
} else {
|
|
290
|
+
results.push(['MCP', 'warn', 'Run manually: claude-brain install'])
|
|
291
|
+
console.log(warningText(' MCP registration failed — run: claude-brain install'))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
console.log()
|
|
295
|
+
|
|
296
|
+
// ── Step 4: Verify & report ─────────────────────────────
|
|
297
|
+
|
|
298
|
+
const chromaVerified = isChromaReachable()
|
|
299
|
+
if (!chromaVerified && chromaOk) {
|
|
300
|
+
// Update result if it was ok but now unreachable
|
|
301
|
+
const idx = results.findIndex(r => r[0] === 'ChromaDB')
|
|
302
|
+
if (idx >= 0) results[idx] = ['ChromaDB', 'warn', 'Started but not responding yet']
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Build summary
|
|
306
|
+
const statusIcon = (s: 'ok' | 'warn' | 'fail') =>
|
|
307
|
+
s === 'ok' ? successText('✓') : s === 'warn' ? warningText('⚠') : errorText('✗')
|
|
308
|
+
|
|
309
|
+
const summaryLines = results.map(
|
|
310
|
+
([label, status, detail]) => ` ${statusIcon(status)} ${theme.bold(label!.padEnd(12))} ${dimText(detail!)}`
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const allOk = results.every(r => r[1] === 'ok')
|
|
314
|
+
|
|
315
|
+
console.log(box([
|
|
316
|
+
heading(allOk ? 'Refresh complete!' : 'Refresh complete (with warnings)'),
|
|
317
|
+
'',
|
|
318
|
+
...summaryLines,
|
|
319
|
+
'',
|
|
320
|
+
dimText('Verify: claude-brain health'),
|
|
321
|
+
].join('\n'), allOk ? 'Done' : 'Done'))
|
|
322
|
+
console.log()
|
|
323
|
+
}
|
|
@@ -18,12 +18,6 @@ export async function runServe() {
|
|
|
18
18
|
// Auto-initialize home directory on first run
|
|
19
19
|
ensureHomeDirectory()
|
|
20
20
|
|
|
21
|
-
// Background auto-update (non-blocking)
|
|
22
|
-
try {
|
|
23
|
-
const { checkForUpdateInBackground } = await import('@/cli/auto-update')
|
|
24
|
-
checkForUpdateInBackground().catch(() => {})
|
|
25
|
-
} catch {}
|
|
26
|
-
|
|
27
21
|
// Auto-install Claude Code hooks (idempotent, non-fatal)
|
|
28
22
|
try {
|
|
29
23
|
const { installHooks } = await import('@/hooks/installer')
|
|
@@ -116,6 +116,9 @@ export async function runUpdate() {
|
|
|
116
116
|
'',
|
|
117
117
|
dimText('Run health check to verify:'),
|
|
118
118
|
` ${theme.bold('claude-brain health')}`,
|
|
119
|
+
'',
|
|
120
|
+
dimText('For a full kill + update + restart:'),
|
|
121
|
+
` ${theme.bold('claude-brain refresh')}`,
|
|
119
122
|
].join('\n'), 'Done'))
|
|
120
123
|
console.log()
|
|
121
124
|
}
|
package/src/config/defaults.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { PartialConfig } from './schema'
|
|
|
3
3
|
/** Default configuration values for Claude Brain */
|
|
4
4
|
export const defaultConfig: PartialConfig = {
|
|
5
5
|
serverName: 'claude-brain',
|
|
6
|
-
serverVersion: '0.
|
|
6
|
+
serverVersion: '0.16.0',
|
|
7
7
|
logLevel: 'info',
|
|
8
8
|
logFilePath: './logs/claude-brain.log',
|
|
9
9
|
dbPath: './data/memory.db',
|
package/src/config/schema.ts
CHANGED
|
@@ -284,7 +284,7 @@ export const ConfigSchema = z.object({
|
|
|
284
284
|
serverName: z.string().default('claude-brain'),
|
|
285
285
|
|
|
286
286
|
/** Server version in semver format */
|
|
287
|
-
serverVersion: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semver format').default('0.
|
|
287
|
+
serverVersion: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semver format').default('0.16.0'),
|
|
288
288
|
|
|
289
289
|
/** Logging level */
|
|
290
290
|
logLevel: LogLevelSchema.default('info'),
|
package/src/hooks/brain-hook.ts
CHANGED
|
@@ -54,7 +54,23 @@ export async function main(): Promise<void> {
|
|
|
54
54
|
|
|
55
55
|
const port = parseInt(process.env.CLAUDE_BRAIN_PORT || '3000', 10)
|
|
56
56
|
|
|
57
|
-
//
|
|
57
|
+
// For Stop events: trigger session-end summarization regardless of classification
|
|
58
|
+
if (input.hook_event_name === 'Stop' && input.session_id) {
|
|
59
|
+
try {
|
|
60
|
+
await fetch(`http://localhost:${port}/api/hooks/session-end`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ sessionId: input.session_id }),
|
|
64
|
+
signal: AbortSignal.timeout(5000),
|
|
65
|
+
})
|
|
66
|
+
} catch {
|
|
67
|
+
// Server unreachable — session summary lost, acceptable tradeoff
|
|
68
|
+
}
|
|
69
|
+
process.exit(0)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Process the hook event (non-Stop events only)
|
|
58
74
|
const capture = new BrainCapture({ enabled: true })
|
|
59
75
|
const knowledge = capture.process(input)
|
|
60
76
|
|
|
@@ -109,48 +109,45 @@ export class PassiveClassifier {
|
|
|
109
109
|
const content = toolInput.new_string || toolInput.content || ''
|
|
110
110
|
const responseText = this.extractResponseText(input.tool_response)
|
|
111
111
|
|
|
112
|
-
//
|
|
112
|
+
// For Write tool: only capture if content contains decision or correction language
|
|
113
|
+
// Plain file creations ("Created file: X") are noise — zero recall value
|
|
113
114
|
if (input.tool_name?.toLowerCase() === 'write') {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (correctionInContent) {
|
|
131
|
-
return {
|
|
132
|
-
type: 'correction',
|
|
133
|
-
confidence: 0.75,
|
|
134
|
-
content: correctionInContent,
|
|
135
|
-
project: this.extractProjectFromCwd(input.cwd),
|
|
136
|
-
technologies,
|
|
137
|
-
metadata: { filePath, role, action: 'create' },
|
|
138
|
-
source: 'hook-passive',
|
|
139
|
-
timestamp: new Date().toISOString(),
|
|
140
|
-
}
|
|
115
|
+
const writeContent = typeof content === 'string' ? content : ''
|
|
116
|
+
const writeResponse = responseText || ''
|
|
117
|
+
const combined = writeContent + ' ' + writeResponse
|
|
118
|
+
|
|
119
|
+
// Check for decision language in file content or response
|
|
120
|
+
const decisionInWrite = this.detectDecisionLanguage(combined)
|
|
121
|
+
if (decisionInWrite) {
|
|
122
|
+
return {
|
|
123
|
+
type: 'decision',
|
|
124
|
+
confidence: 0.75,
|
|
125
|
+
content: decisionInWrite,
|
|
126
|
+
project: this.extractProjectFromCwd(input.cwd),
|
|
127
|
+
technologies,
|
|
128
|
+
metadata: { filePath, role, action: 'create' },
|
|
129
|
+
source: 'hook-passive',
|
|
130
|
+
timestamp: new Date().toISOString(),
|
|
141
131
|
}
|
|
142
132
|
}
|
|
143
133
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
134
|
+
// Check for correction language
|
|
135
|
+
const correctionInWrite = this.detectCorrectionLanguage(combined)
|
|
136
|
+
if (correctionInWrite) {
|
|
137
|
+
return {
|
|
138
|
+
type: 'correction',
|
|
139
|
+
confidence: 0.75,
|
|
140
|
+
content: correctionInWrite,
|
|
141
|
+
project: this.extractProjectFromCwd(input.cwd),
|
|
142
|
+
technologies,
|
|
143
|
+
metadata: { filePath, role, action: 'create' },
|
|
144
|
+
source: 'hook-passive',
|
|
145
|
+
timestamp: new Date().toISOString(),
|
|
146
|
+
}
|
|
153
147
|
}
|
|
148
|
+
|
|
149
|
+
// No decision or correction language found — skip this file creation
|
|
150
|
+
return null
|
|
154
151
|
}
|
|
155
152
|
|
|
156
153
|
// For edits, only capture if they look significant
|
|
@@ -134,46 +134,83 @@ export class HookSessionTracker {
|
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
136
|
* Build a structured summary from session items.
|
|
137
|
-
*
|
|
137
|
+
* Phase 25: Natural sentence format with project context, deduplication, noise filtering.
|
|
138
138
|
*/
|
|
139
139
|
private buildStructuredSummary(session: SessionState): string {
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
const
|
|
140
|
+
const decisions = session.items.filter(i => i.type === 'decision')
|
|
141
|
+
const corrections = session.items.filter(i => i.type === 'correction')
|
|
142
|
+
const progress = session.items.filter(i => i.type === 'progress')
|
|
143
143
|
|
|
144
|
+
// Extract unique technologies across all items
|
|
145
|
+
const techs = new Set<string>()
|
|
144
146
|
for (const item of session.items) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
for (const t of item.technologies) techs.add(t)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Extract unique files touched
|
|
151
|
+
const files = new Set<string>()
|
|
152
|
+
for (const item of session.items) {
|
|
153
|
+
const filePath = item.metadata?.filePath || item.metadata?.file_path
|
|
154
|
+
if (filePath) files.add(filePath as string)
|
|
149
155
|
if (item.metadata?.files) {
|
|
150
156
|
for (const f of Array.isArray(item.metadata.files) ? item.metadata.files : [item.metadata.files]) {
|
|
151
|
-
files.add(f)
|
|
157
|
+
files.add(f as string)
|
|
152
158
|
}
|
|
153
159
|
}
|
|
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
160
|
}
|
|
163
161
|
|
|
164
162
|
const parts: string[] = []
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
|
|
164
|
+
// Project and tech context
|
|
165
|
+
const project = session.project || 'unknown project'
|
|
166
|
+
if (techs.size > 0) {
|
|
167
|
+
parts.push(`Worked on ${project} using ${Array.from(techs).slice(0, 5).join(', ')}`)
|
|
168
|
+
} else {
|
|
169
|
+
parts.push(`Worked on ${project}`)
|
|
167
170
|
}
|
|
171
|
+
|
|
172
|
+
// Key decisions (deduplicated, max 3)
|
|
168
173
|
if (decisions.length > 0) {
|
|
169
|
-
|
|
174
|
+
const uniqueDecisions = this.deduplicateByPrefix(decisions.map(d => d.content), 3)
|
|
175
|
+
parts.push(`Decisions: ${uniqueDecisions.join('. ')}`)
|
|
170
176
|
}
|
|
177
|
+
|
|
178
|
+
// What was done (git commits and installs only — skip file creation noise)
|
|
179
|
+
const meaningfulProgress = progress.filter(p =>
|
|
180
|
+
p.metadata?.action === 'git' || p.metadata?.action === 'install' ||
|
|
181
|
+
(p.metadata?.action === 'build' && p.metadata?.failed)
|
|
182
|
+
)
|
|
183
|
+
if (meaningfulProgress.length > 0) {
|
|
184
|
+
const uniqueProgress = this.deduplicateByPrefix(meaningfulProgress.map(p => p.content), 3)
|
|
185
|
+
parts.push(`Progress: ${uniqueProgress.join('. ')}`)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Lessons learned (always valuable, keep all)
|
|
189
|
+
if (corrections.length > 0) {
|
|
190
|
+
parts.push(`Lessons: ${corrections.map(c => c.content).join('. ')}`)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// File count (not full list — just a signal of scope)
|
|
171
194
|
if (files.size > 0) {
|
|
172
|
-
|
|
173
|
-
parts.push(`Files: ${fileList.join(', ')}`)
|
|
195
|
+
parts.push(`Touched ${files.size} file${files.size > 1 ? 's' : ''}`)
|
|
174
196
|
}
|
|
175
197
|
|
|
176
|
-
return parts.join('. ') ||
|
|
198
|
+
return parts.join('. ') || 'Session with no significant events captured.'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Deduplicate strings by checking if one starts with the first 30 chars of another */
|
|
202
|
+
private deduplicateByPrefix(items: string[], max: number): string[] {
|
|
203
|
+
const seen = new Set<string>()
|
|
204
|
+
const result: string[] = []
|
|
205
|
+
for (const item of items) {
|
|
206
|
+
const prefix = item.slice(0, 30).toLowerCase()
|
|
207
|
+
if (!seen.has(prefix)) {
|
|
208
|
+
seen.add(prefix)
|
|
209
|
+
result.push(item)
|
|
210
|
+
if (result.length >= max) break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return result
|
|
177
214
|
}
|
|
178
215
|
|
|
179
216
|
private async summarizeAndPersist(session: SessionState): Promise<void> {
|