claude-brain 0.17.13 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/VERSION +1 -1
  2. package/package.json +3 -1
  3. package/scripts/postinstall.mjs +80 -104
  4. package/src/cli/auto-setup.ts +1 -9
  5. package/src/cli/bin.ts +23 -2
  6. package/src/cli/commands/export.ts +130 -0
  7. package/src/cli/commands/reindex.ts +107 -0
  8. package/src/cli/commands/serve.ts +54 -0
  9. package/src/cli/commands/status.ts +158 -0
  10. package/src/code-intelligence/indexer.ts +315 -0
  11. package/src/code-intelligence/linker.ts +178 -0
  12. package/src/code-intelligence/parser.ts +484 -0
  13. package/src/code-intelligence/query.ts +291 -0
  14. package/src/code-intelligence/schema.ts +83 -0
  15. package/src/code-intelligence/types.ts +95 -0
  16. package/src/config/defaults.ts +3 -3
  17. package/src/config/loader.ts +6 -0
  18. package/src/config/schema.ts +28 -2
  19. package/src/health/index.ts +5 -2
  20. package/src/hooks/brain-hook.ts +4 -1
  21. package/src/hooks/context-hook.ts +69 -10
  22. package/src/hooks/installer.ts +4 -7
  23. package/src/intelligence/cross-project/index.ts +1 -7
  24. package/src/intelligence/prediction/index.ts +1 -7
  25. package/src/intelligence/reasoning/index.ts +1 -7
  26. package/src/memory/compression.ts +105 -0
  27. package/src/memory/fts5-search.ts +456 -0
  28. package/src/memory/index.ts +342 -38
  29. package/src/memory/migrations/add-fts5.ts +98 -0
  30. package/src/memory/pruning.ts +60 -0
  31. package/src/routing/intent-classifier.ts +58 -1
  32. package/src/routing/response-filter.ts +128 -0
  33. package/src/routing/router.ts +457 -54
  34. package/src/server/http-api.ts +319 -1
  35. package/src/server/providers/resources.ts +1 -42
  36. package/src/server/services.ts +113 -12
  37. package/src/server/web-viewer.ts +1115 -0
  38. package/src/setup/index.ts +12 -22
  39. package/src/tools/schemas.ts +1 -1
  40. package/src/intelligence/cross-project/affinity.ts +0 -159
  41. package/src/intelligence/cross-project/transfer.ts +0 -201
  42. package/src/intelligence/prediction/context-anticipator.ts +0 -198
  43. package/src/intelligence/prediction/decision-predictor.ts +0 -184
  44. package/src/intelligence/reasoning/counterfactual.ts +0 -248
  45. package/src/intelligence/reasoning/synthesizer.ts +0 -167
  46. package/src/setup/wizard.ts +0 -459
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Status Command — Phase 30
3
+ * Shows a system overview of Claude Brain's current state.
4
+ *
5
+ * Usage: claude-brain status
6
+ */
7
+
8
+ import { readFileSync, existsSync } from 'node:fs'
9
+ import { resolve, join } from 'node:path'
10
+ import { renderLogo, theme, heading, dimText, successText, warningText, summaryPanel } from '@/cli/ui/index.js'
11
+ import { getClaudeBrainHome } from '@/config/home'
12
+
13
+ export async function runStatus() {
14
+ console.log()
15
+ console.log(renderLogo())
16
+ console.log()
17
+ console.log(heading('System Status'))
18
+ console.log()
19
+
20
+ // Version
21
+ let version = 'unknown'
22
+ try {
23
+ const pkgPath = resolve(__dirname, '..', '..', '..', 'package.json')
24
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
25
+ version = pkg.version || 'unknown'
26
+ } catch {
27
+ // fall through
28
+ }
29
+
30
+ const home = getClaudeBrainHome()
31
+ const items: Array<{ label: string; value: string; status?: 'success' | 'warning' | 'error' | 'info' }> = []
32
+
33
+ items.push({ label: 'Version', value: `v${version}`, status: 'info' })
34
+
35
+ // Storage info
36
+ const dbPath = join(home, 'data', 'memory.db')
37
+ if (existsSync(dbPath)) {
38
+ try {
39
+ const { Database } = await import('bun:sqlite')
40
+ const db = new Database(dbPath, { readonly: true })
41
+
42
+ // Count observations by category
43
+ const total = (db.prepare('SELECT COUNT(*) as cnt FROM observations WHERE archived = 0').get() as any)?.cnt ?? 0
44
+ const decisions = (db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE category = 'decision' AND archived = 0").get() as any)?.cnt ?? 0
45
+ const patterns = (db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE category = 'pattern' AND archived = 0").get() as any)?.cnt ?? 0
46
+ const corrections = (db.prepare("SELECT COUNT(*) as cnt FROM observations WHERE category = 'correction' AND archived = 0").get() as any)?.cnt ?? 0
47
+
48
+ items.push({ label: 'Storage', value: 'SQLite FTS5', status: 'success' })
49
+ items.push({
50
+ label: 'Observations',
51
+ value: `${total} (${decisions} decisions, ${patterns} patterns, ${corrections} corrections)`,
52
+ status: total > 0 ? 'success' : 'warning'
53
+ })
54
+
55
+ // Projects
56
+ const projects = db.prepare('SELECT DISTINCT project FROM observations WHERE archived = 0').all() as any[]
57
+ items.push({
58
+ label: 'Projects',
59
+ value: projects.length > 0 ? projects.map(p => p.project).join(', ') : '(none)',
60
+ status: projects.length > 0 ? 'success' : 'warning'
61
+ })
62
+
63
+ db.close()
64
+ } catch {
65
+ items.push({ label: 'Storage', value: 'SQLite FTS5 (error reading DB)', status: 'error' })
66
+ }
67
+ } else {
68
+ items.push({ label: 'Storage', value: 'Not initialized', status: 'warning' })
69
+ }
70
+
71
+ // Code intelligence
72
+ const codeDbPath = join(home, 'data', 'code.db')
73
+ if (existsSync(codeDbPath)) {
74
+ try {
75
+ const { Database } = await import('bun:sqlite')
76
+ const codeDb = new Database(codeDbPath, { readonly: true })
77
+
78
+ const files = (codeDb.prepare('SELECT COUNT(*) as cnt FROM code_files').get() as any)?.cnt ?? 0
79
+ const symbols = (codeDb.prepare('SELECT COUNT(*) as cnt FROM code_symbols').get() as any)?.cnt ?? 0
80
+
81
+ // Last indexed time
82
+ const latest = (codeDb.prepare('SELECT MAX(indexed_at) as latest FROM code_files').get() as any)?.latest
83
+ const lastIndexed = latest ? formatAge(latest) : 'never'
84
+
85
+ items.push({
86
+ label: 'Code Index',
87
+ value: `${files} files, ${symbols} symbols (last indexed: ${lastIndexed})`,
88
+ status: files > 0 ? 'success' : 'warning'
89
+ })
90
+
91
+ codeDb.close()
92
+ } catch {
93
+ items.push({ label: 'Code Index', value: 'Not available', status: 'warning' })
94
+ }
95
+ } else {
96
+ items.push({ label: 'Code Index', value: 'Not initialized', status: 'warning' })
97
+ }
98
+
99
+ // Hooks status
100
+ const hookNames = ['PostToolUse', 'Stop', 'SessionStart', 'UserPromptSubmit']
101
+ const settingsPath = join(process.env.HOME || '', '.claude', 'settings.json')
102
+ let installedHooks: string[] = []
103
+ if (existsSync(settingsPath)) {
104
+ try {
105
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
106
+ const hooks = settings.hooks || {}
107
+ for (const hookName of hookNames) {
108
+ const hookEntries = hooks[hookName] || []
109
+ const hasBrain = hookEntries.some((h: any) => {
110
+ const cmd = typeof h === 'string' ? h : h.command || ''
111
+ return cmd.includes('claude-brain')
112
+ })
113
+ if (hasBrain) installedHooks.push(hookName)
114
+ }
115
+ } catch {
116
+ // settings parse failed
117
+ }
118
+ }
119
+ items.push({
120
+ label: 'Hooks',
121
+ value: installedHooks.length > 0 ? `${installedHooks.length} installed (${installedHooks.join(', ')})` : 'Not installed',
122
+ status: installedHooks.length > 0 ? 'success' : 'warning'
123
+ })
124
+
125
+ // Server status
126
+ const port = process.env.CLAUDE_BRAIN_PORT || '3000'
127
+ try {
128
+ const res = await fetch(`http://localhost:${port}/api/health`, {
129
+ signal: AbortSignal.timeout(2000)
130
+ })
131
+ if (res.ok) {
132
+ items.push({ label: 'Server', value: `http://localhost:${port} (running)`, status: 'success' })
133
+ } else {
134
+ items.push({ label: 'Server', value: 'Not responding', status: 'warning' })
135
+ }
136
+ } catch {
137
+ items.push({ label: 'Server', value: 'Not running', status: 'warning' })
138
+ }
139
+
140
+ console.log(summaryPanel('Claude Brain', items))
141
+ console.log()
142
+ }
143
+
144
+ function formatAge(isoDate: string): string {
145
+ try {
146
+ const diff = Date.now() - new Date(isoDate).getTime()
147
+ const minutes = Math.floor(diff / 60000)
148
+ if (minutes < 1) return 'just now'
149
+ if (minutes < 60) return `${minutes} minutes ago`
150
+ const hours = Math.floor(minutes / 60)
151
+ if (hours < 24) return `${hours} hours ago`
152
+ const days = Math.floor(hours / 24)
153
+ if (days === 1) return 'yesterday'
154
+ return `${days} days ago`
155
+ } catch {
156
+ return 'unknown'
157
+ }
158
+ }
@@ -0,0 +1,315 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { readdir, stat } from 'node:fs/promises'
3
+ import { join, relative, extname } from 'node:path'
4
+ import { createHash } from 'node:crypto'
5
+ import type { Logger } from 'pino'
6
+ import type { CodeParser } from './parser'
7
+ import type { IndexResult, IndexStats, ParsedFile } from './types'
8
+ import { createCodeSchema } from './schema'
9
+
10
+ const SKIP_DIRS = new Set([
11
+ 'node_modules', '.git', 'dist', 'build', '__pycache__',
12
+ '.venv', 'target', '.cache', 'coverage', '.turbo', '.output', 'vendor',
13
+ ])
14
+
15
+ const SKIP_FILES = new Set([
16
+ 'package-lock.json', 'yarn.lock', 'bun.lockb', 'pnpm-lock.yaml', '.DS_Store',
17
+ ])
18
+
19
+ const MAX_FILE_SIZE = 500 * 1024 // 500KB
20
+
21
+ const SUPPORTED_EXTENSIONS = new Set([
22
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
23
+ '.py', '.go', '.rs', '.vue',
24
+ '.html', '.css', '.json', '.yaml', '.yml',
25
+ ])
26
+
27
+ const BATCH_SIZE = 100
28
+
29
+ export class CodeIndexer {
30
+ private db: Database
31
+ private parser: CodeParser
32
+ private logger: Logger
33
+
34
+ constructor(db: Database, parser: CodeParser, logger: Logger) {
35
+ this.db = db
36
+ this.parser = parser
37
+ this.logger = logger.child({ component: 'code-indexer' })
38
+ }
39
+
40
+ async initialize(): Promise<void> {
41
+ createCodeSchema(this.db)
42
+ this.logger.info('Code intelligence schema initialized')
43
+ }
44
+
45
+ async indexProject(projectPath: string, projectName: string): Promise<IndexResult> {
46
+ const startTime = Date.now()
47
+ const errors: string[] = []
48
+ let filesIndexed = 0
49
+ let filesSkipped = 0
50
+ let symbolsFound = 0
51
+
52
+ this.logger.info({ projectPath, projectName }, 'Starting project index')
53
+
54
+ // Collect all eligible files
55
+ const files = await this.collectFiles(projectPath)
56
+ this.logger.info({ fileCount: files.length }, 'Files collected for indexing')
57
+
58
+ // Process in batches
59
+ for (let i = 0; i < files.length; i += BATCH_SIZE) {
60
+ const batch = files.slice(i, i + BATCH_SIZE)
61
+ this.db.run('BEGIN TRANSACTION')
62
+ try {
63
+ for (const filePath of batch) {
64
+ try {
65
+ const result = await this.indexSingleFile(filePath, projectPath, projectName)
66
+ if (result.skipped) {
67
+ filesSkipped++
68
+ } else {
69
+ filesIndexed++
70
+ symbolsFound += result.symbolCount
71
+ }
72
+ } catch (error) {
73
+ const msg = `Failed to index ${filePath}: ${error instanceof Error ? error.message : String(error)}`
74
+ errors.push(msg)
75
+ this.logger.warn({ filePath, error }, 'Failed to index file')
76
+ }
77
+ }
78
+ this.db.run('COMMIT')
79
+ } catch (error) {
80
+ this.db.run('ROLLBACK')
81
+ const msg = `Batch failed at offset ${i}: ${error instanceof Error ? error.message : String(error)}`
82
+ errors.push(msg)
83
+ this.logger.error({ error, batchOffset: i }, 'Batch transaction failed')
84
+ }
85
+ }
86
+
87
+ const durationMs = Date.now() - startTime
88
+ this.logger.info(
89
+ { filesIndexed, filesSkipped, symbolsFound, durationMs, errors: errors.length },
90
+ 'Project indexing complete'
91
+ )
92
+
93
+ return {
94
+ project: projectName,
95
+ filesIndexed,
96
+ filesSkipped,
97
+ symbolsFound,
98
+ durationMs,
99
+ errors,
100
+ }
101
+ }
102
+
103
+ async indexFile(filePath: string, projectName: string): Promise<void> {
104
+ this.db.run('BEGIN TRANSACTION')
105
+ try {
106
+ // Clear existing data for this file
107
+ this.db.run('DELETE FROM code_symbols WHERE project = ? AND file_path = ?', [projectName, filePath])
108
+ this.db.run('DELETE FROM code_dependencies WHERE project = ? AND source_file = ?', [projectName, filePath])
109
+ this.db.run('DELETE FROM code_files WHERE project = ? AND file_path = ?', [projectName, filePath])
110
+
111
+ const content = await Bun.file(filePath).text()
112
+ const hash = createHash('sha256').update(content).digest('hex')
113
+ const parsed = await this.parser.parseFile(filePath, content)
114
+
115
+ this.storeFile(parsed, projectName, hash)
116
+ this.storeSymbols(parsed, projectName)
117
+ this.storeDependencies(parsed, projectName)
118
+
119
+ this.db.run('COMMIT')
120
+ this.logger.debug({ filePath, symbols: parsed.symbols.length }, 'File re-indexed')
121
+ } catch (error) {
122
+ this.db.run('ROLLBACK')
123
+ throw error
124
+ }
125
+ }
126
+
127
+ async clearProject(projectName: string): Promise<void> {
128
+ this.db.run('BEGIN TRANSACTION')
129
+ try {
130
+ this.db.run('DELETE FROM code_symbols WHERE project = ?', [projectName])
131
+ this.db.run('DELETE FROM code_dependencies WHERE project = ?', [projectName])
132
+ this.db.run('DELETE FROM code_files WHERE project = ?', [projectName])
133
+ this.db.run('COMMIT')
134
+ this.logger.info({ projectName }, 'Project data cleared')
135
+ } catch (error) {
136
+ this.db.run('ROLLBACK')
137
+ throw error
138
+ }
139
+ }
140
+
141
+ getStats(projectName: string): IndexStats {
142
+ const fileRow = this.db.query(
143
+ 'SELECT COUNT(*) as cnt FROM code_files WHERE project = ?'
144
+ ).get(projectName) as { cnt: number } | null
145
+
146
+ const symbolRow = this.db.query(
147
+ 'SELECT COUNT(*) as cnt FROM code_symbols WHERE project = ?'
148
+ ).get(projectName) as { cnt: number } | null
149
+
150
+ const lastRow = this.db.query(
151
+ 'SELECT MAX(last_indexed) as last_indexed FROM code_files WHERE project = ?'
152
+ ).get(projectName) as { last_indexed: string | null } | null
153
+
154
+ return {
155
+ project: projectName,
156
+ filesIndexed: fileRow?.cnt ?? 0,
157
+ totalSymbols: symbolRow?.cnt ?? 0,
158
+ lastIndexed: lastRow?.last_indexed ?? null,
159
+ staleFiles: 0, // Could be computed by comparing file hashes
160
+ }
161
+ }
162
+
163
+ generateFileSummary(parsed: ParsedFile): string {
164
+ const exported = parsed.symbols.filter(s => s.exported)
165
+ if (exported.length === 0) {
166
+ return `${parsed.language} file, ${parsed.lineCount} lines`
167
+ }
168
+
169
+ const grouped = new Map<string, string[]>()
170
+ for (const sym of exported) {
171
+ const existing = grouped.get(sym.type) ?? []
172
+ existing.push(sym.name)
173
+ grouped.set(sym.type, existing)
174
+ }
175
+
176
+ const parts: string[] = []
177
+ for (const [type, names] of grouped) {
178
+ if (names.length <= 3) {
179
+ parts.push(`${names.join(', ')} (${type})`)
180
+ } else {
181
+ parts.push(`${names.slice(0, 3).join(', ')} +${names.length - 3} (${type})`)
182
+ }
183
+ }
184
+
185
+ return parts.join(' — ')
186
+ }
187
+
188
+ // ─── Private Helpers ──────────────────────────────────────
189
+
190
+ private async collectFiles(dirPath: string): Promise<string[]> {
191
+ const files: string[] = []
192
+ await this.walkDirectory(dirPath, files)
193
+ return files
194
+ }
195
+
196
+ private async walkDirectory(dirPath: string, files: string[]): Promise<void> {
197
+ let entries
198
+ try {
199
+ entries = await readdir(dirPath, { withFileTypes: true })
200
+ } catch {
201
+ return // Skip unreadable directories
202
+ }
203
+
204
+ for (const entry of entries) {
205
+ if (entry.isDirectory()) {
206
+ if (!SKIP_DIRS.has(entry.name)) {
207
+ await this.walkDirectory(join(dirPath, entry.name), files)
208
+ }
209
+ } else if (entry.isFile()) {
210
+ if (SKIP_FILES.has(entry.name)) continue
211
+ const ext = extname(entry.name).toLowerCase()
212
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue
213
+ files.push(join(dirPath, entry.name))
214
+ }
215
+ }
216
+ }
217
+
218
+ private async indexSingleFile(
219
+ filePath: string,
220
+ projectPath: string,
221
+ projectName: string
222
+ ): Promise<{ skipped: boolean; symbolCount: number }> {
223
+ // Check file size
224
+ const fileStats = await stat(filePath)
225
+ if (fileStats.size > MAX_FILE_SIZE) {
226
+ return { skipped: true, symbolCount: 0 }
227
+ }
228
+
229
+ const content = await Bun.file(filePath).text()
230
+ const hash = createHash('sha256').update(content).digest('hex')
231
+
232
+ // Check if file is unchanged
233
+ const relativePath = relative(projectPath, filePath)
234
+ const existing = this.db.query(
235
+ 'SELECT file_hash FROM code_files WHERE project = ? AND file_path = ?'
236
+ ).get(projectName, relativePath) as { file_hash: string | null } | null
237
+
238
+ if (existing?.file_hash === hash) {
239
+ return { skipped: true, symbolCount: 0 }
240
+ }
241
+
242
+ // Clear old data for this file
243
+ this.db.run('DELETE FROM code_symbols WHERE project = ? AND file_path = ?', [projectName, relativePath])
244
+ this.db.run('DELETE FROM code_dependencies WHERE project = ? AND source_file = ?', [projectName, relativePath])
245
+ this.db.run('DELETE FROM code_files WHERE project = ? AND file_path = ?', [projectName, relativePath])
246
+
247
+ // Parse and store
248
+ const parsed = await this.parser.parseFile(filePath, content)
249
+ // Override filePath with relative path for storage
250
+ const storable: ParsedFile = { ...parsed, filePath: relativePath }
251
+
252
+ this.storeFile(storable, projectName, hash)
253
+ this.storeSymbols(storable, projectName)
254
+ this.storeDependencies(storable, projectName)
255
+
256
+ return { skipped: false, symbolCount: storable.symbols.length }
257
+ }
258
+
259
+ private storeFile(parsed: ParsedFile, projectName: string, hash: string): void {
260
+ const summary = this.generateFileSummary(parsed)
261
+ this.db.run(
262
+ `INSERT OR REPLACE INTO code_files (project, file_path, language, summary, symbol_count, line_count, last_indexed, file_hash)
263
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
264
+ [
265
+ projectName,
266
+ parsed.filePath,
267
+ parsed.language,
268
+ summary,
269
+ parsed.symbols.length,
270
+ parsed.lineCount,
271
+ new Date().toISOString(),
272
+ hash,
273
+ ]
274
+ )
275
+ }
276
+
277
+ private storeSymbols(parsed: ParsedFile, projectName: string): void {
278
+ const stmt = this.db.prepare(
279
+ `INSERT INTO code_symbols (project, file_path, symbol_name, symbol_type, line_start, line_end, parent_symbol, signature, exported, updated_at)
280
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
281
+ )
282
+
283
+ for (const sym of parsed.symbols) {
284
+ stmt.run(
285
+ projectName,
286
+ parsed.filePath,
287
+ sym.name,
288
+ sym.type,
289
+ sym.lineStart,
290
+ sym.lineEnd ?? null,
291
+ sym.parentSymbol ?? null,
292
+ sym.signature ?? null,
293
+ sym.exported ? 1 : 0,
294
+ new Date().toISOString()
295
+ )
296
+ }
297
+ }
298
+
299
+ private storeDependencies(parsed: ParsedFile, projectName: string): void {
300
+ const stmt = this.db.prepare(
301
+ `INSERT INTO code_dependencies (project, source_file, target_file, import_names, import_type)
302
+ VALUES (?, ?, ?, ?, ?)`
303
+ )
304
+
305
+ for (const imp of parsed.imports) {
306
+ stmt.run(
307
+ projectName,
308
+ parsed.filePath,
309
+ imp.source,
310
+ imp.names.join(', '),
311
+ imp.importType
312
+ )
313
+ }
314
+ }
315
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * MemoryCodeLinker — Phase 29: Code-Anchored Memory
3
+ *
4
+ * Links stored observations to code files automatically using three strategies:
5
+ * 1. Explicit file paths mentioned in content
6
+ * 2. Recently edited files (from activity_log)
7
+ * 3. Symbol name matching (from code_symbols table)
8
+ *
9
+ * All linkage is fire-and-forget — failures are non-fatal.
10
+ */
11
+
12
+ import type { Database } from 'bun:sqlite'
13
+ import type { Logger } from 'pino'
14
+
15
+ /** Regex to extract file paths from memory content */
16
+ const FILE_PATH_REGEX = /(?:^|\s)((?:src|lib|app|pages|components|utils|hooks|server|api|config|middleware|routes|models|services|types|scripts|tests|test)\/[\w\-./]+\.\w+)/g
17
+
18
+ /** Regex to extract camelCase/PascalCase/snake_case identifiers >4 chars */
19
+ const SYMBOL_REGEX = /\b([a-z][a-zA-Z0-9]{3,}|[A-Z][a-zA-Z0-9]{3,}|[a-z]+(?:_[a-z]+){1,})\b/g
20
+
21
+ /** Max symbol candidates to query against code_symbols */
22
+ const MAX_SYMBOL_CANDIDATES = 20
23
+
24
+ export class MemoryCodeLinker {
25
+ constructor(
26
+ private memoryDb: Database,
27
+ private codeDb: Database | null,
28
+ private logger: Logger
29
+ ) {
30
+ this.logger = logger.child({ component: 'code-linker' })
31
+ }
32
+
33
+ /**
34
+ * Link an observation to relevant code files.
35
+ * Called after storing an observation — non-blocking, fire-and-forget.
36
+ * Returns the file paths that were linked.
37
+ */
38
+ async linkObservation(observationId: string, content: string, project: string): Promise<string[]> {
39
+ const filePaths = new Set<string>()
40
+ const symbols = new Set<string>()
41
+
42
+ // Strategy 1: Explicit file paths in content
43
+ try {
44
+ const explicitPaths = this.extractFilePaths(content)
45
+ for (const p of explicitPaths) filePaths.add(p)
46
+ } catch (err) {
47
+ this.logger.debug({ err }, 'Strategy 1 (explicit paths) failed')
48
+ }
49
+
50
+ // Strategy 2: Recently edited files
51
+ try {
52
+ const recentFiles = this.getRecentlyEditedFiles(project)
53
+ for (const p of recentFiles) filePaths.add(p)
54
+ } catch (err) {
55
+ this.logger.debug({ err }, 'Strategy 2 (recent files) failed')
56
+ }
57
+
58
+ // Strategy 3: Symbol name matching (only when codeDb available)
59
+ if (this.codeDb) {
60
+ try {
61
+ const symbolMatches = this.matchSymbols(content, project)
62
+ for (const match of symbolMatches) {
63
+ filePaths.add(match.filePath)
64
+ symbols.add(match.symbol)
65
+ }
66
+ } catch (err) {
67
+ this.logger.debug({ err }, 'Strategy 3 (symbol matching) failed')
68
+ }
69
+ }
70
+
71
+ if (filePaths.size === 0 && symbols.size === 0) {
72
+ return []
73
+ }
74
+
75
+ // Update the observation with linked paths and symbols
76
+ const pathsArray = [...filePaths]
77
+ const symbolsArray = [...symbols]
78
+
79
+ try {
80
+ this.memoryDb.prepare(
81
+ `UPDATE observations SET file_paths = ?, symbols = ?, updated_at = ? WHERE id = ?`
82
+ ).run(
83
+ JSON.stringify(pathsArray),
84
+ JSON.stringify(symbolsArray),
85
+ new Date().toISOString(),
86
+ observationId
87
+ )
88
+
89
+ this.logger.debug(
90
+ { observationId, paths: pathsArray.length, symbols: symbolsArray.length },
91
+ 'Observation linked to code'
92
+ )
93
+ } catch (err) {
94
+ this.logger.debug({ err, observationId }, 'Failed to update observation with code links')
95
+ }
96
+
97
+ return pathsArray
98
+ }
99
+
100
+ /**
101
+ * Strategy 1: Extract explicit file paths from content.
102
+ */
103
+ private extractFilePaths(content: string): string[] {
104
+ const paths: string[] = []
105
+ let match: RegExpExecArray | null
106
+
107
+ // Reset regex state
108
+ FILE_PATH_REGEX.lastIndex = 0
109
+ while ((match = FILE_PATH_REGEX.exec(content)) !== null) {
110
+ paths.push(match[1])
111
+ }
112
+
113
+ return [...new Set(paths)]
114
+ }
115
+
116
+ /**
117
+ * Strategy 2: Get recently edited files from activity_log.
118
+ * Gracefully degrades if the table doesn't exist or has a different schema.
119
+ */
120
+ private getRecentlyEditedFiles(project: string): string[] {
121
+ try {
122
+ const rows = this.memoryDb.prepare(`
123
+ SELECT DISTINCT json_extract(metadata, '$.file_path') as file_path
124
+ FROM activity_log
125
+ WHERE project = ?
126
+ AND tool_name IN ('Write', 'Edit')
127
+ AND json_extract(metadata, '$.file_path') IS NOT NULL
128
+ ORDER BY created_at DESC
129
+ LIMIT 5
130
+ `).all(project) as { file_path: string }[]
131
+
132
+ return rows.map(r => r.file_path).filter(Boolean)
133
+ } catch {
134
+ // activity_log table may not exist — graceful degradation
135
+ return []
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Strategy 3: Match potential symbol names from content against code_symbols.
141
+ */
142
+ private matchSymbols(content: string, project: string): { symbol: string; filePath: string }[] {
143
+ if (!this.codeDb) return []
144
+
145
+ // Extract candidate symbols from content
146
+ const candidates = new Set<string>()
147
+ let match: RegExpExecArray | null
148
+
149
+ SYMBOL_REGEX.lastIndex = 0
150
+ while ((match = SYMBOL_REGEX.exec(content)) !== null) {
151
+ if (candidates.size >= MAX_SYMBOL_CANDIDATES) break
152
+ candidates.add(match[1])
153
+ }
154
+
155
+ if (candidates.size === 0) return []
156
+
157
+ // Query code_symbols for matches
158
+ const results: { symbol: string; filePath: string }[] = []
159
+ const placeholders = [...candidates].map(() => '?').join(', ')
160
+
161
+ try {
162
+ const rows = this.codeDb.prepare(`
163
+ SELECT DISTINCT symbol_name, file_path
164
+ FROM code_symbols
165
+ WHERE project = ? AND symbol_name IN (${placeholders})
166
+ LIMIT 20
167
+ `).all(project, ...candidates) as { symbol_name: string; file_path: string }[]
168
+
169
+ for (const row of rows) {
170
+ results.push({ symbol: row.symbol_name, filePath: row.file_path })
171
+ }
172
+ } catch (err) {
173
+ this.logger.debug({ err }, 'Symbol matching query failed')
174
+ }
175
+
176
+ return results
177
+ }
178
+ }