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.
- package/VERSION +1 -1
- package/package.json +3 -1
- package/scripts/postinstall.mjs +80 -104
- package/src/cli/auto-setup.ts +1 -9
- package/src/cli/bin.ts +23 -2
- package/src/cli/commands/export.ts +130 -0
- package/src/cli/commands/reindex.ts +107 -0
- package/src/cli/commands/serve.ts +54 -0
- package/src/cli/commands/status.ts +158 -0
- package/src/code-intelligence/indexer.ts +315 -0
- package/src/code-intelligence/linker.ts +178 -0
- package/src/code-intelligence/parser.ts +484 -0
- package/src/code-intelligence/query.ts +291 -0
- package/src/code-intelligence/schema.ts +83 -0
- package/src/code-intelligence/types.ts +95 -0
- package/src/config/defaults.ts +3 -3
- package/src/config/loader.ts +6 -0
- package/src/config/schema.ts +28 -2
- package/src/health/index.ts +5 -2
- package/src/hooks/brain-hook.ts +4 -1
- package/src/hooks/context-hook.ts +69 -10
- package/src/hooks/installer.ts +4 -7
- package/src/intelligence/cross-project/index.ts +1 -7
- package/src/intelligence/prediction/index.ts +1 -7
- package/src/intelligence/reasoning/index.ts +1 -7
- package/src/memory/compression.ts +105 -0
- package/src/memory/fts5-search.ts +456 -0
- package/src/memory/index.ts +342 -38
- package/src/memory/migrations/add-fts5.ts +98 -0
- package/src/memory/pruning.ts +60 -0
- package/src/routing/intent-classifier.ts +58 -1
- package/src/routing/response-filter.ts +128 -0
- package/src/routing/router.ts +457 -54
- package/src/server/http-api.ts +319 -1
- package/src/server/providers/resources.ts +1 -42
- package/src/server/services.ts +113 -12
- package/src/server/web-viewer.ts +1115 -0
- package/src/setup/index.ts +12 -22
- package/src/tools/schemas.ts +1 -1
- package/src/intelligence/cross-project/affinity.ts +0 -159
- package/src/intelligence/cross-project/transfer.ts +0 -201
- package/src/intelligence/prediction/context-anticipator.ts +0 -198
- package/src/intelligence/prediction/decision-predictor.ts +0 -184
- package/src/intelligence/reasoning/counterfactual.ts +0 -248
- package/src/intelligence/reasoning/synthesizer.ts +0 -167
- 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
|
+
}
|