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 CHANGED
@@ -1 +1 @@
1
- 0.15.1
1
+ 0.16.0
package/assets/CLAUDE.md CHANGED
@@ -1,11 +1,29 @@
1
1
  # Claude Brain
2
2
 
3
- Brain works automatically in the background you don't need to call it.
3
+ You have persistent memory via the `brain` tool. It remembers across sessions.
4
4
 
5
- Use the `brain` tool ONLY when you want to:
6
- - Explicitly store a decision: "I decided to use Redis for caching"
7
- - Ask a specific question: "What did we decide about auth?"
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
- Everything else (session tracking, file captures, git commits, context loading) happens automatically via invisible PostToolUse and Stop hooks in ~/.claude/settings.json. These hooks silently capture tool events and store patterns, decisions, and corrections without any explicit brain() call.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
  }
@@ -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.15.1',
6
+ serverVersion: '0.16.0',
7
7
  logLevel: 'info',
8
8
  logFilePath: './logs/claude-brain.log',
9
9
  dbPath: './data/memory.db',
@@ -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.15.1'),
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'),
@@ -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
- // Process the hook event
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
- // Check for new file creation (Write tool)
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
- // Check file content for decision/correction language before defaulting to pattern
115
- if (typeof content === 'string' && content.length > 50) {
116
- const decisionInContent = this.detectDecisionLanguage(content)
117
- if (decisionInContent) {
118
- return {
119
- type: 'decision',
120
- confidence: 0.8,
121
- content: decisionInContent,
122
- project: this.extractProjectFromCwd(input.cwd),
123
- technologies,
124
- metadata: { filePath, role, action: 'create' },
125
- source: 'hook-passive',
126
- timestamp: new Date().toISOString(),
127
- }
128
- }
129
- const correctionInContent = this.detectCorrectionLanguage(content)
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
- return {
145
- type: 'pattern',
146
- confidence: 0.7,
147
- content: `Created ${role ? role + ' ' : ''}file: ${this.shortenPath(filePath)}${technologies.length ? ` (${technologies.join(', ')})` : ''}`,
148
- project: this.extractProjectFromCwd(input.cwd),
149
- technologies,
150
- metadata: { filePath, role, action: 'create' },
151
- source: 'hook-passive',
152
- timestamp: new Date().toISOString(),
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
- * Format: "Did: ...; Decided: ...; Files: ..."
137
+ * Phase 25: Natural sentence format with project context, deduplication, noise filtering.
138
138
  */
139
139
  private buildStructuredSummary(session: SessionState): string {
140
- const actions: string[] = []
141
- const decisions: string[] = []
142
- const files = new Set<string>()
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
- // Extract file paths from metadata
146
- if (item.metadata?.file_path) {
147
- files.add(item.metadata.file_path)
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
- if (actions.length > 0) {
166
- parts.push(`Did: ${actions.slice(0, 5).join('; ')}`)
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
- parts.push(`Decided: ${decisions.slice(0, 3).join('; ')}`)
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
- const fileList = [...files].slice(0, 8)
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('. ') || `Session with ${session.items.length} events`
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> {