claude-brain 0.28.1 → 0.28.2

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.28.1
1
+ 0.28.2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-brain",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
4
4
  "description": "Local development assistant bridging Obsidian vaults with Claude Code via MCP",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -77,6 +77,7 @@ function setupHomeDirectory() {
77
77
  join(HOME, 'vault', 'Projects'),
78
78
  join(HOME, 'vault', 'Global'),
79
79
  join(HOME, 'hooks'),
80
+ join(HOME, 'models'),
80
81
  ]
81
82
 
82
83
  for (const dir of dirs) {
@@ -151,6 +152,20 @@ codeIntelligence:
151
152
  enabled: true
152
153
  autoIndexOnSessionStart: true
153
154
 
155
+ # SLM: Local model inference (replaces regex classifiers)
156
+ # Models are optional — install with: claude-brain models download
157
+ slm:
158
+ enabled: false
159
+ modelsDir: ~/.claude-brain/models
160
+ confidenceThreshold: 0.7
161
+ tasks:
162
+ intent: regex
163
+ entity: regex
164
+ query: regex
165
+ knowledge: regex
166
+ compress: api
167
+ pattern: regex
168
+
154
169
  logLevel: warn
155
170
  `
156
171
 
@@ -159,6 +174,48 @@ logLevel: warn
159
174
  return true
160
175
  }
161
176
 
177
+ // ── Step 2b: Upgrade existing config.yml (add missing sections) ──
178
+
179
+ function upgradeExistingConfig() {
180
+ const configPath = join(HOME, 'config.yml')
181
+ if (!existsSync(configPath)) {
182
+ return false
183
+ }
184
+
185
+ let content
186
+ try {
187
+ content = readFileSync(configPath, 'utf-8')
188
+ } catch {
189
+ return false
190
+ }
191
+
192
+ // Don't touch if user already has an slm: section
193
+ if (/^slm:/m.test(content)) {
194
+ log('Config already has SLM section')
195
+ return true
196
+ }
197
+
198
+ const slmSection = `
199
+ # SLM: Local model inference (replaces regex classifiers)
200
+ # Models are optional — install with: claude-brain models download
201
+ slm:
202
+ enabled: false
203
+ modelsDir: ~/.claude-brain/models
204
+ confidenceThreshold: 0.7
205
+ tasks:
206
+ intent: regex
207
+ entity: regex
208
+ query: regex
209
+ knowledge: regex
210
+ compress: api
211
+ pattern: regex
212
+ `
213
+
214
+ writeFileSync(configPath, content.trimEnd() + '\n' + slmSection, 'utf-8')
215
+ log('Upgraded config.yml with SLM section')
216
+ return true
217
+ }
218
+
162
219
  // ── Step 3: Copy hook files ──────────────────────────────
163
220
 
164
221
  /** Files to copy from package src/hooks/ to ~/.claude-brain/hooks/ */
@@ -432,6 +489,9 @@ async function main() {
432
489
  // Step 2: Create default config.yml
433
490
  try { results.config = createDefaultConfig() } catch (e) { log(`Config creation failed: ${e.message}`) }
434
491
 
492
+ // Step 2b: Upgrade existing config.yml with new sections (e.g. SLM)
493
+ try { upgradeExistingConfig() } catch (e) { log(`Config upgrade failed: ${e.message}`) }
494
+
435
495
  // Step 3: Copy hook files
436
496
  try { results.hookFiles = copyHookFiles() } catch (e) { log(`Hook file copy failed: ${e.message}`) }
437
497
 
@@ -51,6 +51,7 @@ export function ensureHomeDirectory(): void {
51
51
  paths.vault,
52
52
  paths.vaultProjects,
53
53
  paths.vaultGlobal,
54
+ paths.models,
54
55
  ]
55
56
 
56
57
  for (const dir of dirs) {
@@ -23,10 +23,20 @@ import { shouldRetrain, retrainTask, retrainAll, type RetrainConfig } from '@/tr
23
23
 
24
24
  const ALL_TASKS: ModelTask[] = ['intent', 'entity', 'query', 'knowledge', 'compress', 'pattern']
25
25
 
26
+ /** Default mode when disabling a task */
27
+ const DISABLE_MODE: Record<ModelTask, string> = {
28
+ intent: 'regex',
29
+ entity: 'regex',
30
+ query: 'regex',
31
+ knowledge: 'regex',
32
+ compress: 'api',
33
+ pattern: 'regex',
34
+ }
35
+
26
36
  export async function runModels() {
27
37
  const args = parseArgs(process.argv.slice(3), {
28
- subcommand: { type: 'positional', required: false, description: 'Subcommand: list, download, enable, disable, benchmark, stats, retrain' },
29
- taskArg: { type: 'positional', required: false, description: 'Task name (for enable/disable/benchmark/retrain)' },
38
+ subcommand: { type: 'positional', required: false, description: 'Subcommand: list, status, download, enable, disable, benchmark, stats, retrain' },
39
+ taskArg: { type: 'positional', required: false, description: 'Task name or "all" (for enable/disable/benchmark/retrain)' },
30
40
  task: { type: 'string', description: 'Target task (for download --task)' },
31
41
  source: { type: 'string', description: 'Source: local (default) or release' },
32
42
  force: { type: 'boolean', description: 'Force retrain even if checks say not needed' },
@@ -38,6 +48,8 @@ export async function runModels() {
38
48
  switch (subcommand) {
39
49
  case 'list':
40
50
  return listModels()
51
+ case 'status':
52
+ return showStatus()
41
53
  case 'download':
42
54
  return downloadModels(taskArg || 'all', (args.source as string) || 'local')
43
55
  case 'enable':
@@ -135,6 +147,113 @@ function listModels() {
135
147
  console.log()
136
148
  }
137
149
 
150
+ // ─── status ──────────────────────────────────────────────────────
151
+
152
+ async function showStatus() {
153
+ console.log()
154
+ console.log(renderLogo())
155
+ console.log()
156
+ console.log(heading('SLM Inference Status'))
157
+ console.log()
158
+
159
+ // Check ONNX runtime availability
160
+ let onnxAvailable = false
161
+ let onnxBackend = 'not installed'
162
+ try {
163
+ await import('onnxruntime-node')
164
+ onnxAvailable = true
165
+ onnxBackend = 'onnxruntime-node (native)'
166
+ } catch {
167
+ try {
168
+ await import('onnxruntime-web')
169
+ onnxAvailable = true
170
+ onnxBackend = 'onnxruntime-web (WASM)'
171
+ } catch {
172
+ // Neither available
173
+ }
174
+ }
175
+
176
+ const config = loadConfigFile()
177
+ const slmEnabled = config.slm?.enabled ?? false
178
+ const taskConfig = config.slm?.tasks ?? {}
179
+
180
+ const paths = getHomePaths()
181
+ const manifest = loadManifest()
182
+
183
+ const headerItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = [
184
+ {
185
+ label: 'ONNX Runtime',
186
+ value: onnxBackend,
187
+ status: onnxAvailable ? 'success' : 'warning',
188
+ },
189
+ {
190
+ label: 'SLM Enabled',
191
+ value: slmEnabled ? 'yes' : 'no',
192
+ status: slmEnabled ? 'success' : 'info',
193
+ },
194
+ {
195
+ label: 'Confidence Threshold',
196
+ value: `${config.slm?.confidenceThreshold ?? 0.7}`,
197
+ status: 'info',
198
+ },
199
+ {
200
+ label: 'Models Dir',
201
+ value: paths.models,
202
+ status: existsSync(paths.models) ? 'success' : 'warning',
203
+ },
204
+ ]
205
+
206
+ console.log(summaryPanel('Configuration', headerItems))
207
+ console.log()
208
+
209
+ // Per-task status
210
+ const taskItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = []
211
+
212
+ for (const task of ALL_TASKS) {
213
+ const mode = taskConfig[task] || DISABLE_MODE[task]
214
+ const entry = manifest?.models?.[task]
215
+ const fileExists = entry ? existsSync(join(paths.models, entry.file)) : false
216
+
217
+ let statusStr = `mode: ${mode}`
218
+ if (entry) {
219
+ statusStr += fileExists ? ', model: available' : ', model: MISSING'
220
+ } else {
221
+ statusStr += ', no manifest entry'
222
+ }
223
+
224
+ const isActive = (mode === 'model' || mode === 'both') && fileExists && onnxAvailable
225
+ taskItems.push({
226
+ label: task,
227
+ value: statusStr,
228
+ status: isActive ? 'success' : mode === 'model' || mode === 'both' ? 'error' : 'info',
229
+ })
230
+ }
231
+
232
+ console.log(summaryPanel('Task Routing', taskItems))
233
+ console.log()
234
+
235
+ if (!onnxAvailable) {
236
+ console.log(warningText(' ONNX Runtime is not installed. Models cannot be loaded.'))
237
+ console.log(dimText(' Install with: npm install onnxruntime-node'))
238
+ console.log()
239
+ }
240
+
241
+ if (!slmEnabled && manifest) {
242
+ console.log(dimText(' SLM is disabled. Enable with: claude-brain models enable all'))
243
+ console.log()
244
+ }
245
+ }
246
+
247
+ function loadManifest(): ModelManifest | null {
248
+ const manifestPath = join(getHomePaths().models, 'manifest.json')
249
+ if (!existsSync(manifestPath)) return null
250
+ try {
251
+ return JSON.parse(readFileSync(manifestPath, 'utf-8')) as ModelManifest
252
+ } catch {
253
+ return null
254
+ }
255
+ }
256
+
138
257
  // ─── download ─────────────────────────────────────────────────────
139
258
 
140
259
  function downloadModels(taskFilter: string, source: string) {
@@ -274,15 +393,15 @@ function downloadModels(taskFilter: string, source: string) {
274
393
  writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
275
394
 
276
395
  // Auto-enable successfully installed models in config
277
- const configPath = join(getClaudeBrainHome(), 'config.json')
278
- const config = loadConfigFile(configPath)
396
+ const config = loadConfigFile()
279
397
  if (!config.slm) config.slm = {}
280
398
  config.slm.enabled = true
281
399
  if (!config.slm.tasks) config.slm.tasks = {}
282
400
  for (const task of installedTasks) {
283
401
  config.slm.tasks[task] = 'model'
284
402
  }
285
- writeFileSync(configPath, JSON.stringify(config, null, 2))
403
+ saveConfigFile(config)
404
+ updateConfigYml(installedTasks, 'model')
286
405
 
287
406
  console.log(successText(`Installed ${installed} model${installed !== 1 ? 's' : ''} (total: ${formatBytes(totalBytes)})`))
288
407
  console.log(successText(`Auto-enabled ${installedTasks.join(', ')} in config`))
@@ -292,64 +411,96 @@ function downloadModels(taskFilter: string, source: string) {
292
411
 
293
412
  // ─── enable ───────────────────────────────────────────────────────
294
413
 
295
- function enableTask(task: string) {
296
- if (!task) {
297
- console.log(errorText('Missing task argument'))
298
- console.log(dimText(`Usage: claude-brain models enable <task>`))
299
- console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}`))
300
- return
301
- }
414
+ function enableTask(taskArg: string) {
415
+ const tasks = resolveTaskArg(taskArg, 'enable')
416
+ if (!tasks) return
302
417
 
303
- if (!ALL_TASKS.includes(task as ModelTask)) {
304
- console.log(errorText(`Invalid task: ${task}`))
305
- console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
306
- return
307
- }
418
+ console.log()
419
+ console.log(heading('Enable SLM Models'))
420
+ console.log()
308
421
 
309
- const configPath = join(getClaudeBrainHome(), 'config.json')
310
- const config = loadConfigFile(configPath)
422
+ const config = loadConfigFile()
311
423
 
312
424
  // Ensure slm section exists
313
425
  if (!config.slm) config.slm = {}
314
426
  config.slm.enabled = true
315
427
  if (!config.slm.tasks) config.slm.tasks = {}
316
428
 
317
- // compress uses 'api' baseline, others use 'regex'
318
- config.slm.tasks[task] = 'model'
429
+ for (const task of tasks) {
430
+ config.slm.tasks[task] = 'model'
431
+ }
319
432
 
320
- writeFileSync(configPath, JSON.stringify(config, null, 2))
321
- console.log(successText(`Enabled model inference for task: ${task}`))
322
- console.log(dimText(`Config updated: ${configPath}`))
433
+ saveConfigFile(config)
434
+ updateConfigYml(tasks, 'model')
435
+
436
+ for (const task of tasks) {
437
+ console.log(successText(`${task} -> model`))
438
+ }
439
+ console.log()
440
+ console.log(successText('SLM enabled'))
441
+ console.log(dimText(' Changes take effect on next server restart'))
442
+ console.log()
323
443
  }
324
444
 
325
445
  // ─── disable ──────────────────────────────────────────────────────
326
446
 
327
- function disableTask(task: string) {
328
- if (!task) {
329
- console.log(errorText('Missing task argument'))
330
- console.log(dimText(`Usage: claude-brain models disable <task>`))
331
- console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}`))
332
- return
447
+ function disableTask(taskArg: string) {
448
+ const tasks = resolveTaskArg(taskArg, 'disable')
449
+ if (!tasks) return
450
+
451
+ console.log()
452
+ console.log(heading('Disable SLM Models'))
453
+ console.log()
454
+
455
+ const config = loadConfigFile()
456
+ if (!config.slm) config.slm = {}
457
+ if (!config.slm.tasks) config.slm.tasks = {}
458
+
459
+ for (const task of tasks) {
460
+ config.slm.tasks[task] = DISABLE_MODE[task]
333
461
  }
334
462
 
335
- if (!ALL_TASKS.includes(task as ModelTask)) {
336
- console.log(errorText(`Invalid task: ${task}`))
337
- console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
338
- return
463
+ // If all tasks are now regex/api, disable SLM globally
464
+ const allDisabled = ALL_TASKS.every(t => {
465
+ const mode = config.slm.tasks[t] || DISABLE_MODE[t]
466
+ return mode === 'regex' || mode === 'api'
467
+ })
468
+ if (allDisabled) {
469
+ config.slm.enabled = false
339
470
  }
340
471
 
341
- const configPath = join(getClaudeBrainHome(), 'config.json')
342
- const config = loadConfigFile(configPath)
472
+ saveConfigFile(config)
473
+ updateConfigYml(tasks, 'disable')
343
474
 
344
- if (!config.slm) config.slm = {}
345
- if (!config.slm.tasks) config.slm.tasks = {}
475
+ for (const task of tasks) {
476
+ console.log(warningText(`${task} -> ${DISABLE_MODE[task]}`))
477
+ }
478
+ console.log()
479
+ if (allDisabled) {
480
+ console.log(warningText('All tasks disabled — SLM globally disabled'))
481
+ }
482
+ console.log(dimText(' Changes take effect on next server restart'))
483
+ console.log()
484
+ }
485
+
486
+ /** Resolve a task argument to a list of tasks, or null if invalid */
487
+ function resolveTaskArg(taskArg: string, verb: string): ModelTask[] | null {
488
+ if (!taskArg) {
489
+ console.log(errorText('Missing task argument'))
490
+ console.log(dimText(`Usage: claude-brain models ${verb} <task|all>`))
491
+ console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}, all`))
492
+ return null
493
+ }
346
494
 
347
- // Revert to baseline: compress → 'api', others → 'regex'
348
- config.slm.tasks[task] = task === 'compress' ? 'api' : 'regex'
495
+ if (taskArg === 'all') return [...ALL_TASKS]
349
496
 
350
- writeFileSync(configPath, JSON.stringify(config, null, 2))
351
- console.log(successText(`Disabled model inference for task: ${task}`))
352
- console.log(dimText(`Reverted to ${task === 'compress' ? 'api' : 'regex'} mode`))
497
+ if (!ALL_TASKS.includes(taskArg as ModelTask)) {
498
+ console.log(errorText(`Invalid task: ${taskArg}`))
499
+ console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}, all`))
500
+ return null
501
+ }
502
+
503
+ return [taskArg as ModelTask]
353
504
  }
354
505
 
355
506
  // ─── benchmark ────────────────────────────────────────────────────
@@ -535,9 +686,8 @@ async function retrainModels(taskFilter: string, force: boolean) {
535
686
  return
536
687
  }
537
688
 
538
- // Build config from schema defaults + config.json overrides
539
- const configPath = join(getClaudeBrainHome(), 'config.json')
540
- const userConfig = loadConfigFile(configPath)
689
+ // Build config from schema defaults + .claudebrainrc.json overrides
690
+ const userConfig = loadConfigFile()
541
691
  const retrainCfg = userConfig?.slm?.retrain ?? {}
542
692
 
543
693
  const config: RetrainConfig = {
@@ -632,9 +782,10 @@ function printModelsHelp() {
632
782
 
633
783
  const subcommands = [
634
784
  ['list', 'Show installed models and their status'],
785
+ ['status', 'Show inference routing and ONNX runtime status'],
635
786
  ['download', 'Download pre-trained models (--task <task>|all)'],
636
- ['enable <task>', 'Enable model inference for a task'],
637
- ['disable <task>', 'Disable model inference for a task'],
787
+ ['enable <task|all>', 'Enable model inference for task(s)'],
788
+ ['disable <task|all>', 'Disable model inference for task(s)'],
638
789
  ['benchmark <task>', 'Run accuracy benchmark on test data'],
639
790
  ['stats', 'Show training data statistics'],
640
791
  ['retrain [<task>|all]', 'Retrain models from feedback (--force)'],
@@ -653,7 +804,10 @@ function printModelsHelp() {
653
804
  console.log()
654
805
  console.log(theme.bold('Examples:'))
655
806
  console.log(` ${dimText('claude-brain models list')}`)
807
+ console.log(` ${dimText('claude-brain models status')}`)
808
+ console.log(` ${dimText('claude-brain models enable all')}`)
656
809
  console.log(` ${dimText('claude-brain models enable intent')}`)
810
+ console.log(` ${dimText('claude-brain models disable pattern')}`)
657
811
  console.log(` ${dimText('claude-brain models benchmark intent')}`)
658
812
  console.log(` ${dimText('claude-brain models stats')}`)
659
813
  console.log(` ${dimText('claude-brain models retrain intent')}`)
@@ -663,15 +817,58 @@ function printModelsHelp() {
663
817
 
664
818
  // ─── helpers ──────────────────────────────────────────────────────
665
819
 
666
- function loadConfigFile(configPath: string): Record<string, any> {
667
- if (!existsSync(configPath)) return {}
820
+ const RC_FILE = '.claudebrainrc.json'
821
+
822
+ function loadConfigFile(): Record<string, any> {
823
+ const rcPath = join(getClaudeBrainHome(), RC_FILE)
824
+ if (!existsSync(rcPath)) return {}
668
825
  try {
669
- return JSON.parse(readFileSync(configPath, 'utf-8'))
826
+ return JSON.parse(readFileSync(rcPath, 'utf-8'))
670
827
  } catch {
671
828
  return {}
672
829
  }
673
830
  }
674
831
 
832
+ function saveConfigFile(config: Record<string, any>): void {
833
+ const rcPath = join(getClaudeBrainHome(), RC_FILE)
834
+ writeFileSync(rcPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
835
+ }
836
+
837
+ /** Best-effort update of config.yml task lines for user visibility */
838
+ function updateConfigYml(tasks: ModelTask[], mode: string): void {
839
+ const ymlPath = join(getClaudeBrainHome(), 'config.yml')
840
+ if (!existsSync(ymlPath)) return
841
+
842
+ try {
843
+ let content = readFileSync(ymlPath, 'utf-8')
844
+
845
+ // Update each task line (e.g. " intent: regex" -> " intent: model")
846
+ for (const task of tasks) {
847
+ const newMode = mode === 'disable' ? DISABLE_MODE[task] : mode
848
+ const regex = new RegExp(`^(\\s*${task}:\\s*)\\S+`, 'm')
849
+ content = content.replace(regex, `$1${newMode}`)
850
+ }
851
+
852
+ // Update slm.enabled line if present
853
+ // Use a heuristic: find "enabled:" that appears after "slm:"
854
+ const slmIdx = content.indexOf('slm:')
855
+ if (slmIdx !== -1) {
856
+ const afterSlm = content.slice(slmIdx)
857
+ const enabledMatch = afterSlm.match(/^(\s+enabled:\s*)\S+/m)
858
+ if (enabledMatch) {
859
+ const config = loadConfigFile()
860
+ const slmEnabled = config.slm?.enabled ?? false
861
+ const newLine = `${enabledMatch[1]}${slmEnabled}`
862
+ content = content.slice(0, slmIdx) + afterSlm.replace(enabledMatch[0], newLine)
863
+ }
864
+ }
865
+
866
+ writeFileSync(ymlPath, content, 'utf-8')
867
+ } catch {
868
+ // Non-critical — .claudebrainrc.json is the authoritative config
869
+ }
870
+ }
871
+
675
872
  function formatBytes(bytes: number): string {
676
873
  if (bytes === 0) return '0 B'
677
874
  const units = ['B', 'KB', 'MB', 'GB']
@@ -179,6 +179,20 @@ export class ModelManager {
179
179
  }
180
180
  }
181
181
 
182
+ /**
183
+ * Public accessor for the models directory path
184
+ */
185
+ getModelsDir(): string {
186
+ return this.modelsDir
187
+ }
188
+
189
+ /**
190
+ * Public check for ONNX Runtime availability
191
+ */
192
+ async isOnnxAvailable(): Promise<boolean> {
193
+ return this.checkOnnxRuntime()
194
+ }
195
+
182
196
  /**
183
197
  * Get status of all models (for CLI and health checks)
184
198
  */
@@ -394,6 +394,41 @@ export async function initializeServices(config: Config, logger: Logger): Promis
394
394
  serviceLogger.info('SLM inference wired into PatternRecognizer')
395
395
  }
396
396
 
397
+ // SLM startup detection: log model availability and configuration state
398
+ if (modelManager) {
399
+ const modelStatus = modelManager.getStatus()
400
+ const modelsDir = modelManager.getModelsDir()
401
+ const availableCount = Object.values(modelStatus).filter(s => s.available).length
402
+ const slmEnabled = config.slm?.enabled ?? false
403
+
404
+ if (availableCount > 0 && !slmEnabled) {
405
+ serviceLogger.info(
406
+ { count: availableCount, modelsDir },
407
+ `Found ${availableCount} ONNX models in ${modelsDir}. SLM is disabled. Run "claude-brain models enable all" to activate local inference.`
408
+ )
409
+ } else if (availableCount > 0 && slmEnabled) {
410
+ const tasks = config.slm?.tasks ?? {}
411
+ const enabledTasks = Object.entries(tasks)
412
+ .filter(([_, mode]) => mode === 'model' || mode === 'both')
413
+ .map(([task]) => task)
414
+ serviceLogger.info(
415
+ { count: availableCount, enabledTasks },
416
+ `SLM active: ${enabledTasks.length} tasks using local model inference`
417
+ )
418
+
419
+ // Check onnxruntime availability when SLM is enabled
420
+ const onnxAvailable = await modelManager.isOnnxAvailable()
421
+ if (!onnxAvailable) {
422
+ serviceLogger.warn('SLM enabled but onnxruntime-node not installed. Run: bun add onnxruntime-node')
423
+ }
424
+ } else if (availableCount === 0 && slmEnabled) {
425
+ serviceLogger.warn(
426
+ { modelsDir },
427
+ `SLM enabled but no models found in ${modelsDir}. Run "claude-brain models download" to install models.`
428
+ )
429
+ }
430
+ }
431
+
397
432
  // Store services
398
433
  services = {
399
434
  memory,