claude-brain 0.14.4 → 0.15.1

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.14.4
1
+ 0.15.1
package/assets/CLAUDE.md CHANGED
@@ -8,4 +8,4 @@ Use the `brain` tool ONLY when you want to:
8
8
  - Update something: "Changed my mind, use Postgres instead"
9
9
  - Delete something: "Remove the note about migrations"
10
10
 
11
- Everything else (session tracking, file captures, git commits, context loading) happens automatically.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.14.4",
3
+ "version": "0.15.1",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "src/**/*.ts",
12
+ "scripts/postinstall.mjs",
12
13
  "packs/",
13
14
  "assets/",
14
15
  "package.json",
@@ -19,6 +20,7 @@
19
20
  "LICENSE"
20
21
  ],
21
22
  "scripts": {
23
+ "postinstall": "node scripts/postinstall.mjs",
22
24
  "dev": "CLAUDE_BRAIN_HOME=. bun --watch src/index.ts",
23
25
  "build": "bun build src/index.ts --outdir dist --target bun",
24
26
  "build:binary": "bun build src/cli/bin.ts --compile --outfile claude-brain",
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Postinstall script for claude-brain.
5
+ * Runs automatically after `npm install -g claude-brain` or `bun install -g claude-brain`.
6
+ * Pure Node.js — no TypeScript, no path aliases, no Bun APIs.
7
+ * Always exits 0 so it never blocks the install.
8
+ */
9
+
10
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, renameSync } from 'node:fs'
11
+ import { join, dirname } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+ import { execSync } from 'node:child_process'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const __filename = fileURLToPath(import.meta.url)
17
+ const __dirname = dirname(__filename)
18
+
19
+ const PREFIX = '[claude-brain]'
20
+ const HOME = join(homedir(), '.claude-brain')
21
+ const CLAUDE_DIR = join(homedir(), '.claude')
22
+ const CLAUDE_SETTINGS = join(CLAUDE_DIR, 'settings.json')
23
+ const CLAUDE_MD_PATH = join(CLAUDE_DIR, 'CLAUDE.md')
24
+ const HOOK_MARKER = 'claude-brain-hook'
25
+
26
+ function log(msg) {
27
+ console.error(`${PREFIX} ${msg}`)
28
+ }
29
+
30
+ // ── Step 0: Skip if not a global install ─────────────────
31
+
32
+ function shouldSkip() {
33
+ // CI environments
34
+ if (process.env.CI === 'true' || process.env.CI === '1') {
35
+ return 'CI environment detected'
36
+ }
37
+
38
+ // If we're inside a node_modules directory, it's a local install
39
+ const scriptDir = dirname(__filename)
40
+ if (scriptDir.includes('node_modules') && !scriptDir.includes('global')) {
41
+ // Check if this is actually a global install by looking deeper
42
+ // bun global installs go to ~/.bun/install/global/node_modules
43
+ // npm global installs go to /usr/local/lib/node_modules or ~/.npm-global
44
+ const isGlobalPath =
45
+ scriptDir.includes('.bun/install/global') ||
46
+ scriptDir.includes('/usr/local/lib/node_modules') ||
47
+ scriptDir.includes('/usr/lib/node_modules') ||
48
+ scriptDir.includes('npm-global') ||
49
+ scriptDir.includes('AppData/Roaming/npm')
50
+
51
+ if (!isGlobalPath) {
52
+ return 'local install (not global)'
53
+ }
54
+ }
55
+
56
+ return null
57
+ }
58
+
59
+ // ── Step 1: Create ~/.claude-brain/ ──────────────────────
60
+
61
+ function setupHomeDirectory() {
62
+ if (existsSync(join(HOME, 'data'))) {
63
+ log('Home directory already initialized')
64
+ return true
65
+ }
66
+
67
+ log(`Setting up ${HOME}/...`)
68
+
69
+ const dirs = [
70
+ join(HOME, 'data'),
71
+ join(HOME, 'data', 'chroma'),
72
+ join(HOME, 'logs'),
73
+ join(HOME, 'vault'),
74
+ join(HOME, 'vault', 'Projects'),
75
+ join(HOME, 'vault', 'Global'),
76
+ join(HOME, 'hooks'),
77
+ ]
78
+
79
+ for (const dir of dirs) {
80
+ mkdirSync(dir, { recursive: true })
81
+ }
82
+
83
+ // Write default .env if not present
84
+ const envPath = join(HOME, '.env')
85
+ if (!existsSync(envPath)) {
86
+ writeFileSync(envPath, `# Claude Brain Configuration
87
+ # Generated by postinstall
88
+ VAULT_PATH=${join(HOME, 'vault')}
89
+ LOG_LEVEL=info
90
+ NODE_ENV=production
91
+
92
+ # ChromaDB Configuration
93
+ CHROMA_MODE=client-server
94
+ CHROMA_HOST=localhost
95
+ CHROMA_PORT=8000
96
+ CHROMA_EMBEDDING_PROVIDER=transformers
97
+ `, 'utf-8')
98
+ }
99
+
100
+ // Write default global standards
101
+ const standardsPath = join(HOME, 'vault', 'Global', 'standards.md')
102
+ if (!existsSync(standardsPath)) {
103
+ writeFileSync(standardsPath, `---
104
+ type: global-standards
105
+ last_updated: ${new Date().toISOString().split('T')[0]}
106
+ ---
107
+
108
+ # Global Coding Standards
109
+
110
+ ## General
111
+ - Write clear, readable code
112
+ - Prefer explicit over implicit
113
+ - Keep functions focused and small
114
+
115
+ ## TypeScript
116
+ - Use strict mode
117
+ - Prefer const over let
118
+ - Add JSDoc comments for public APIs
119
+ `, 'utf-8')
120
+ }
121
+
122
+ log('Home directory initialized')
123
+ return true
124
+ }
125
+
126
+ // ── Step 2: Install ChromaDB via pip ─────────────────────
127
+
128
+ function installChromaDB() {
129
+ // Check if already installed
130
+ try {
131
+ execSync('chroma --version', { stdio: 'pipe', timeout: 5000 })
132
+ log('ChromaDB already installed')
133
+ return true
134
+ } catch {}
135
+
136
+ // Check for Python
137
+ let pythonCmd = null
138
+ for (const cmd of ['python3', 'python']) {
139
+ try {
140
+ const ver = execSync(`${cmd} --version`, { encoding: 'utf-8', stdio: 'pipe', timeout: 5000 })
141
+ if (ver.includes('3.')) {
142
+ pythonCmd = cmd
143
+ break
144
+ }
145
+ } catch {}
146
+ }
147
+
148
+ if (!pythonCmd) {
149
+ log('Python 3 not found — skipping ChromaDB install (SQLite fallback will be used)')
150
+ return false
151
+ }
152
+
153
+ log('Installing ChromaDB...')
154
+
155
+ const pipCommands = pythonCmd === 'python3'
156
+ ? ['pip3 install chromadb', 'python3 -m pip install chromadb']
157
+ : ['pip install chromadb', 'python -m pip install chromadb']
158
+
159
+ for (const cmd of pipCommands) {
160
+ try {
161
+ execSync(cmd, { stdio: 'pipe', timeout: 300_000 })
162
+ log('ChromaDB installed')
163
+ return true
164
+ } catch {}
165
+ }
166
+
167
+ log('ChromaDB install failed — SQLite fallback will be used')
168
+ return false
169
+ }
170
+
171
+ // ── Step 3: Register MCP server ──────────────────────────
172
+
173
+ function registerMcpServer() {
174
+ // Check if claude CLI exists
175
+ try {
176
+ execSync('claude --version', { stdio: 'pipe', timeout: 5000 })
177
+ } catch {
178
+ log('Claude CLI not found — skipping MCP registration')
179
+ log(' Run manually: claude mcp add claude-brain -- claude-brain serve')
180
+ return false
181
+ }
182
+
183
+ // Check if already configured
184
+ try {
185
+ const result = execSync('claude mcp list', { encoding: 'utf-8', stdio: 'pipe', timeout: 10000 })
186
+ if (result.includes('claude-brain')) {
187
+ log('MCP server already registered')
188
+ return true
189
+ }
190
+ } catch {}
191
+
192
+ // Register
193
+ try {
194
+ execSync('claude mcp add claude-brain -- claude-brain serve', {
195
+ stdio: 'pipe',
196
+ timeout: 15000,
197
+ })
198
+ log('Registered as MCP server in Claude Code')
199
+ return true
200
+ } catch (err) {
201
+ const msg = err instanceof Error ? err.message : String(err)
202
+ if (msg.includes('already') || msg.includes('exists')) {
203
+ log('MCP server already registered')
204
+ return true
205
+ }
206
+ log('MCP registration failed — run manually: claude mcp add claude-brain -- claude-brain serve')
207
+ return false
208
+ }
209
+ }
210
+
211
+ // ── Step 4: Install hooks ────────────────────────────────
212
+
213
+ function installHooks() {
214
+ // Read existing settings
215
+ let settings = {}
216
+ if (existsSync(CLAUDE_SETTINGS)) {
217
+ try {
218
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf-8'))
219
+ } catch {}
220
+ }
221
+
222
+ // Check if already installed
223
+ function hasOurHooks(entries) {
224
+ if (!Array.isArray(entries)) return false
225
+ return entries.some(entry =>
226
+ entry && Array.isArray(entry.hooks) &&
227
+ entry.hooks.some(h => typeof h.command === 'string' && h.command.includes(HOOK_MARKER))
228
+ )
229
+ }
230
+
231
+ if (settings.hooks && (hasOurHooks(settings.hooks.PostToolUse) || hasOurHooks(settings.hooks.Stop))) {
232
+ log('Hooks already installed')
233
+ return true
234
+ }
235
+
236
+ // Build hook command
237
+ const scriptPath = join(HOME, 'hooks', 'brain-hook.ts')
238
+ function buildCmd(event) {
239
+ return `bun "${scriptPath}" --event ${event} # ${HOOK_MARKER}`
240
+ }
241
+
242
+ if (!settings.hooks) settings.hooks = {}
243
+
244
+ // PostToolUse
245
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = []
246
+ settings.hooks.PostToolUse.push({
247
+ matcher: '',
248
+ hooks: [{ type: 'command', command: buildCmd('PostToolUse') }],
249
+ })
250
+
251
+ // Stop
252
+ if (!settings.hooks.Stop) settings.hooks.Stop = []
253
+ settings.hooks.Stop.push({
254
+ matcher: '',
255
+ hooks: [{ type: 'command', command: buildCmd('Stop') }],
256
+ })
257
+
258
+ // Write atomically
259
+ if (!existsSync(CLAUDE_DIR)) {
260
+ mkdirSync(CLAUDE_DIR, { recursive: true })
261
+ }
262
+ const tmpPath = CLAUDE_SETTINGS + '.tmp'
263
+ writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
264
+ renameSync(tmpPath, CLAUDE_SETTINGS)
265
+
266
+ log('Hooks installed')
267
+ return true
268
+ }
269
+
270
+ // ── Step 5: Install CLAUDE.md ────────────────────────────
271
+
272
+ function installClaudeMd() {
273
+ // Find the assets/CLAUDE.md relative to this script
274
+ // scripts/postinstall.mjs → assets/CLAUDE.md
275
+ const assetsPath = join(__dirname, '..', 'assets', 'CLAUDE.md')
276
+
277
+ if (!existsSync(assetsPath)) {
278
+ log('CLAUDE.md asset not found — skipping')
279
+ return false
280
+ }
281
+
282
+ // Only install if no CLAUDE.md exists yet (don't overwrite user customizations)
283
+ if (existsSync(CLAUDE_MD_PATH)) {
284
+ // Check if it already mentions claude-brain
285
+ const existing = readFileSync(CLAUDE_MD_PATH, 'utf-8')
286
+ if (existing.includes('brain') || existing.includes('Brain')) {
287
+ log('CLAUDE.md already configured')
288
+ return true
289
+ }
290
+ // Append our section
291
+ const addition = readFileSync(assetsPath, 'utf-8')
292
+ writeFileSync(CLAUDE_MD_PATH, existing.trimEnd() + '\n\n' + addition, 'utf-8')
293
+ log('Appended brain instructions to existing CLAUDE.md')
294
+ return true
295
+ }
296
+
297
+ // Create new
298
+ if (!existsSync(CLAUDE_DIR)) {
299
+ mkdirSync(CLAUDE_DIR, { recursive: true })
300
+ }
301
+ const content = readFileSync(assetsPath, 'utf-8')
302
+ writeFileSync(CLAUDE_MD_PATH, content, 'utf-8')
303
+ log('Installed CLAUDE.md')
304
+ return true
305
+ }
306
+
307
+ // ── Main ─────────────────────────────────────────────────
308
+
309
+ async function main() {
310
+ const skipReason = shouldSkip()
311
+ if (skipReason) {
312
+ log(`Skipping postinstall (${skipReason})`)
313
+ return
314
+ }
315
+
316
+ log('Running postinstall setup...')
317
+ console.error('')
318
+
319
+ const results = {
320
+ home: false,
321
+ chromadb: false,
322
+ mcp: false,
323
+ hooks: false,
324
+ claudemd: false,
325
+ }
326
+
327
+ try { results.home = setupHomeDirectory() } catch (e) { log(`Home setup failed: ${e.message}`) }
328
+ try { results.chromadb = installChromaDB() } catch (e) { log(`ChromaDB install failed: ${e.message}`) }
329
+ try { results.mcp = registerMcpServer() } catch (e) { log(`MCP registration failed: ${e.message}`) }
330
+ try { results.hooks = installHooks() } catch (e) { log(`Hook install failed: ${e.message}`) }
331
+ try { results.claudemd = installClaudeMd() } catch (e) { log(`CLAUDE.md install failed: ${e.message}`) }
332
+
333
+ console.error('')
334
+ log('Setup complete! Restart Claude Code to activate.')
335
+ }
336
+
337
+ main().catch(err => {
338
+ log(`Postinstall error: ${err.message}`)
339
+ }).finally(() => {
340
+ process.exit(0)
341
+ })
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Background auto-update check for claude-brain.
3
+ * Runs on every `serve` start, rate-limited to 1 check per hour.
4
+ * All output to stderr (stdout reserved for MCP JSON-RPC).
5
+ * Never throws — all errors silently caught.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
9
+ import { join, dirname } from 'node:path'
10
+ import { execSync } from 'node:child_process'
11
+ import { getHomePaths } from '@/config/home'
12
+
13
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000 // 1 hour
14
+ const TIMESTAMP_FILE = 'last-update-check'
15
+
16
+ function getTimestampPath(): string {
17
+ return join(getHomePaths().data, TIMESTAMP_FILE)
18
+ }
19
+
20
+ function shouldCheck(): boolean {
21
+ const tsPath = getTimestampPath()
22
+ if (!existsSync(tsPath)) return true
23
+
24
+ try {
25
+ const lastCheck = parseInt(readFileSync(tsPath, 'utf-8').trim(), 10)
26
+ return Date.now() - lastCheck > CHECK_INTERVAL_MS
27
+ } catch {
28
+ return true
29
+ }
30
+ }
31
+
32
+ function writeTimestamp(): void {
33
+ const tsPath = getTimestampPath()
34
+ const dir = dirname(tsPath)
35
+ if (!existsSync(dir)) {
36
+ mkdirSync(dir, { recursive: true })
37
+ }
38
+ writeFileSync(tsPath, String(Date.now()), 'utf-8')
39
+ }
40
+
41
+ function getCurrentVersion(): string | null {
42
+ try {
43
+ // Read from our own package.json
44
+ const pkgPath = join(dirname(new URL(import.meta.url).pathname), '..', '..', 'package.json')
45
+ if (existsSync(pkgPath)) {
46
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
47
+ return pkg.version || null
48
+ }
49
+ } catch {}
50
+
51
+ // Fallback: read VERSION file
52
+ try {
53
+ const versionPath = join(dirname(new URL(import.meta.url).pathname), '..', '..', 'VERSION')
54
+ if (existsSync(versionPath)) {
55
+ return readFileSync(versionPath, 'utf-8').trim()
56
+ }
57
+ } catch {}
58
+
59
+ return null
60
+ }
61
+
62
+ function getLatestVersion(): string | null {
63
+ try {
64
+ const result = execSync('npm view claude-brain version', {
65
+ encoding: 'utf-8',
66
+ stdio: ['pipe', 'pipe', 'pipe'],
67
+ timeout: 10_000,
68
+ })
69
+ return result.trim() || null
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ function isNewer(latest: string, current: string): boolean {
76
+ const parse = (v: string) => v.split('.').map(Number)
77
+ const [lMaj, lMin, lPat] = parse(latest)
78
+ const [cMaj, cMin, cPat] = parse(current)
79
+
80
+ if (lMaj !== cMaj) return lMaj > cMaj
81
+ if (lMin !== cMin) return lMin > cMin
82
+ return lPat > cPat
83
+ }
84
+
85
+ function performUpdate(): boolean {
86
+ // Try bun first, fallback to npm
87
+ const commands = [
88
+ 'bun update -g claude-brain',
89
+ 'npm update -g claude-brain',
90
+ ]
91
+
92
+ for (const cmd of commands) {
93
+ try {
94
+ execSync(cmd, {
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ timeout: 120_000,
97
+ })
98
+ return true
99
+ } catch {}
100
+ }
101
+
102
+ return false
103
+ }
104
+
105
+ function postUpdateTasks(): void {
106
+ // Re-install hooks (idempotent)
107
+ try {
108
+ const { installHooks } = require('@/hooks/installer')
109
+ installHooks()
110
+ } catch {}
111
+
112
+ // Re-install CLAUDE.md if asset exists
113
+ try {
114
+ const { existsSync: exists, readFileSync: read, writeFileSync: write } = require('node:fs')
115
+ const { join: pjoin } = require('node:path')
116
+ const { homedir } = require('node:os')
117
+
118
+ const assetsPath = pjoin(dirname(new URL(import.meta.url).pathname), '..', '..', 'assets', 'CLAUDE.md')
119
+ const claudeMdPath = pjoin(homedir(), '.claude', 'CLAUDE.md')
120
+
121
+ if (exists(assetsPath)) {
122
+ if (!exists(claudeMdPath)) {
123
+ write(claudeMdPath, read(assetsPath, 'utf-8'), 'utf-8')
124
+ }
125
+ }
126
+ } catch {}
127
+ }
128
+
129
+ async function checkAndUpdate(): Promise<void> {
130
+ if (!shouldCheck()) return
131
+
132
+ writeTimestamp()
133
+
134
+ const current = getCurrentVersion()
135
+ if (!current) return
136
+
137
+ const latest = getLatestVersion()
138
+ if (!latest) return
139
+
140
+ if (!isNewer(latest, current)) return
141
+
142
+ console.error(`[claude-brain] Update available: ${current} → ${latest}`)
143
+
144
+ const updated = performUpdate()
145
+ if (updated) {
146
+ console.error(`[claude-brain] Updated to ${latest}`)
147
+ postUpdateTasks()
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Fire-and-forget background update check.
153
+ * Call this from serve.ts — it never rejects.
154
+ */
155
+ export function checkForUpdateInBackground(): Promise<void> {
156
+ return checkAndUpdate().catch(() => {})
157
+ }
@@ -18,6 +18,12 @@ 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
+
21
27
  // Auto-install Claude Code hooks (idempotent, non-fatal)
22
28
  try {
23
29
  const { installHooks } = await import('@/hooks/installer')
@@ -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.14.4',
6
+ serverVersion: '0.15.1',
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.14.4'),
287
+ serverVersion: z.string().regex(/^\d+\.\d+\.\d+$/, 'Version must be semver format').default('0.15.1'),
288
288
 
289
289
  /** Logging level */
290
290
  logLevel: LogLevelSchema.default('info'),
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'
7
7
  import { join, dirname } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
8
9
  import { homedir } from 'node:os'
9
10
  import { getClaudeBrainHome } from '@/config/home'
10
11
 
@@ -169,7 +170,9 @@ function copyHookScript(): void {
169
170
  mkdirSync(destDir, { recursive: true })
170
171
  }
171
172
 
172
- const srcDir = dirname(new URL(import.meta.url).pathname)
173
+ // Use Bun's import.meta.dir if available, otherwise fileURLToPath for Windows compat
174
+ // (new URL(...).pathname returns "/C:/..." on Windows, breaking existsSync)
175
+ const srcDir = (import.meta as any).dir ?? dirname(fileURLToPath(import.meta.url))
173
176
 
174
177
  for (const file of HOOK_FILES) {
175
178
  const srcPath = join(srcDir, file)
@@ -111,6 +111,36 @@ export class PassiveClassifier {
111
111
 
112
112
  // Check for new file creation (Write tool)
113
113
  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
+ }
141
+ }
142
+ }
143
+
114
144
  return {
115
145
  type: 'pattern',
116
146
  confidence: 0.7,
@@ -161,10 +191,19 @@ export class PassiveClassifier {
161
191
  }
162
192
 
163
193
  private classifyBashCommand(input: HookInput): CapturedKnowledge | null {
164
- const command = (input.tool_input?.command || '') as string
165
- if (!command || command.length < 3) return null
194
+ const rawCommand = (input.tool_input?.command || '') as string
195
+ if (!rawCommand || rawCommand.length < 3) return null
196
+
197
+ // Split compound commands (cd "..." && bun add react) into sub-commands
198
+ const subCommands = rawCommand.split(/\s*(?:&&|\|\||;)\s*/).map(s => s.trim()).filter(Boolean)
199
+
200
+ // Find the first meaningful sub-command (skip cd, export, etc.)
201
+ const command = subCommands.find(sub => {
202
+ const firstWord = sub.split(/\s+/)[0]?.toLowerCase()
203
+ return !firstWord || !SKIP_COMMANDS.has(firstWord)
204
+ }) || rawCommand
166
205
 
167
- // Skip low-signal commands
206
+ // If all sub-commands are skip-worthy, bail
168
207
  const firstWord = command.trim().split(/\s+/)[0]?.toLowerCase()
169
208
  if (firstWord && SKIP_COMMANDS.has(firstWord)) return null
170
209
 
@@ -180,7 +219,7 @@ export class PassiveClassifier {
180
219
  content: `Installed package(s): ${packages}`,
181
220
  project: this.extractProjectFromCwd(input.cwd),
182
221
  technologies: this.extractTechFromPackages(packages),
183
- metadata: { command, action: 'install' },
222
+ metadata: { command: rawCommand, action: 'install' },
184
223
  source: 'hook-passive',
185
224
  timestamp: new Date().toISOString(),
186
225
  }
@@ -198,16 +237,17 @@ export class PassiveClassifier {
198
237
  content: `Git: ${command.trim().slice(0, 200)}`,
199
238
  project: this.extractProjectFromCwd(input.cwd),
200
239
  technologies: ['git'],
201
- metadata: { command, action: 'git' },
240
+ metadata: { command: rawCommand, action: 'git' },
202
241
  source: 'hook-passive',
203
242
  timestamp: new Date().toISOString(),
204
243
  }
205
244
  }
206
245
  }
207
246
 
208
- // Test/build runs
247
+ // Test/build runs — check all sub-commands since test may follow cd
248
+ const buildCommand = subCommands.find(sub => BUILD_PATTERNS.some(p => p.test(sub))) || command
209
249
  for (const pattern of BUILD_PATTERNS) {
210
- if (pattern.test(command)) {
250
+ if (pattern.test(buildCommand)) {
211
251
  const responseText = this.extractResponseText(input.tool_response)
212
252
  const failed = responseText?.toLowerCase().includes('fail') ||
213
253
  responseText?.toLowerCase().includes('error')
@@ -216,10 +256,10 @@ export class PassiveClassifier {
216
256
  return {
217
257
  type: 'correction',
218
258
  confidence: 0.75,
219
- content: `Build/test failure: ${command.trim().slice(0, 100)}`,
259
+ content: `Build/test failure: ${buildCommand.trim().slice(0, 100)}`,
220
260
  project: this.extractProjectFromCwd(input.cwd),
221
261
  technologies: [],
222
- metadata: { command, action: 'build', failed: true },
262
+ metadata: { command: rawCommand, action: 'build', failed: true },
223
263
  source: 'hook-passive',
224
264
  timestamp: new Date().toISOString(),
225
265
  }
@@ -228,10 +268,10 @@ export class PassiveClassifier {
228
268
  return {
229
269
  type: 'progress',
230
270
  confidence: 0.7,
231
- content: `Ran: ${command.trim().slice(0, 200)}`,
271
+ content: `Ran: ${buildCommand.trim().slice(0, 200)}`,
232
272
  project: this.extractProjectFromCwd(input.cwd),
233
273
  technologies: [],
234
- metadata: { command, action: 'build', failed: false },
274
+ metadata: { command: rawCommand, action: 'build', failed: false },
235
275
  source: 'hook-passive',
236
276
  timestamp: new Date().toISOString(),
237
277
  }
@@ -249,7 +289,7 @@ export class PassiveClassifier {
249
289
  content: correction,
250
290
  project: this.extractProjectFromCwd(input.cwd),
251
291
  technologies: [],
252
- metadata: { command, action: 'bash' },
292
+ metadata: { command: rawCommand, action: 'bash' },
253
293
  source: 'hook-passive',
254
294
  timestamp: new Date().toISOString(),
255
295
  }
@@ -281,7 +321,7 @@ export class PassiveClassifier {
281
321
 
282
322
  /** Extract file role from path segments */
283
323
  private extractRoleFromPath(filePath: string): string | undefined {
284
- const segments = filePath.toLowerCase().split('/')
324
+ const segments = filePath.toLowerCase().split(/[/\\]/)
285
325
  for (const segment of segments) {
286
326
  if (PATH_ROLE_MAP[segment]) return PATH_ROLE_MAP[segment]
287
327
  }
@@ -331,7 +371,8 @@ export class PassiveClassifier {
331
371
  /** Extract project name from cwd (last directory segment) */
332
372
  private extractProjectFromCwd(cwd: string): string | undefined {
333
373
  if (!cwd) return undefined
334
- const parts = cwd.split('/').filter(Boolean)
374
+ // Split on both / and \ for cross-platform support
375
+ const parts = cwd.split(/[/\\]/).filter(Boolean)
335
376
  const last = parts.pop()
336
377
  if (last && last.length > 1 && last.length < 50) {
337
378
  return last.replace(/\s+/g, '-').toLowerCase()
@@ -341,7 +382,7 @@ export class PassiveClassifier {
341
382
 
342
383
  /** Shorten a file path for display */
343
384
  private shortenPath(filePath: string): string {
344
- const parts = filePath.split('/')
385
+ const parts = filePath.split(/[/\\]/)
345
386
  if (parts.length <= 3) return filePath
346
387
  return `.../${parts.slice(-3).join('/')}`
347
388
  }
@@ -135,7 +135,7 @@ export class OpenAIEmbeddingProvider implements EmbeddingProvider {
135
135
  export function createEmbeddingProvider(
136
136
  logger: Logger,
137
137
  config: ChromaConfig
138
- ): EmbeddingProvider {
138
+ ): EmbeddingProvider | undefined {
139
139
  switch (config.embeddingProvider) {
140
140
  case 'openai':
141
141
  if (!config.openaiApiKey) {
@@ -148,6 +148,8 @@ export function createEmbeddingProvider(
148
148
 
149
149
  case 'default':
150
150
  default:
151
- return new DefaultEmbeddingProvider()
151
+ // Return undefined so store/search code falls through to queryTexts path
152
+ // (ChromaDB's built-in auto-embedding)
153
+ return undefined
152
154
  }
153
155
  }
@@ -23,7 +23,7 @@ export class ChromaManager {
23
23
  public collections: CollectionManager
24
24
  public store: ChromaMemoryStore
25
25
  public search: ChromaSearchEngine
26
- public embeddings: EmbeddingProvider
26
+ public embeddings: EmbeddingProvider | undefined
27
27
 
28
28
  private initialized: boolean = false
29
29