claude-brain 0.30.2 → 0.30.3
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/README.md +241 -191
- package/VERSION +1 -1
- package/assets/CLAUDE-unified.md +11 -11
- package/assets/CLAUDE.md +29 -29
- package/package.json +7 -3
- package/packs/backend/node.json +173 -173
- package/packs/core/javascript.json +176 -176
- package/packs/core/typescript.json +222 -222
- package/packs/frontend/react.json +254 -254
- package/packs/meta/testing.json +172 -172
- package/scripts/postinstall.mjs +531 -531
- package/src/automation/decision-detector.ts +452 -452
- package/src/automation/phase12-manager.ts +456 -456
- package/src/automation/proactive-recall.ts +373 -373
- package/src/automation/project-detector.ts +310 -310
- package/src/automation/repo-scanner.ts +210 -205
- package/src/cli/auto-setup.ts +75 -75
- package/src/cli/auto-start.ts +266 -266
- package/src/cli/bin.ts +264 -264
- package/src/cli/commands/autostart.ts +90 -90
- package/src/cli/commands/chroma.ts +578 -577
- package/src/cli/commands/export-training.ts +70 -70
- package/src/cli/commands/export.ts +130 -130
- package/src/cli/commands/git-hook.ts +183 -183
- package/src/cli/commands/hooks.ts +217 -217
- package/src/cli/commands/init.ts +123 -123
- package/src/cli/commands/install-mcp.ts +122 -111
- package/src/cli/commands/models.ts +979 -979
- package/src/cli/commands/pack.ts +200 -200
- package/src/cli/commands/refresh.ts +344 -339
- package/src/cli/commands/reindex.ts +120 -120
- package/src/cli/commands/serve.ts +466 -463
- package/src/cli/commands/start.ts +44 -44
- package/src/cli/commands/status.ts +220 -203
- package/src/cli/commands/uninstall-mcp.ts +45 -41
- package/src/cli/commands/update.ts +130 -124
- package/src/cli/migrate-chroma.ts +106 -106
- package/src/cli/ui/animations.ts +80 -80
- package/src/cli/ui/components.ts +82 -82
- package/src/cli/ui/index.ts +4 -4
- package/src/cli/ui/logo.ts +36 -36
- package/src/cli/ui/theme.ts +55 -55
- package/src/code-intelligence/indexer.ts +352 -352
- package/src/code-intelligence/linker.ts +178 -178
- package/src/code-intelligence/parser.ts +484 -484
- package/src/code-intelligence/query.ts +291 -291
- package/src/code-intelligence/schema.ts +83 -83
- package/src/code-intelligence/types.ts +95 -95
- package/src/config/defaults.ts +52 -52
- package/src/config/home.ts +56 -56
- package/src/config/index.ts +5 -5
- package/src/config/loader.ts +192 -192
- package/src/config/schema.ts +446 -415
- package/src/config/validator.ts +182 -182
- package/src/context/assembler.ts +407 -400
- package/src/context/index.ts +79 -79
- package/src/context/progress-tracker.ts +174 -174
- package/src/context/standards-manager.ts +287 -287
- package/src/context/validator.ts +58 -58
- package/src/diagnostics/index.ts +122 -121
- package/src/health/index.ts +233 -232
- package/src/hooks/brain-hook.ts +134 -131
- package/src/hooks/capture.ts +168 -168
- package/src/hooks/claude-code-mastery.md +112 -112
- package/src/hooks/context-hook.ts +260 -245
- package/src/hooks/deduplicator.ts +72 -72
- package/src/hooks/git-capture.ts +109 -109
- package/src/hooks/git-hook-installer.ts +211 -207
- package/src/hooks/index.ts +20 -20
- package/src/hooks/installer.ts +306 -288
- package/src/hooks/interceptor-hook.ts +204 -201
- package/src/hooks/passive-classifier.ts +397 -397
- package/src/hooks/queue.ts +160 -129
- package/src/hooks/session-tracker.ts +312 -312
- package/src/hooks/types.ts +52 -52
- package/src/index.ts +7 -7
- package/src/intelligence/cross-project/generalizer.ts +283 -283
- package/src/intelligence/cross-project/index.ts +7 -7
- package/src/intelligence/hf-downloader.ts +222 -222
- package/src/intelligence/hf-manifest.json +78 -78
- package/src/intelligence/index.ts +24 -24
- package/src/intelligence/inference-router.ts +762 -762
- package/src/intelligence/model-manager.ts +263 -245
- package/src/intelligence/optimization/index.ts +10 -10
- package/src/intelligence/optimization/precompute.ts +202 -202
- package/src/intelligence/optimization/semantic-cache.ts +213 -207
- package/src/intelligence/prediction/index.ts +7 -7
- package/src/intelligence/prediction/recommender.ts +276 -268
- package/src/intelligence/reasoning/chain-retrieval.ts +243 -247
- package/src/intelligence/reasoning/index.ts +7 -7
- package/src/intelligence/temporal/evolution.ts +193 -197
- package/src/intelligence/temporal/index.ts +16 -16
- package/src/intelligence/temporal/query-processor.ts +190 -190
- package/src/intelligence/temporal/timeline.ts +272 -259
- package/src/intelligence/temporal/trends.ts +263 -263
- package/src/intelligence/tokenizer.ts +118 -118
- package/src/knowledge/entity-extractor.ts +447 -443
- package/src/knowledge/graph/builder.ts +185 -185
- package/src/knowledge/graph/linker.ts +201 -201
- package/src/knowledge/graph/memory-graph.ts +359 -359
- package/src/knowledge/graph/schema.ts +99 -99
- package/src/knowledge/graph/search.ts +166 -166
- package/src/knowledge/relationship-extractor.ts +108 -108
- package/src/memory/chroma/client.ts +211 -192
- package/src/memory/chroma/collection-manager.ts +92 -92
- package/src/memory/chroma/config.ts +57 -57
- package/src/memory/chroma/embeddings.ts +177 -175
- package/src/memory/chroma/index.ts +82 -82
- package/src/memory/chroma/migration.ts +270 -270
- package/src/memory/chroma/schemas.ts +69 -69
- package/src/memory/chroma/search.ts +319 -315
- package/src/memory/chroma/store.ts +755 -747
- package/src/memory/compression.ts +121 -121
- package/src/memory/consolidation/archiver.ts +162 -165
- package/src/memory/consolidation/merger.ts +182 -186
- package/src/memory/consolidation/scorer.ts +136 -136
- package/src/memory/database.ts +9 -0
- package/src/memory/dual-write.ts +145 -0
- package/src/memory/embeddings.ts +226 -226
- package/src/memory/episodic/detector.ts +108 -108
- package/src/memory/episodic/manager.ts +347 -351
- package/src/memory/episodic/summarizer.ts +179 -179
- package/src/memory/episodic/types.ts +52 -52
- package/src/memory/fts5-search.ts +692 -633
- package/src/memory/index.ts +943 -1060
- package/src/memory/migrations/add-fts5.ts +118 -108
- package/src/memory/patterns.ts +438 -438
- package/src/memory/pruning.ts +60 -60
- package/src/memory/schema.ts +88 -88
- package/src/memory/store.ts +911 -787
- package/src/orchestrator/handlers/decision-handler.ts +204 -204
- package/src/packs/index.ts +9 -9
- package/src/packs/loader.ts +134 -134
- package/src/packs/manager.ts +204 -204
- package/src/packs/ranker.ts +78 -78
- package/src/packs/types.ts +81 -81
- package/src/phase12/index.ts +5 -5
- package/src/retrieval/bm25/index.ts +300 -297
- package/src/retrieval/bm25/tokenizer.ts +184 -184
- package/src/retrieval/feedback/adaptive.ts +221 -221
- package/src/retrieval/feedback/index.ts +16 -16
- package/src/retrieval/feedback/metrics.ts +221 -221
- package/src/retrieval/feedback/store.ts +283 -283
- package/src/retrieval/fusion/index.ts +194 -194
- package/src/retrieval/fusion/rrf.ts +165 -165
- package/src/retrieval/index.ts +12 -12
- package/src/retrieval/pipeline.ts +375 -375
- package/src/retrieval/query/expander.ts +203 -203
- package/src/retrieval/query/index.ts +27 -27
- package/src/retrieval/query/intent-classifier.ts +252 -252
- package/src/retrieval/query/temporal-parser.ts +295 -295
- package/src/retrieval/reranker/index.ts +189 -188
- package/src/retrieval/reranker/model.ts +99 -95
- package/src/retrieval/service.ts +125 -125
- package/src/retrieval/types.ts +162 -162
- package/src/routing/entity-extractor.ts +454 -454
- package/src/routing/handlers/exploration-handler.ts +369 -0
- package/src/routing/handlers/index.ts +19 -0
- package/src/routing/handlers/memory-handler.ts +273 -0
- package/src/routing/handlers/mutation-handler.ts +241 -0
- package/src/routing/handlers/recall-handler.ts +642 -0
- package/src/routing/handlers/shared.ts +515 -0
- package/src/routing/handlers/types.ts +48 -0
- package/src/routing/intent-classifier.ts +552 -552
- package/src/routing/response-filter.ts +399 -391
- package/src/routing/router.ts +245 -2193
- package/src/routing/search-engine.ts +521 -514
- package/src/routing/types.ts +104 -94
- package/src/scripts/health-check.ts +118 -118
- package/src/scripts/setup.ts +122 -122
- package/src/server/auto-updater.ts +283 -276
- package/src/server/handlers/call-tool.ts +159 -159
- package/src/server/handlers/list-tools.ts +35 -35
- package/src/server/handlers/tools/auto-remember.ts +165 -165
- package/src/server/handlers/tools/brain.ts +86 -86
- package/src/server/handlers/tools/create-project.ts +135 -135
- package/src/server/handlers/tools/get-code-standards.ts +123 -123
- package/src/server/handlers/tools/get-corrections.ts +152 -152
- package/src/server/handlers/tools/get-patterns.ts +156 -156
- package/src/server/handlers/tools/get-project-context.ts +75 -75
- package/src/server/handlers/tools/index.ts +30 -30
- package/src/server/handlers/tools/init-project.ts +756 -756
- package/src/server/handlers/tools/list-projects.ts +126 -126
- package/src/server/handlers/tools/recall-similar.ts +87 -87
- package/src/server/handlers/tools/recognize-pattern.ts +132 -132
- package/src/server/handlers/tools/record-correction.ts +131 -131
- package/src/server/handlers/tools/remember-decision.ts +168 -168
- package/src/server/handlers/tools/schemas.ts +179 -179
- package/src/server/handlers/tools/search-code.ts +122 -122
- package/src/server/handlers/tools/smart-context.ts +146 -146
- package/src/server/handlers/tools/update-progress.ts +131 -131
- package/src/server/http-api.ts +215 -1229
- package/src/server/mcp-proxy.ts +85 -84
- package/src/server/mcp-server.ts +285 -284
- package/src/server/middleware/auth.ts +39 -0
- package/src/server/middleware/error-handler.ts +37 -0
- package/src/server/middleware/rate-limit.ts +53 -0
- package/src/server/middleware/validate.ts +42 -0
- package/src/server/pid-manager.ts +137 -136
- package/src/server/providers/resources.ts +581 -581
- package/src/server/routes/code.ts +228 -0
- package/src/server/routes/context.ts +26 -0
- package/src/server/routes/health.ts +19 -0
- package/src/server/routes/helpers.ts +100 -0
- package/src/server/routes/hooks.ts +197 -0
- package/src/server/routes/mcp.ts +47 -0
- package/src/server/routes/memory.ts +397 -0
- package/src/server/routes/models.ts +96 -0
- package/src/server/routes/projects.ts +89 -0
- package/src/server/routes/types.ts +21 -0
- package/src/server/schemas/api-schemas.ts +202 -0
- package/src/server/services.ts +720 -720
- package/src/server/utils/memory-indicator.ts +84 -84
- package/src/server/utils/response-formatter.ts +129 -129
- package/src/server/web-viewer.ts +1145 -1115
- package/src/setup/index.ts +38 -38
- package/src/tools/registry.ts +115 -115
- package/src/tools/schemas.ts +666 -666
- package/src/tools/types.ts +412 -412
- package/src/training/data-store.ts +320 -298
- package/src/training/retrain-pipeline.ts +399 -394
- package/src/utils/error-handler.ts +136 -136
- package/src/utils/index.ts +58 -58
- package/src/utils/kill-port.ts +55 -53
- package/src/utils/phase12-helper.ts +56 -56
- package/src/utils/safe-path.ts +43 -0
- package/src/utils/timing.ts +47 -47
- package/src/utils/transaction.ts +63 -63
- package/src/vault/index.ts +4 -3
- package/src/vault/paths.ts +106 -106
- package/src/vault/query.ts +4 -1
- package/src/vault/reader.ts +44 -1
- package/src/vault/watcher.ts +24 -1
- package/src/vault/writer.ts +487 -413
- package/skills/persistent-memory/SKILL.md +0 -148
- package/skills/persistent-memory/references/tool-reference.md +0 -90
|
@@ -1,979 +1,979 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Models Command — SLM Upgrade Phase 5
|
|
3
|
-
* Manage SLM models: list, download, enable, disable, benchmark, stats.
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* claude-brain models list
|
|
7
|
-
* claude-brain models download [--task <task>|all]
|
|
8
|
-
* claude-brain models enable <task>
|
|
9
|
-
* claude-brain models disable <task>
|
|
10
|
-
* claude-brain models benchmark <task>
|
|
11
|
-
* claude-brain models stats
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync, copyFileSync } from 'node:fs'
|
|
15
|
-
import { join } from 'node:path'
|
|
16
|
-
import { homedir } from 'node:os'
|
|
17
|
-
import { parseArgs } from 'citty'
|
|
18
|
-
import { renderLogo, theme, heading, dimText, successText, warningText, errorText, box, summaryPanel } from '@/cli/ui/index.js'
|
|
19
|
-
import { progressBar } from '@/cli/ui/components.js'
|
|
20
|
-
import { getHomePaths, getClaudeBrainHome } from '@/config/home'
|
|
21
|
-
import { getTrainingStats, type TrainingTask } from '@/training/data-store'
|
|
22
|
-
import type { ModelManifest, ModelManifestEntry, ModelTask } from '@/intelligence/model-manager'
|
|
23
|
-
import { shouldRetrain, retrainTask, retrainAll, type RetrainConfig } from '@/training/retrain-pipeline'
|
|
24
|
-
import { downloadFromHuggingFace, type HfManifest } from '@/intelligence/hf-downloader'
|
|
25
|
-
import hfManifestData from '@/intelligence/hf-manifest.json'
|
|
26
|
-
|
|
27
|
-
const ALL_TASKS: ModelTask[] = ['intent', 'entity', 'query', 'knowledge', 'compress', 'pattern']
|
|
28
|
-
|
|
29
|
-
/** Default mode when disabling a task */
|
|
30
|
-
const DISABLE_MODE: Record<ModelTask, string> = {
|
|
31
|
-
intent: 'regex',
|
|
32
|
-
entity: 'regex',
|
|
33
|
-
query: 'regex',
|
|
34
|
-
knowledge: 'regex',
|
|
35
|
-
compress: 'api',
|
|
36
|
-
pattern: 'regex',
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export async function runModels() {
|
|
40
|
-
const args = parseArgs(process.argv.slice(3), {
|
|
41
|
-
subcommand: { type: 'positional', required: false, description: 'Subcommand: list, status, download, enable, disable, benchmark, stats, retrain' },
|
|
42
|
-
taskArg: { type: 'positional', required: false, description: 'Task name or "all" (for enable/disable/benchmark/retrain)' },
|
|
43
|
-
task: { type: 'string', description: 'Target task (for download --task)' },
|
|
44
|
-
source: { type: 'string', description: 'Source: local (default) or hf (Hugging Face Hub)' },
|
|
45
|
-
force: { type: 'boolean', description: 'Force retrain even if checks say not needed' },
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const subcommand = args.subcommand || ''
|
|
49
|
-
const taskArg = (args.task || args.taskArg || '') as string
|
|
50
|
-
|
|
51
|
-
switch (subcommand) {
|
|
52
|
-
case 'list':
|
|
53
|
-
return listModels()
|
|
54
|
-
case 'status':
|
|
55
|
-
return showStatus()
|
|
56
|
-
case 'download':
|
|
57
|
-
return downloadModels(taskArg || 'all', (args.source as string) || 'local')
|
|
58
|
-
case 'enable':
|
|
59
|
-
return enableTask(taskArg)
|
|
60
|
-
case 'disable':
|
|
61
|
-
return disableTask(taskArg)
|
|
62
|
-
case 'benchmark':
|
|
63
|
-
return benchmarkTask(taskArg)
|
|
64
|
-
case 'stats':
|
|
65
|
-
return showStats()
|
|
66
|
-
case 'retrain':
|
|
67
|
-
return retrainModels(taskArg || 'all', !!args.force)
|
|
68
|
-
default:
|
|
69
|
-
return printModelsHelp()
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// ─── list ─────────────────────────────────────────────────────────
|
|
74
|
-
|
|
75
|
-
function listModels() {
|
|
76
|
-
console.log()
|
|
77
|
-
console.log(renderLogo())
|
|
78
|
-
console.log()
|
|
79
|
-
console.log(heading('SLM Models'))
|
|
80
|
-
console.log()
|
|
81
|
-
|
|
82
|
-
const paths = getHomePaths()
|
|
83
|
-
const manifestPath = join(paths.models, 'manifest.json')
|
|
84
|
-
|
|
85
|
-
const items: Array<{ label: string; value: string; status?: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
86
|
-
|
|
87
|
-
if (!existsSync(manifestPath)) {
|
|
88
|
-
items.push({ label: 'Manifest', value: 'Not found — run "models download" first', status: 'warning' })
|
|
89
|
-
console.log(summaryPanel('Models', items))
|
|
90
|
-
console.log()
|
|
91
|
-
|
|
92
|
-
// Still show task status even without manifest
|
|
93
|
-
for (const task of ALL_TASKS) {
|
|
94
|
-
console.log(` ${theme.primary(task.padEnd(12))} ${warningText('not installed')}`)
|
|
95
|
-
}
|
|
96
|
-
console.log()
|
|
97
|
-
return
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
let manifest: ModelManifest
|
|
101
|
-
try {
|
|
102
|
-
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
103
|
-
} catch {
|
|
104
|
-
console.log(errorText('Failed to parse manifest.json'))
|
|
105
|
-
return
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
let totalSize = 0
|
|
109
|
-
const rows: string[] = []
|
|
110
|
-
|
|
111
|
-
for (const task of ALL_TASKS) {
|
|
112
|
-
const entry = manifest.models?.[task]
|
|
113
|
-
if (!entry) {
|
|
114
|
-
rows.push(` ${theme.primary(task.padEnd(12))} ${warningText('not installed')}`)
|
|
115
|
-
continue
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const modelPath = join(paths.models, entry.file)
|
|
119
|
-
const installed = existsSync(modelPath)
|
|
120
|
-
|
|
121
|
-
let sizeStr = '—'
|
|
122
|
-
if (installed) {
|
|
123
|
-
try {
|
|
124
|
-
const size = statSync(modelPath).size
|
|
125
|
-
totalSize += size
|
|
126
|
-
sizeStr = formatBytes(size)
|
|
127
|
-
} catch {
|
|
128
|
-
sizeStr = '?'
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const status = installed ? successText('installed') : warningText('missing')
|
|
133
|
-
const version = entry.version ? dimText(`v${entry.version}`) : ''
|
|
134
|
-
const accuracy = entry.accuracy != null ? dimText(`${(entry.accuracy * 100).toFixed(1)}%`) : ''
|
|
135
|
-
|
|
136
|
-
rows.push(
|
|
137
|
-
` ${theme.primary(task.padEnd(12))} ${status.padEnd(24)} ${version.padEnd(14)} ${accuracy.padEnd(10)} ${dimText(sizeStr)}`
|
|
138
|
-
)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Header
|
|
142
|
-
console.log(
|
|
143
|
-
` ${dimText('Task'.padEnd(12))} ${dimText('Status'.padEnd(14))} ${dimText('Version'.padEnd(14))} ${dimText('Accuracy'.padEnd(10))} ${dimText('Size')}`
|
|
144
|
-
)
|
|
145
|
-
for (const row of rows) {
|
|
146
|
-
console.log(row)
|
|
147
|
-
}
|
|
148
|
-
console.log()
|
|
149
|
-
console.log(` ${dimText('Total size:')} ${dimText(formatBytes(totalSize))}`)
|
|
150
|
-
console.log()
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ─── status ──────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
async function showStatus() {
|
|
156
|
-
console.log()
|
|
157
|
-
console.log(renderLogo())
|
|
158
|
-
console.log()
|
|
159
|
-
console.log(heading('SLM Inference Status'))
|
|
160
|
-
console.log()
|
|
161
|
-
|
|
162
|
-
// Check ONNX runtime availability
|
|
163
|
-
let onnxAvailable = false
|
|
164
|
-
let onnxBackend = 'not installed'
|
|
165
|
-
try {
|
|
166
|
-
await import('onnxruntime-node')
|
|
167
|
-
onnxAvailable = true
|
|
168
|
-
onnxBackend = 'onnxruntime-node (native)'
|
|
169
|
-
} catch {
|
|
170
|
-
try {
|
|
171
|
-
await import('onnxruntime-web')
|
|
172
|
-
onnxAvailable = true
|
|
173
|
-
onnxBackend = 'onnxruntime-web (WASM)'
|
|
174
|
-
} catch {
|
|
175
|
-
// Neither available
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const config = loadConfigFile()
|
|
180
|
-
const slmEnabled = config.slm?.enabled ?? false
|
|
181
|
-
const taskConfig = config.slm?.tasks ?? {}
|
|
182
|
-
|
|
183
|
-
const paths = getHomePaths()
|
|
184
|
-
const manifest = loadManifest()
|
|
185
|
-
|
|
186
|
-
const headerItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = [
|
|
187
|
-
{
|
|
188
|
-
label: 'ONNX Runtime',
|
|
189
|
-
value: onnxBackend,
|
|
190
|
-
status: onnxAvailable ? 'success' : 'warning',
|
|
191
|
-
},
|
|
192
|
-
{
|
|
193
|
-
label: 'SLM Enabled',
|
|
194
|
-
value: slmEnabled ? 'yes' : 'no',
|
|
195
|
-
status: slmEnabled ? 'success' : 'info',
|
|
196
|
-
},
|
|
197
|
-
{
|
|
198
|
-
label: 'Confidence Threshold',
|
|
199
|
-
value: `${config.slm?.confidenceThreshold ?? 0.7}`,
|
|
200
|
-
status: 'info',
|
|
201
|
-
},
|
|
202
|
-
{
|
|
203
|
-
label: 'Models Dir',
|
|
204
|
-
value: paths.models,
|
|
205
|
-
status: existsSync(paths.models) ? 'success' : 'warning',
|
|
206
|
-
},
|
|
207
|
-
]
|
|
208
|
-
|
|
209
|
-
console.log(summaryPanel('Configuration', headerItems))
|
|
210
|
-
console.log()
|
|
211
|
-
|
|
212
|
-
// Per-task status
|
|
213
|
-
const taskItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
214
|
-
|
|
215
|
-
for (const task of ALL_TASKS) {
|
|
216
|
-
const mode = taskConfig[task] || DISABLE_MODE[task]
|
|
217
|
-
const entry = manifest?.models?.[task]
|
|
218
|
-
const fileExists = entry ? existsSync(join(paths.models, entry.file)) : false
|
|
219
|
-
|
|
220
|
-
let statusStr = `mode: ${mode}`
|
|
221
|
-
if (entry) {
|
|
222
|
-
statusStr += fileExists ? ', model: available' : ', model: MISSING'
|
|
223
|
-
} else {
|
|
224
|
-
statusStr += ', no manifest entry'
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const isActive = (mode === 'model' || mode === 'both') && fileExists && onnxAvailable
|
|
228
|
-
taskItems.push({
|
|
229
|
-
label: task,
|
|
230
|
-
value: statusStr,
|
|
231
|
-
status: isActive ? 'success' : mode === 'model' || mode === 'both' ? 'error' : 'info',
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
console.log(summaryPanel('Task Routing', taskItems))
|
|
236
|
-
console.log()
|
|
237
|
-
|
|
238
|
-
if (!onnxAvailable) {
|
|
239
|
-
console.log(warningText(' ONNX Runtime is not installed. Models cannot be loaded.'))
|
|
240
|
-
console.log(dimText(' Install with: npm install onnxruntime-node'))
|
|
241
|
-
console.log()
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!slmEnabled && manifest) {
|
|
245
|
-
console.log(dimText(' SLM is disabled. Enable with: claude-brain models enable all'))
|
|
246
|
-
console.log()
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function loadManifest(): ModelManifest | null {
|
|
251
|
-
const manifestPath = join(getHomePaths().models, 'manifest.json')
|
|
252
|
-
if (!existsSync(manifestPath)) return null
|
|
253
|
-
try {
|
|
254
|
-
return JSON.parse(readFileSync(manifestPath, 'utf-8')) as ModelManifest
|
|
255
|
-
} catch {
|
|
256
|
-
return null
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// ─── download ─────────────────────────────────────────────────────
|
|
261
|
-
|
|
262
|
-
async function downloadModels(taskFilter: string, source: string) {
|
|
263
|
-
console.log()
|
|
264
|
-
console.log(renderLogo())
|
|
265
|
-
console.log()
|
|
266
|
-
console.log(heading('Download Models'))
|
|
267
|
-
console.log()
|
|
268
|
-
|
|
269
|
-
const paths = getHomePaths()
|
|
270
|
-
|
|
271
|
-
// Validate task filter
|
|
272
|
-
if (taskFilter !== 'all' && !ALL_TASKS.includes(taskFilter as ModelTask)) {
|
|
273
|
-
console.log(errorText(`Invalid task: ${taskFilter}`))
|
|
274
|
-
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
|
|
275
|
-
return
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
const tasks = taskFilter === 'all' ? ALL_TASKS : [taskFilter as ModelTask]
|
|
279
|
-
|
|
280
|
-
// Ensure models directory exists
|
|
281
|
-
if (!existsSync(paths.models)) {
|
|
282
|
-
mkdirSync(paths.models, { recursive: true })
|
|
283
|
-
console.log(successText(`Created models directory: ${paths.models}`))
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Hugging Face Hub source
|
|
287
|
-
if (source === 'hf' || source === 'release') {
|
|
288
|
-
return downloadFromHF(tasks, paths.models)
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Local source — copy from ~/slm-training/models/
|
|
292
|
-
const sourceDir = join(homedir(), 'slm-training', 'models')
|
|
293
|
-
|
|
294
|
-
console.log(` ${dimText('Source:')} ${sourceDir}`)
|
|
295
|
-
console.log(` ${dimText('Target:')} ${paths.models}`)
|
|
296
|
-
console.log()
|
|
297
|
-
|
|
298
|
-
if (!existsSync(sourceDir)) {
|
|
299
|
-
console.log(errorText(`Source directory not found: ${sourceDir}`))
|
|
300
|
-
console.log(dimText('Train models first, then place .onnx and .json files in the source directory.'))
|
|
301
|
-
return
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
let installed = 0
|
|
305
|
-
let totalBytes = 0
|
|
306
|
-
const installedTasks: ModelTask[] = []
|
|
307
|
-
const manifestModels: Partial<Record<ModelTask, ModelManifestEntry>> = {}
|
|
308
|
-
|
|
309
|
-
// Load existing manifest to preserve entries for tasks we're not updating
|
|
310
|
-
const manifestPath = join(paths.models, 'manifest.json')
|
|
311
|
-
if (existsSync(manifestPath)) {
|
|
312
|
-
try {
|
|
313
|
-
const existing: ModelManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
314
|
-
if (existing.models) {
|
|
315
|
-
Object.assign(manifestModels, existing.models)
|
|
316
|
-
}
|
|
317
|
-
} catch {
|
|
318
|
-
// Ignore corrupt manifest, we'll overwrite it
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
for (const task of tasks) {
|
|
323
|
-
const onnxFile = `${task}.onnx`
|
|
324
|
-
const metaFile = `${task}.json`
|
|
325
|
-
const srcOnnx = join(sourceDir, onnxFile)
|
|
326
|
-
const srcMeta = join(sourceDir, metaFile)
|
|
327
|
-
|
|
328
|
-
if (!existsSync(srcOnnx)) {
|
|
329
|
-
console.log(` ${warningText(`${onnxFile} not found in source — skipping`)}`)
|
|
330
|
-
continue
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Copy ONNX model
|
|
334
|
-
const dstOnnx = join(paths.models, onnxFile)
|
|
335
|
-
copyFileSync(srcOnnx, dstOnnx)
|
|
336
|
-
|
|
337
|
-
// Verify copied file is valid (non-empty and size matches source)
|
|
338
|
-
const srcSize = statSync(srcOnnx).size
|
|
339
|
-
const dstSize = statSync(dstOnnx).size
|
|
340
|
-
if (dstSize === 0 || dstSize !== srcSize) {
|
|
341
|
-
console.log(` ${errorText(`${onnxFile} copy verification failed (src: ${formatBytes(srcSize)}, dst: ${formatBytes(dstSize)}) — skipping`)}`)
|
|
342
|
-
continue
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
totalBytes += dstSize
|
|
346
|
-
console.log(` Copying ${onnxFile}... ${successText('done')} ${dimText(`(${formatBytes(dstSize)})`)}`)
|
|
347
|
-
|
|
348
|
-
// Copy metadata if present
|
|
349
|
-
let meta: Partial<ModelManifestEntry> = {}
|
|
350
|
-
if (existsSync(srcMeta)) {
|
|
351
|
-
const dstMeta = join(paths.models, metaFile)
|
|
352
|
-
copyFileSync(srcMeta, dstMeta)
|
|
353
|
-
try {
|
|
354
|
-
meta = JSON.parse(readFileSync(dstMeta, 'utf-8'))
|
|
355
|
-
} catch {
|
|
356
|
-
console.log(` ${warningText(`Failed to parse ${metaFile} — using defaults`)}`)
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Build manifest entry for this task
|
|
361
|
-
// Map training metadata fields → manifest fields:
|
|
362
|
-
// val_acc → accuracy, block_size → maxSeqLen, model_name → params
|
|
363
|
-
const metaAny = meta as Record<string, any>
|
|
364
|
-
manifestModels[task] = {
|
|
365
|
-
version: meta.version ?? '0.1.0',
|
|
366
|
-
file: onnxFile,
|
|
367
|
-
sha256: meta.sha256,
|
|
368
|
-
params: meta.params ?? metaAny.model_name,
|
|
369
|
-
accuracy: meta.accuracy ?? metaAny.val_acc,
|
|
370
|
-
labels: meta.labels,
|
|
371
|
-
maxSeqLen: meta.maxSeqLen ?? metaAny.block_size,
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
installedTasks.push(task)
|
|
375
|
-
installed++
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
console.log()
|
|
379
|
-
|
|
380
|
-
if (installed === 0) {
|
|
381
|
-
console.log(warningText('No models were installed.'))
|
|
382
|
-
console.log()
|
|
383
|
-
return
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Write manifest
|
|
387
|
-
const manifest: ModelManifest = { models: manifestModels }
|
|
388
|
-
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
389
|
-
|
|
390
|
-
// Auto-enable successfully installed models in config
|
|
391
|
-
const config = loadConfigFile()
|
|
392
|
-
if (!config.slm) config.slm = {}
|
|
393
|
-
config.slm.enabled = true
|
|
394
|
-
if (!config.slm.tasks) config.slm.tasks = {}
|
|
395
|
-
for (const task of installedTasks) {
|
|
396
|
-
config.slm.tasks[task] = 'model'
|
|
397
|
-
}
|
|
398
|
-
saveConfigFile(config)
|
|
399
|
-
updateConfigYml(installedTasks, 'model')
|
|
400
|
-
|
|
401
|
-
console.log(successText(`Installed ${installed} model${installed !== 1 ? 's' : ''} (total: ${formatBytes(totalBytes)})`))
|
|
402
|
-
console.log(successText(`Auto-enabled ${installedTasks.join(', ')} in config`))
|
|
403
|
-
console.log(dimText(`Manifest written to ${manifestPath}`))
|
|
404
|
-
console.log()
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// ─── download from HF ─────────────────────────────────────────────
|
|
408
|
-
|
|
409
|
-
const isTTY = process.stdout.isTTY === true
|
|
410
|
-
|
|
411
|
-
async function downloadFromHF(tasks: ModelTask[], modelsDir: string) {
|
|
412
|
-
const manifest = hfManifestData as HfManifest
|
|
413
|
-
|
|
414
|
-
// Compute total download size
|
|
415
|
-
let totalSize = 0
|
|
416
|
-
for (const task of tasks) {
|
|
417
|
-
const entry = manifest.models[task]
|
|
418
|
-
if (entry) totalSize += entry.size
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
console.log(` ${dimText('Source:')} Hugging Face Hub (${manifest.hfRepo})`)
|
|
422
|
-
console.log(` ${dimText('Target:')} ${modelsDir}`)
|
|
423
|
-
console.log(` ${dimText('Models:')} ${tasks.join(', ')}`)
|
|
424
|
-
console.log(` ${dimText('Total:')} ~${formatBytes(totalSize)}`)
|
|
425
|
-
console.log()
|
|
426
|
-
|
|
427
|
-
const results = await downloadFromHuggingFace(manifest, {
|
|
428
|
-
destDir: modelsDir,
|
|
429
|
-
tasks,
|
|
430
|
-
onProgress(task, downloaded, total) {
|
|
431
|
-
if (isTTY && total > 0) {
|
|
432
|
-
const pct = (downloaded / total) * 100
|
|
433
|
-
const bar = progressBar(pct, 25)
|
|
434
|
-
process.stdout.write(`\r ${task.padEnd(12)} ${bar} ${formatBytes(downloaded)} / ${formatBytes(total)}`)
|
|
435
|
-
}
|
|
436
|
-
},
|
|
437
|
-
onComplete(task, bytes) {
|
|
438
|
-
if (isTTY) process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
439
|
-
console.log(` ${successText(task.padEnd(12))} ${formatBytes(bytes)} ${dimText('SHA256 verified')}`)
|
|
440
|
-
},
|
|
441
|
-
onError(task, error) {
|
|
442
|
-
if (isTTY) process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
443
|
-
console.log(` ${errorText(task.padEnd(12))} ${error}`)
|
|
444
|
-
},
|
|
445
|
-
})
|
|
446
|
-
|
|
447
|
-
console.log()
|
|
448
|
-
|
|
449
|
-
const succeeded = results.filter(r => r.success)
|
|
450
|
-
if (succeeded.length === 0) {
|
|
451
|
-
console.log(warningText('No models were downloaded.'))
|
|
452
|
-
console.log()
|
|
453
|
-
return
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
// Build manifest from downloaded results + existing
|
|
457
|
-
const manifestPath = join(modelsDir, 'manifest.json')
|
|
458
|
-
const manifestModels: Partial<Record<ModelTask, ModelManifestEntry>> = {}
|
|
459
|
-
|
|
460
|
-
// Preserve existing manifest entries
|
|
461
|
-
if (existsSync(manifestPath)) {
|
|
462
|
-
try {
|
|
463
|
-
const existing: ModelManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
464
|
-
if (existing.models) Object.assign(manifestModels, existing.models)
|
|
465
|
-
} catch { /* overwrite corrupt */ }
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
const installedTasks: ModelTask[] = []
|
|
469
|
-
let totalBytes = 0
|
|
470
|
-
|
|
471
|
-
for (const result of succeeded) {
|
|
472
|
-
const task = result.task as ModelTask
|
|
473
|
-
const entry = manifest.models[task]
|
|
474
|
-
if (!entry) continue
|
|
475
|
-
|
|
476
|
-
manifestModels[task] = {
|
|
477
|
-
version: entry.version,
|
|
478
|
-
file: entry.file,
|
|
479
|
-
sha256: entry.sha256,
|
|
480
|
-
params: entry.params,
|
|
481
|
-
accuracy: entry.accuracy ?? undefined,
|
|
482
|
-
labels: entry.labels,
|
|
483
|
-
maxSeqLen: entry.maxSeqLen,
|
|
484
|
-
}
|
|
485
|
-
installedTasks.push(task)
|
|
486
|
-
totalBytes += result.bytes
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// Write manifest
|
|
490
|
-
const localManifest: ModelManifest = { models: manifestModels }
|
|
491
|
-
writeFileSync(manifestPath, JSON.stringify(localManifest, null, 2))
|
|
492
|
-
|
|
493
|
-
// Auto-enable models in config
|
|
494
|
-
const config = loadConfigFile()
|
|
495
|
-
if (!config.slm) config.slm = {}
|
|
496
|
-
config.slm.enabled = true
|
|
497
|
-
if (!config.slm.tasks) config.slm.tasks = {}
|
|
498
|
-
for (const task of installedTasks) {
|
|
499
|
-
config.slm.tasks[task] = 'model'
|
|
500
|
-
}
|
|
501
|
-
saveConfigFile(config)
|
|
502
|
-
updateConfigYml(installedTasks, 'model')
|
|
503
|
-
|
|
504
|
-
console.log(successText(`Downloaded ${succeeded.length} model${succeeded.length !== 1 ? 's' : ''} (${formatBytes(totalBytes)})`))
|
|
505
|
-
console.log(successText(`Auto-enabled ${installedTasks.join(', ')} in config`))
|
|
506
|
-
console.log(dimText(`Manifest written to ${manifestPath}`))
|
|
507
|
-
console.log()
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// ─── enable ───────────────────────────────────────────────────────
|
|
511
|
-
|
|
512
|
-
function enableTask(taskArg: string) {
|
|
513
|
-
const tasks = resolveTaskArg(taskArg, 'enable')
|
|
514
|
-
if (!tasks) return
|
|
515
|
-
|
|
516
|
-
console.log()
|
|
517
|
-
console.log(heading('Enable SLM Models'))
|
|
518
|
-
console.log()
|
|
519
|
-
|
|
520
|
-
const config = loadConfigFile()
|
|
521
|
-
|
|
522
|
-
// Ensure slm section exists
|
|
523
|
-
if (!config.slm) config.slm = {}
|
|
524
|
-
config.slm.enabled = true
|
|
525
|
-
if (!config.slm.tasks) config.slm.tasks = {}
|
|
526
|
-
|
|
527
|
-
for (const task of tasks) {
|
|
528
|
-
config.slm.tasks[task] = 'model'
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
saveConfigFile(config)
|
|
532
|
-
updateConfigYml(tasks, 'model')
|
|
533
|
-
|
|
534
|
-
for (const task of tasks) {
|
|
535
|
-
console.log(successText(`${task} -> model`))
|
|
536
|
-
}
|
|
537
|
-
console.log()
|
|
538
|
-
console.log(successText('SLM enabled'))
|
|
539
|
-
console.log(dimText(' Changes take effect on next server restart'))
|
|
540
|
-
console.log()
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// ─── disable ──────────────────────────────────────────────────────
|
|
544
|
-
|
|
545
|
-
function disableTask(taskArg: string) {
|
|
546
|
-
const tasks = resolveTaskArg(taskArg, 'disable')
|
|
547
|
-
if (!tasks) return
|
|
548
|
-
|
|
549
|
-
console.log()
|
|
550
|
-
console.log(heading('Disable SLM Models'))
|
|
551
|
-
console.log()
|
|
552
|
-
|
|
553
|
-
const config = loadConfigFile()
|
|
554
|
-
if (!config.slm) config.slm = {}
|
|
555
|
-
if (!config.slm.tasks) config.slm.tasks = {}
|
|
556
|
-
|
|
557
|
-
for (const task of tasks) {
|
|
558
|
-
config.slm.tasks[task] = DISABLE_MODE[task]
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// If all tasks are now regex/api, disable SLM globally
|
|
562
|
-
const allDisabled = ALL_TASKS.every(t => {
|
|
563
|
-
const mode = config.slm.tasks[t] || DISABLE_MODE[t]
|
|
564
|
-
return mode === 'regex' || mode === 'api'
|
|
565
|
-
})
|
|
566
|
-
if (allDisabled) {
|
|
567
|
-
config.slm.enabled = false
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
saveConfigFile(config)
|
|
571
|
-
updateConfigYml(tasks, 'disable')
|
|
572
|
-
|
|
573
|
-
for (const task of tasks) {
|
|
574
|
-
console.log(warningText(`${task} -> ${DISABLE_MODE[task]}`))
|
|
575
|
-
}
|
|
576
|
-
console.log()
|
|
577
|
-
if (allDisabled) {
|
|
578
|
-
console.log(warningText('All tasks disabled — SLM globally disabled'))
|
|
579
|
-
}
|
|
580
|
-
console.log(dimText(' Changes take effect on next server restart'))
|
|
581
|
-
console.log()
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
/** Resolve a task argument to a list of tasks, or null if invalid */
|
|
585
|
-
function resolveTaskArg(taskArg: string, verb: string): ModelTask[] | null {
|
|
586
|
-
if (!taskArg) {
|
|
587
|
-
console.log(errorText('Missing task argument'))
|
|
588
|
-
console.log(dimText(`Usage: claude-brain models ${verb} <task|all>`))
|
|
589
|
-
console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
590
|
-
return null
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (taskArg === 'all') return [...ALL_TASKS]
|
|
594
|
-
|
|
595
|
-
if (!ALL_TASKS.includes(taskArg as ModelTask)) {
|
|
596
|
-
console.log(errorText(`Invalid task: ${taskArg}`))
|
|
597
|
-
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
598
|
-
return null
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
return [taskArg as ModelTask]
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// ─── benchmark ────────────────────────────────────────────────────
|
|
605
|
-
|
|
606
|
-
async function benchmarkTask(task: string) {
|
|
607
|
-
if (!task) {
|
|
608
|
-
console.log(errorText('Missing task argument'))
|
|
609
|
-
console.log(dimText(`Usage: claude-brain models benchmark <task>`))
|
|
610
|
-
console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}`))
|
|
611
|
-
return
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
if (!ALL_TASKS.includes(task as ModelTask)) {
|
|
615
|
-
console.log(errorText(`Invalid task: ${task}`))
|
|
616
|
-
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
|
|
617
|
-
return
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
console.log()
|
|
621
|
-
console.log(heading(`Benchmark: ${task}`))
|
|
622
|
-
console.log()
|
|
623
|
-
|
|
624
|
-
// Look for test data
|
|
625
|
-
const home = getClaudeBrainHome()
|
|
626
|
-
const testDataPath = join(home, 'training', 'benchmarks', 'baseline', `${task}_test.jsonl`)
|
|
627
|
-
|
|
628
|
-
if (!existsSync(testDataPath)) {
|
|
629
|
-
console.log(warningText(`Test data not found: ${testDataPath}`))
|
|
630
|
-
console.log(dimText('Generate test data first with: claude-brain export-training ' + task))
|
|
631
|
-
return
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// Load test examples
|
|
635
|
-
const lines = readFileSync(testDataPath, 'utf-8').trim().split('\n').filter(Boolean)
|
|
636
|
-
if (lines.length === 0) {
|
|
637
|
-
console.log(warningText('Test file is empty'))
|
|
638
|
-
return
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
console.log(dimText(`Loaded ${lines.length} test examples from ${testDataPath}`))
|
|
642
|
-
console.log()
|
|
643
|
-
|
|
644
|
-
// Run through regex classifier for intent task
|
|
645
|
-
if (task === 'intent') {
|
|
646
|
-
await benchmarkIntent(lines)
|
|
647
|
-
} else {
|
|
648
|
-
// For non-intent tasks, just report data availability
|
|
649
|
-
console.log(dimText(`Benchmark for "${task}" requires model inference (ONNX).`))
|
|
650
|
-
console.log(dimText(`Regex-only benchmarking is available for the intent task.`))
|
|
651
|
-
console.log()
|
|
652
|
-
console.log(dimText(`Test data: ${lines.length} examples ready`))
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
async function benchmarkIntent(lines: string[]) {
|
|
657
|
-
// Dynamic import to avoid circular deps
|
|
658
|
-
const { IntentClassifier } = await import('@/routing/intent-classifier')
|
|
659
|
-
const classifier = new IntentClassifier()
|
|
660
|
-
|
|
661
|
-
let correct = 0
|
|
662
|
-
const labelCounts: Record<string, { tp: number; fp: number; fn: number }> = {}
|
|
663
|
-
|
|
664
|
-
for (const line of lines) {
|
|
665
|
-
let example: { input: string; output: { label: string } }
|
|
666
|
-
try {
|
|
667
|
-
example = JSON.parse(line)
|
|
668
|
-
} catch {
|
|
669
|
-
continue
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
const expected = example.output?.label
|
|
673
|
-
if (!expected) continue
|
|
674
|
-
|
|
675
|
-
const result = classifier.classify(example.input)
|
|
676
|
-
const predicted = result.primary
|
|
677
|
-
|
|
678
|
-
// Initialize counters
|
|
679
|
-
if (!labelCounts[expected]) labelCounts[expected] = { tp: 0, fp: 0, fn: 0 }
|
|
680
|
-
if (!labelCounts[predicted]) labelCounts[predicted] = { tp: 0, fp: 0, fn: 0 }
|
|
681
|
-
|
|
682
|
-
if (predicted === expected) {
|
|
683
|
-
correct++
|
|
684
|
-
labelCounts[expected].tp++
|
|
685
|
-
} else {
|
|
686
|
-
labelCounts[expected].fn++
|
|
687
|
-
labelCounts[predicted].fp++
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const total = lines.length
|
|
692
|
-
const accuracy = total > 0 ? correct / total : 0
|
|
693
|
-
|
|
694
|
-
console.log(` ${theme.bold('Overall Accuracy:')} ${accuracy >= 0.7 ? successText(`${(accuracy * 100).toFixed(1)}%`) : warningText(`${(accuracy * 100).toFixed(1)}%`)} (${correct}/${total})`)
|
|
695
|
-
console.log()
|
|
696
|
-
|
|
697
|
-
// Per-class metrics
|
|
698
|
-
console.log(` ${dimText('Label'.padEnd(20))} ${dimText('Prec'.padEnd(8))} ${dimText('Recall'.padEnd(8))} ${dimText('F1'.padEnd(8))} ${dimText('Support')}`)
|
|
699
|
-
console.log(` ${dimText('-'.repeat(52))}`)
|
|
700
|
-
|
|
701
|
-
const sortedLabels = Object.keys(labelCounts).sort()
|
|
702
|
-
for (const label of sortedLabels) {
|
|
703
|
-
const { tp, fp, fn } = labelCounts[label]
|
|
704
|
-
const precision = tp + fp > 0 ? tp / (tp + fp) : 0
|
|
705
|
-
const recall = tp + fn > 0 ? tp / (tp + fn) : 0
|
|
706
|
-
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0
|
|
707
|
-
const support = tp + fn
|
|
708
|
-
|
|
709
|
-
console.log(
|
|
710
|
-
` ${theme.primary(label.padEnd(20))} ${(precision * 100).toFixed(1).padStart(5)}% ${(recall * 100).toFixed(1).padStart(5)}% ${(f1 * 100).toFixed(1).padStart(5)}% ${String(support).padStart(5)}`
|
|
711
|
-
)
|
|
712
|
-
}
|
|
713
|
-
console.log()
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ─── stats ────────────────────────────────────────────────────────
|
|
717
|
-
|
|
718
|
-
async function showStats() {
|
|
719
|
-
console.log()
|
|
720
|
-
console.log(renderLogo())
|
|
721
|
-
console.log()
|
|
722
|
-
console.log(heading('Training Data Statistics'))
|
|
723
|
-
console.log()
|
|
724
|
-
|
|
725
|
-
const stats = getTrainingStats()
|
|
726
|
-
const items: Array<{ label: string; value: string; status?: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
727
|
-
|
|
728
|
-
let grandTotal = 0
|
|
729
|
-
let grandVerified = 0
|
|
730
|
-
|
|
731
|
-
for (const task of ALL_TASKS) {
|
|
732
|
-
const { total, verified } = stats[task]
|
|
733
|
-
grandTotal += total
|
|
734
|
-
grandVerified += verified
|
|
735
|
-
items.push({
|
|
736
|
-
label: task,
|
|
737
|
-
value: `${total} total, ${verified} verified`,
|
|
738
|
-
status: total > 0 ? 'success' : 'warning'
|
|
739
|
-
})
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
items.push({
|
|
743
|
-
label: 'Total',
|
|
744
|
-
value: `${grandTotal} total, ${grandVerified} verified`,
|
|
745
|
-
status: grandTotal > 0 ? 'success' : 'warning'
|
|
746
|
-
})
|
|
747
|
-
|
|
748
|
-
console.log(summaryPanel('Training Data', items))
|
|
749
|
-
console.log()
|
|
750
|
-
|
|
751
|
-
// Check for model_feedback table
|
|
752
|
-
try {
|
|
753
|
-
const { Database } = await import('bun:sqlite') as any
|
|
754
|
-
const dbPath = join(getClaudeBrainHome(), 'data', 'memory.db')
|
|
755
|
-
if (existsSync(dbPath)) {
|
|
756
|
-
const db = new Database(dbPath, { readonly: true })
|
|
757
|
-
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='model_feedback'").get()
|
|
758
|
-
if (hasTable) {
|
|
759
|
-
const disagreements = (db.prepare('SELECT COUNT(*) as c FROM model_feedback WHERE model_label != regex_label').get() as any)?.c ?? 0
|
|
760
|
-
const totalFeedback = (db.prepare('SELECT COUNT(*) as c FROM model_feedback').get() as any)?.c ?? 0
|
|
761
|
-
console.log(` ${dimText('Model Feedback:')} ${totalFeedback} entries, ${disagreements} disagreements`)
|
|
762
|
-
console.log()
|
|
763
|
-
}
|
|
764
|
-
db.close()
|
|
765
|
-
}
|
|
766
|
-
} catch {
|
|
767
|
-
// model_feedback table doesn't exist yet — that's fine
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
// ─── retrain ──────────────────────────────────────────────────────
|
|
772
|
-
|
|
773
|
-
async function retrainModels(taskFilter: string, force: boolean) {
|
|
774
|
-
console.log()
|
|
775
|
-
console.log(renderLogo())
|
|
776
|
-
console.log()
|
|
777
|
-
console.log(heading('Retrain Models'))
|
|
778
|
-
console.log()
|
|
779
|
-
|
|
780
|
-
// Validate task filter
|
|
781
|
-
if (taskFilter !== 'all' && !ALL_TASKS.includes(taskFilter as ModelTask)) {
|
|
782
|
-
console.log(errorText(`Invalid task: ${taskFilter}`))
|
|
783
|
-
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
784
|
-
return
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
// Build config from schema defaults + .claudebrainrc.json overrides
|
|
788
|
-
const userConfig = loadConfigFile()
|
|
789
|
-
const retrainCfg = userConfig?.slm?.retrain ?? {}
|
|
790
|
-
|
|
791
|
-
const config: RetrainConfig = {
|
|
792
|
-
minFeedbackCount: retrainCfg.minFeedbackCount ?? 100,
|
|
793
|
-
maxDisagreementRate: retrainCfg.maxDisagreementRate ?? 0.15,
|
|
794
|
-
pythonPath: retrainCfg.pythonPath ?? 'python3',
|
|
795
|
-
trainingDir: retrainCfg.trainingDir ?? '~/slm-training',
|
|
796
|
-
force,
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
console.log(` ${dimText('Training dir:')} ${config.trainingDir}`)
|
|
800
|
-
console.log(` ${dimText('Python path:')} ${config.pythonPath}`)
|
|
801
|
-
console.log(` ${dimText('Min feedback:')} ${config.minFeedbackCount}`)
|
|
802
|
-
console.log(` ${dimText('Max disagreement:')} ${(config.maxDisagreementRate * 100).toFixed(0)}%`)
|
|
803
|
-
if (force) console.log(` ${warningText('Force mode enabled — skipping checks')}`)
|
|
804
|
-
console.log()
|
|
805
|
-
|
|
806
|
-
if (taskFilter === 'all') {
|
|
807
|
-
// Retrain all tasks that need it
|
|
808
|
-
const results = await retrainAll(config)
|
|
809
|
-
|
|
810
|
-
if (results.size === 0) {
|
|
811
|
-
console.log(dimText(' No tasks needed retraining.'))
|
|
812
|
-
console.log()
|
|
813
|
-
return
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
// Summary
|
|
817
|
-
console.log()
|
|
818
|
-
console.log(heading('Retrain Summary'))
|
|
819
|
-
console.log()
|
|
820
|
-
for (const [task, result] of results) {
|
|
821
|
-
if (result.success) {
|
|
822
|
-
const accStr = result.newAccuracy != null
|
|
823
|
-
? `${(result.newAccuracy * 100).toFixed(1)}%`
|
|
824
|
-
: 'n/a'
|
|
825
|
-
const oldStr = result.oldAccuracy != null
|
|
826
|
-
? ` (was ${(result.oldAccuracy * 100).toFixed(1)}%)`
|
|
827
|
-
: ''
|
|
828
|
-
console.log(` ${successText(task.padEnd(12))} accuracy: ${accStr}${oldStr} ${dimText(`${result.trainingDataCount} examples, ${(result.duration / 1000).toFixed(1)}s`)}`)
|
|
829
|
-
} else {
|
|
830
|
-
console.log(` ${errorText(task.padEnd(12))} ${result.error}`)
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
} else {
|
|
834
|
-
// Single task
|
|
835
|
-
const task = taskFilter as ModelTask
|
|
836
|
-
|
|
837
|
-
if (!force) {
|
|
838
|
-
const check = shouldRetrain(task, config)
|
|
839
|
-
console.log(` ${dimText('Feedback count:')} ${check.feedbackCount}`)
|
|
840
|
-
console.log(` ${dimText('Disagreement rate:')} ${(check.disagreementRate * 100).toFixed(1)}%`)
|
|
841
|
-
console.log(` ${dimText('Last retrain:')} ${check.lastRetrainDate ?? 'never'}`)
|
|
842
|
-
console.log(` ${dimText('Needs retrain:')} ${check.needed ? 'yes' : 'no'} — ${check.reason}`)
|
|
843
|
-
console.log()
|
|
844
|
-
|
|
845
|
-
if (!check.needed) {
|
|
846
|
-
console.log(dimText(' Retrain not needed. Use --force to override.'))
|
|
847
|
-
console.log()
|
|
848
|
-
return
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
const result = await retrainTask(task, config)
|
|
853
|
-
|
|
854
|
-
console.log()
|
|
855
|
-
if (result.success) {
|
|
856
|
-
const accStr = result.newAccuracy != null
|
|
857
|
-
? `${(result.newAccuracy * 100).toFixed(1)}%`
|
|
858
|
-
: 'n/a'
|
|
859
|
-
const oldStr = result.oldAccuracy != null
|
|
860
|
-
? ` (was ${(result.oldAccuracy * 100).toFixed(1)}%)`
|
|
861
|
-
: ''
|
|
862
|
-
console.log(successText(`Retrain complete: accuracy ${accStr}${oldStr}`))
|
|
863
|
-
console.log(dimText(` ${result.trainingDataCount} examples, ${(result.duration / 1000).toFixed(1)}s`))
|
|
864
|
-
} else {
|
|
865
|
-
console.log(errorText(`Retrain failed: ${result.error}`))
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
console.log()
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// ─── help ─────────────────────────────────────────────────────────
|
|
873
|
-
|
|
874
|
-
function printModelsHelp() {
|
|
875
|
-
console.log()
|
|
876
|
-
console.log(renderLogo())
|
|
877
|
-
console.log()
|
|
878
|
-
console.log(heading('SLM Model Management'))
|
|
879
|
-
console.log()
|
|
880
|
-
|
|
881
|
-
const subcommands = [
|
|
882
|
-
['list', 'Show installed models and their status'],
|
|
883
|
-
['status', 'Show inference routing and ONNX runtime status'],
|
|
884
|
-
['download', 'Download models (--source local|hf, --task <task>|all)'],
|
|
885
|
-
['enable <task|all>', 'Enable model inference for task(s)'],
|
|
886
|
-
['disable <task|all>', 'Disable model inference for task(s)'],
|
|
887
|
-
['benchmark <task>', 'Run accuracy benchmark on test data'],
|
|
888
|
-
['stats', 'Show training data statistics'],
|
|
889
|
-
['retrain [<task>|all]', 'Retrain models from feedback (--force)'],
|
|
890
|
-
]
|
|
891
|
-
|
|
892
|
-
const lines = subcommands
|
|
893
|
-
.map(([cmd, desc]) => ` ${theme.primary(cmd!.padEnd(20))} ${dimText(desc!)}`)
|
|
894
|
-
.join('\n')
|
|
895
|
-
|
|
896
|
-
console.log(theme.bold('Usage:') + ' ' + dimText('claude-brain models <subcommand>'))
|
|
897
|
-
console.log()
|
|
898
|
-
console.log(theme.bold('Subcommands:'))
|
|
899
|
-
console.log(lines)
|
|
900
|
-
console.log()
|
|
901
|
-
console.log(theme.bold('Tasks:') + ' ' + dimText(ALL_TASKS.join(', ')))
|
|
902
|
-
console.log()
|
|
903
|
-
console.log(theme.bold('Examples:'))
|
|
904
|
-
console.log(` ${dimText('claude-brain models list')}`)
|
|
905
|
-
console.log(` ${dimText('claude-brain models status')}`)
|
|
906
|
-
console.log(` ${dimText('claude-brain models download --source hf')}`)
|
|
907
|
-
console.log(` ${dimText('claude-brain models download --source hf --task intent')}`)
|
|
908
|
-
console.log(` ${dimText('claude-brain models download --source local')}`)
|
|
909
|
-
console.log(` ${dimText('claude-brain models enable all')}`)
|
|
910
|
-
console.log(` ${dimText('claude-brain models enable intent')}`)
|
|
911
|
-
console.log(` ${dimText('claude-brain models disable pattern')}`)
|
|
912
|
-
console.log(` ${dimText('claude-brain models benchmark intent')}`)
|
|
913
|
-
console.log(` ${dimText('claude-brain models stats')}`)
|
|
914
|
-
console.log(` ${dimText('claude-brain models retrain intent')}`)
|
|
915
|
-
console.log(` ${dimText('claude-brain models retrain all --force')}`)
|
|
916
|
-
console.log()
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// ─── helpers ──────────────────────────────────────────────────────
|
|
920
|
-
|
|
921
|
-
const RC_FILE = '.claudebrainrc.json'
|
|
922
|
-
|
|
923
|
-
function loadConfigFile(): Record<string, any> {
|
|
924
|
-
const rcPath = join(getClaudeBrainHome(), RC_FILE)
|
|
925
|
-
if (!existsSync(rcPath)) return {}
|
|
926
|
-
try {
|
|
927
|
-
return JSON.parse(readFileSync(rcPath, 'utf-8'))
|
|
928
|
-
} catch {
|
|
929
|
-
return {}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function saveConfigFile(config: Record<string, any>): void {
|
|
934
|
-
const rcPath = join(getClaudeBrainHome(), RC_FILE)
|
|
935
|
-
writeFileSync(rcPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/** Best-effort update of config.yml task lines for user visibility */
|
|
939
|
-
function updateConfigYml(tasks: ModelTask[], mode: string): void {
|
|
940
|
-
const ymlPath = join(getClaudeBrainHome(), 'config.yml')
|
|
941
|
-
if (!existsSync(ymlPath)) return
|
|
942
|
-
|
|
943
|
-
try {
|
|
944
|
-
let content = readFileSync(ymlPath, 'utf-8')
|
|
945
|
-
|
|
946
|
-
// Update each task line (e.g. " intent: regex" -> " intent: model")
|
|
947
|
-
for (const task of tasks) {
|
|
948
|
-
const newMode = mode === 'disable' ? DISABLE_MODE[task] : mode
|
|
949
|
-
const regex = new RegExp(`^(\\s*${task}:\\s*)\\S+`, 'm')
|
|
950
|
-
content = content.replace(regex, `$1${newMode}`)
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// Update slm.enabled line if present
|
|
954
|
-
// Use a heuristic: find "enabled:" that appears after "slm:"
|
|
955
|
-
const slmIdx = content.indexOf('slm:')
|
|
956
|
-
if (slmIdx !== -1) {
|
|
957
|
-
const afterSlm = content.slice(slmIdx)
|
|
958
|
-
const enabledMatch = afterSlm.match(/^(\s+enabled:\s*)\S+/m)
|
|
959
|
-
if (enabledMatch) {
|
|
960
|
-
const config = loadConfigFile()
|
|
961
|
-
const slmEnabled = config.slm?.enabled ?? false
|
|
962
|
-
const newLine = `${enabledMatch[1]}${slmEnabled}`
|
|
963
|
-
content = content.slice(0, slmIdx) + afterSlm.replace(enabledMatch[0], newLine)
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
writeFileSync(ymlPath, content, 'utf-8')
|
|
968
|
-
} catch {
|
|
969
|
-
// Non-critical — .claudebrainrc.json is the authoritative config
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function formatBytes(bytes: number): string {
|
|
974
|
-
if (bytes === 0) return '0 B'
|
|
975
|
-
const units = ['B', 'KB', 'MB', 'GB']
|
|
976
|
-
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
977
|
-
const val = bytes / Math.pow(1024, i)
|
|
978
|
-
return `${val.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
|
979
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Models Command — SLM Upgrade Phase 5
|
|
3
|
+
* Manage SLM models: list, download, enable, disable, benchmark, stats.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* claude-brain models list
|
|
7
|
+
* claude-brain models download [--task <task>|all]
|
|
8
|
+
* claude-brain models enable <task>
|
|
9
|
+
* claude-brain models disable <task>
|
|
10
|
+
* claude-brain models benchmark <task>
|
|
11
|
+
* claude-brain models stats
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync, copyFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { parseArgs } from 'citty'
|
|
18
|
+
import { renderLogo, theme, heading, dimText, successText, warningText, errorText, box, summaryPanel } from '@/cli/ui/index.js'
|
|
19
|
+
import { progressBar } from '@/cli/ui/components.js'
|
|
20
|
+
import { getHomePaths, getClaudeBrainHome } from '@/config/home'
|
|
21
|
+
import { getTrainingStats, type TrainingTask } from '@/training/data-store'
|
|
22
|
+
import type { ModelManifest, ModelManifestEntry, ModelTask } from '@/intelligence/model-manager'
|
|
23
|
+
import { shouldRetrain, retrainTask, retrainAll, type RetrainConfig } from '@/training/retrain-pipeline'
|
|
24
|
+
import { downloadFromHuggingFace, type HfManifest } from '@/intelligence/hf-downloader'
|
|
25
|
+
import hfManifestData from '@/intelligence/hf-manifest.json'
|
|
26
|
+
|
|
27
|
+
const ALL_TASKS: ModelTask[] = ['intent', 'entity', 'query', 'knowledge', 'compress', 'pattern']
|
|
28
|
+
|
|
29
|
+
/** Default mode when disabling a task */
|
|
30
|
+
const DISABLE_MODE: Record<ModelTask, string> = {
|
|
31
|
+
intent: 'regex',
|
|
32
|
+
entity: 'regex',
|
|
33
|
+
query: 'regex',
|
|
34
|
+
knowledge: 'regex',
|
|
35
|
+
compress: 'api',
|
|
36
|
+
pattern: 'regex',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runModels() {
|
|
40
|
+
const args = parseArgs(process.argv.slice(3), {
|
|
41
|
+
subcommand: { type: 'positional', required: false, description: 'Subcommand: list, status, download, enable, disable, benchmark, stats, retrain' },
|
|
42
|
+
taskArg: { type: 'positional', required: false, description: 'Task name or "all" (for enable/disable/benchmark/retrain)' },
|
|
43
|
+
task: { type: 'string', description: 'Target task (for download --task)' },
|
|
44
|
+
source: { type: 'string', description: 'Source: local (default) or hf (Hugging Face Hub)' },
|
|
45
|
+
force: { type: 'boolean', description: 'Force retrain even if checks say not needed' },
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const subcommand = args.subcommand || ''
|
|
49
|
+
const taskArg = (args.task || args.taskArg || '') as string
|
|
50
|
+
|
|
51
|
+
switch (subcommand) {
|
|
52
|
+
case 'list':
|
|
53
|
+
return listModels()
|
|
54
|
+
case 'status':
|
|
55
|
+
return showStatus()
|
|
56
|
+
case 'download':
|
|
57
|
+
return downloadModels(taskArg || 'all', (args.source as string) || 'local')
|
|
58
|
+
case 'enable':
|
|
59
|
+
return enableTask(taskArg)
|
|
60
|
+
case 'disable':
|
|
61
|
+
return disableTask(taskArg)
|
|
62
|
+
case 'benchmark':
|
|
63
|
+
return benchmarkTask(taskArg)
|
|
64
|
+
case 'stats':
|
|
65
|
+
return showStats()
|
|
66
|
+
case 'retrain':
|
|
67
|
+
return retrainModels(taskArg || 'all', !!args.force)
|
|
68
|
+
default:
|
|
69
|
+
return printModelsHelp()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── list ─────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function listModels() {
|
|
76
|
+
console.log()
|
|
77
|
+
console.log(renderLogo())
|
|
78
|
+
console.log()
|
|
79
|
+
console.log(heading('SLM Models'))
|
|
80
|
+
console.log()
|
|
81
|
+
|
|
82
|
+
const paths = getHomePaths()
|
|
83
|
+
const manifestPath = join(paths.models, 'manifest.json')
|
|
84
|
+
|
|
85
|
+
const items: Array<{ label: string; value: string; status?: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
86
|
+
|
|
87
|
+
if (!existsSync(manifestPath)) {
|
|
88
|
+
items.push({ label: 'Manifest', value: 'Not found — run "models download" first', status: 'warning' })
|
|
89
|
+
console.log(summaryPanel('Models', items))
|
|
90
|
+
console.log()
|
|
91
|
+
|
|
92
|
+
// Still show task status even without manifest
|
|
93
|
+
for (const task of ALL_TASKS) {
|
|
94
|
+
console.log(` ${theme.primary(task.padEnd(12))} ${warningText('not installed')}`)
|
|
95
|
+
}
|
|
96
|
+
console.log()
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let manifest: ModelManifest
|
|
101
|
+
try {
|
|
102
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
103
|
+
} catch {
|
|
104
|
+
console.log(errorText('Failed to parse manifest.json'))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let totalSize = 0
|
|
109
|
+
const rows: string[] = []
|
|
110
|
+
|
|
111
|
+
for (const task of ALL_TASKS) {
|
|
112
|
+
const entry = manifest.models?.[task]
|
|
113
|
+
if (!entry) {
|
|
114
|
+
rows.push(` ${theme.primary(task.padEnd(12))} ${warningText('not installed')}`)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const modelPath = join(paths.models, entry.file)
|
|
119
|
+
const installed = existsSync(modelPath)
|
|
120
|
+
|
|
121
|
+
let sizeStr = '—'
|
|
122
|
+
if (installed) {
|
|
123
|
+
try {
|
|
124
|
+
const size = statSync(modelPath).size
|
|
125
|
+
totalSize += size
|
|
126
|
+
sizeStr = formatBytes(size)
|
|
127
|
+
} catch {
|
|
128
|
+
sizeStr = '?'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const status = installed ? successText('installed') : warningText('missing')
|
|
133
|
+
const version = entry.version ? dimText(`v${entry.version}`) : ''
|
|
134
|
+
const accuracy = entry.accuracy != null ? dimText(`${(entry.accuracy * 100).toFixed(1)}%`) : ''
|
|
135
|
+
|
|
136
|
+
rows.push(
|
|
137
|
+
` ${theme.primary(task.padEnd(12))} ${status.padEnd(24)} ${version.padEnd(14)} ${accuracy.padEnd(10)} ${dimText(sizeStr)}`
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Header
|
|
142
|
+
console.log(
|
|
143
|
+
` ${dimText('Task'.padEnd(12))} ${dimText('Status'.padEnd(14))} ${dimText('Version'.padEnd(14))} ${dimText('Accuracy'.padEnd(10))} ${dimText('Size')}`
|
|
144
|
+
)
|
|
145
|
+
for (const row of rows) {
|
|
146
|
+
console.log(row)
|
|
147
|
+
}
|
|
148
|
+
console.log()
|
|
149
|
+
console.log(` ${dimText('Total size:')} ${dimText(formatBytes(totalSize))}`)
|
|
150
|
+
console.log()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── status ──────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async function showStatus() {
|
|
156
|
+
console.log()
|
|
157
|
+
console.log(renderLogo())
|
|
158
|
+
console.log()
|
|
159
|
+
console.log(heading('SLM Inference Status'))
|
|
160
|
+
console.log()
|
|
161
|
+
|
|
162
|
+
// Check ONNX runtime availability
|
|
163
|
+
let onnxAvailable = false
|
|
164
|
+
let onnxBackend = 'not installed'
|
|
165
|
+
try {
|
|
166
|
+
await import('onnxruntime-node')
|
|
167
|
+
onnxAvailable = true
|
|
168
|
+
onnxBackend = 'onnxruntime-node (native)'
|
|
169
|
+
} catch {
|
|
170
|
+
try {
|
|
171
|
+
await import('onnxruntime-web')
|
|
172
|
+
onnxAvailable = true
|
|
173
|
+
onnxBackend = 'onnxruntime-web (WASM)'
|
|
174
|
+
} catch {
|
|
175
|
+
// Neither available
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const config = loadConfigFile()
|
|
180
|
+
const slmEnabled = config.slm?.enabled ?? false
|
|
181
|
+
const taskConfig = config.slm?.tasks ?? {}
|
|
182
|
+
|
|
183
|
+
const paths = getHomePaths()
|
|
184
|
+
const manifest = loadManifest()
|
|
185
|
+
|
|
186
|
+
const headerItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = [
|
|
187
|
+
{
|
|
188
|
+
label: 'ONNX Runtime',
|
|
189
|
+
value: onnxBackend,
|
|
190
|
+
status: onnxAvailable ? 'success' : 'warning',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
label: 'SLM Enabled',
|
|
194
|
+
value: slmEnabled ? 'yes' : 'no',
|
|
195
|
+
status: slmEnabled ? 'success' : 'info',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
label: 'Confidence Threshold',
|
|
199
|
+
value: `${config.slm?.confidenceThreshold ?? 0.7}`,
|
|
200
|
+
status: 'info',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
label: 'Models Dir',
|
|
204
|
+
value: paths.models,
|
|
205
|
+
status: existsSync(paths.models) ? 'success' : 'warning',
|
|
206
|
+
},
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
console.log(summaryPanel('Configuration', headerItems))
|
|
210
|
+
console.log()
|
|
211
|
+
|
|
212
|
+
// Per-task status
|
|
213
|
+
const taskItems: Array<{ label: string; value: string; status: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
214
|
+
|
|
215
|
+
for (const task of ALL_TASKS) {
|
|
216
|
+
const mode = taskConfig[task] || DISABLE_MODE[task]
|
|
217
|
+
const entry = manifest?.models?.[task]
|
|
218
|
+
const fileExists = entry ? existsSync(join(paths.models, entry.file)) : false
|
|
219
|
+
|
|
220
|
+
let statusStr = `mode: ${mode}`
|
|
221
|
+
if (entry) {
|
|
222
|
+
statusStr += fileExists ? ', model: available' : ', model: MISSING'
|
|
223
|
+
} else {
|
|
224
|
+
statusStr += ', no manifest entry'
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const isActive = (mode === 'model' || mode === 'both') && fileExists && onnxAvailable
|
|
228
|
+
taskItems.push({
|
|
229
|
+
label: task,
|
|
230
|
+
value: statusStr,
|
|
231
|
+
status: isActive ? 'success' : mode === 'model' || mode === 'both' ? 'error' : 'info',
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(summaryPanel('Task Routing', taskItems))
|
|
236
|
+
console.log()
|
|
237
|
+
|
|
238
|
+
if (!onnxAvailable) {
|
|
239
|
+
console.log(warningText(' ONNX Runtime is not installed. Models cannot be loaded.'))
|
|
240
|
+
console.log(dimText(' Install with: npm install onnxruntime-node'))
|
|
241
|
+
console.log()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!slmEnabled && manifest) {
|
|
245
|
+
console.log(dimText(' SLM is disabled. Enable with: claude-brain models enable all'))
|
|
246
|
+
console.log()
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function loadManifest(): ModelManifest | null {
|
|
251
|
+
const manifestPath = join(getHomePaths().models, 'manifest.json')
|
|
252
|
+
if (!existsSync(manifestPath)) return null
|
|
253
|
+
try {
|
|
254
|
+
return JSON.parse(readFileSync(manifestPath, 'utf-8')) as ModelManifest
|
|
255
|
+
} catch {
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── download ─────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
async function downloadModels(taskFilter: string, source: string) {
|
|
263
|
+
console.log()
|
|
264
|
+
console.log(renderLogo())
|
|
265
|
+
console.log()
|
|
266
|
+
console.log(heading('Download Models'))
|
|
267
|
+
console.log()
|
|
268
|
+
|
|
269
|
+
const paths = getHomePaths()
|
|
270
|
+
|
|
271
|
+
// Validate task filter
|
|
272
|
+
if (taskFilter !== 'all' && !ALL_TASKS.includes(taskFilter as ModelTask)) {
|
|
273
|
+
console.log(errorText(`Invalid task: ${taskFilter}`))
|
|
274
|
+
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const tasks = taskFilter === 'all' ? ALL_TASKS : [taskFilter as ModelTask]
|
|
279
|
+
|
|
280
|
+
// Ensure models directory exists
|
|
281
|
+
if (!existsSync(paths.models)) {
|
|
282
|
+
mkdirSync(paths.models, { recursive: true })
|
|
283
|
+
console.log(successText(`Created models directory: ${paths.models}`))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Hugging Face Hub source
|
|
287
|
+
if (source === 'hf' || source === 'release') {
|
|
288
|
+
return downloadFromHF(tasks, paths.models)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Local source — copy from ~/slm-training/models/
|
|
292
|
+
const sourceDir = join(homedir(), 'slm-training', 'models')
|
|
293
|
+
|
|
294
|
+
console.log(` ${dimText('Source:')} ${sourceDir}`)
|
|
295
|
+
console.log(` ${dimText('Target:')} ${paths.models}`)
|
|
296
|
+
console.log()
|
|
297
|
+
|
|
298
|
+
if (!existsSync(sourceDir)) {
|
|
299
|
+
console.log(errorText(`Source directory not found: ${sourceDir}`))
|
|
300
|
+
console.log(dimText('Train models first, then place .onnx and .json files in the source directory.'))
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let installed = 0
|
|
305
|
+
let totalBytes = 0
|
|
306
|
+
const installedTasks: ModelTask[] = []
|
|
307
|
+
const manifestModels: Partial<Record<ModelTask, ModelManifestEntry>> = {}
|
|
308
|
+
|
|
309
|
+
// Load existing manifest to preserve entries for tasks we're not updating
|
|
310
|
+
const manifestPath = join(paths.models, 'manifest.json')
|
|
311
|
+
if (existsSync(manifestPath)) {
|
|
312
|
+
try {
|
|
313
|
+
const existing: ModelManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
314
|
+
if (existing.models) {
|
|
315
|
+
Object.assign(manifestModels, existing.models)
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Ignore corrupt manifest, we'll overwrite it
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const task of tasks) {
|
|
323
|
+
const onnxFile = `${task}.onnx`
|
|
324
|
+
const metaFile = `${task}.json`
|
|
325
|
+
const srcOnnx = join(sourceDir, onnxFile)
|
|
326
|
+
const srcMeta = join(sourceDir, metaFile)
|
|
327
|
+
|
|
328
|
+
if (!existsSync(srcOnnx)) {
|
|
329
|
+
console.log(` ${warningText(`${onnxFile} not found in source — skipping`)}`)
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Copy ONNX model
|
|
334
|
+
const dstOnnx = join(paths.models, onnxFile)
|
|
335
|
+
copyFileSync(srcOnnx, dstOnnx)
|
|
336
|
+
|
|
337
|
+
// Verify copied file is valid (non-empty and size matches source)
|
|
338
|
+
const srcSize = statSync(srcOnnx).size
|
|
339
|
+
const dstSize = statSync(dstOnnx).size
|
|
340
|
+
if (dstSize === 0 || dstSize !== srcSize) {
|
|
341
|
+
console.log(` ${errorText(`${onnxFile} copy verification failed (src: ${formatBytes(srcSize)}, dst: ${formatBytes(dstSize)}) — skipping`)}`)
|
|
342
|
+
continue
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
totalBytes += dstSize
|
|
346
|
+
console.log(` Copying ${onnxFile}... ${successText('done')} ${dimText(`(${formatBytes(dstSize)})`)}`)
|
|
347
|
+
|
|
348
|
+
// Copy metadata if present
|
|
349
|
+
let meta: Partial<ModelManifestEntry> = {}
|
|
350
|
+
if (existsSync(srcMeta)) {
|
|
351
|
+
const dstMeta = join(paths.models, metaFile)
|
|
352
|
+
copyFileSync(srcMeta, dstMeta)
|
|
353
|
+
try {
|
|
354
|
+
meta = JSON.parse(readFileSync(dstMeta, 'utf-8'))
|
|
355
|
+
} catch {
|
|
356
|
+
console.log(` ${warningText(`Failed to parse ${metaFile} — using defaults`)}`)
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Build manifest entry for this task
|
|
361
|
+
// Map training metadata fields → manifest fields:
|
|
362
|
+
// val_acc → accuracy, block_size → maxSeqLen, model_name → params
|
|
363
|
+
const metaAny = meta as Record<string, any>
|
|
364
|
+
manifestModels[task] = {
|
|
365
|
+
version: meta.version ?? '0.1.0',
|
|
366
|
+
file: onnxFile,
|
|
367
|
+
sha256: meta.sha256,
|
|
368
|
+
params: meta.params ?? metaAny.model_name,
|
|
369
|
+
accuracy: meta.accuracy ?? metaAny.val_acc,
|
|
370
|
+
labels: meta.labels,
|
|
371
|
+
maxSeqLen: meta.maxSeqLen ?? metaAny.block_size,
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
installedTasks.push(task)
|
|
375
|
+
installed++
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log()
|
|
379
|
+
|
|
380
|
+
if (installed === 0) {
|
|
381
|
+
console.log(warningText('No models were installed.'))
|
|
382
|
+
console.log()
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Write manifest
|
|
387
|
+
const manifest: ModelManifest = { models: manifestModels }
|
|
388
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2))
|
|
389
|
+
|
|
390
|
+
// Auto-enable successfully installed models in config
|
|
391
|
+
const config = loadConfigFile()
|
|
392
|
+
if (!config.slm) config.slm = {}
|
|
393
|
+
config.slm.enabled = true
|
|
394
|
+
if (!config.slm.tasks) config.slm.tasks = {}
|
|
395
|
+
for (const task of installedTasks) {
|
|
396
|
+
config.slm.tasks[task] = 'model'
|
|
397
|
+
}
|
|
398
|
+
saveConfigFile(config)
|
|
399
|
+
updateConfigYml(installedTasks, 'model')
|
|
400
|
+
|
|
401
|
+
console.log(successText(`Installed ${installed} model${installed !== 1 ? 's' : ''} (total: ${formatBytes(totalBytes)})`))
|
|
402
|
+
console.log(successText(`Auto-enabled ${installedTasks.join(', ')} in config`))
|
|
403
|
+
console.log(dimText(`Manifest written to ${manifestPath}`))
|
|
404
|
+
console.log()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── download from HF ─────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
const isTTY = process.stdout.isTTY === true
|
|
410
|
+
|
|
411
|
+
async function downloadFromHF(tasks: ModelTask[], modelsDir: string) {
|
|
412
|
+
const manifest = hfManifestData as HfManifest
|
|
413
|
+
|
|
414
|
+
// Compute total download size
|
|
415
|
+
let totalSize = 0
|
|
416
|
+
for (const task of tasks) {
|
|
417
|
+
const entry = manifest.models[task]
|
|
418
|
+
if (entry) totalSize += entry.size
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
console.log(` ${dimText('Source:')} Hugging Face Hub (${manifest.hfRepo})`)
|
|
422
|
+
console.log(` ${dimText('Target:')} ${modelsDir}`)
|
|
423
|
+
console.log(` ${dimText('Models:')} ${tasks.join(', ')}`)
|
|
424
|
+
console.log(` ${dimText('Total:')} ~${formatBytes(totalSize)}`)
|
|
425
|
+
console.log()
|
|
426
|
+
|
|
427
|
+
const results = await downloadFromHuggingFace(manifest, {
|
|
428
|
+
destDir: modelsDir,
|
|
429
|
+
tasks,
|
|
430
|
+
onProgress(task, downloaded, total) {
|
|
431
|
+
if (isTTY && total > 0) {
|
|
432
|
+
const pct = (downloaded / total) * 100
|
|
433
|
+
const bar = progressBar(pct, 25)
|
|
434
|
+
process.stdout.write(`\r ${task.padEnd(12)} ${bar} ${formatBytes(downloaded)} / ${formatBytes(total)}`)
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
onComplete(task, bytes) {
|
|
438
|
+
if (isTTY) process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
439
|
+
console.log(` ${successText(task.padEnd(12))} ${formatBytes(bytes)} ${dimText('SHA256 verified')}`)
|
|
440
|
+
},
|
|
441
|
+
onError(task, error) {
|
|
442
|
+
if (isTTY) process.stdout.write('\r' + ' '.repeat(80) + '\r')
|
|
443
|
+
console.log(` ${errorText(task.padEnd(12))} ${error}`)
|
|
444
|
+
},
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
console.log()
|
|
448
|
+
|
|
449
|
+
const succeeded = results.filter(r => r.success)
|
|
450
|
+
if (succeeded.length === 0) {
|
|
451
|
+
console.log(warningText('No models were downloaded.'))
|
|
452
|
+
console.log()
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Build manifest from downloaded results + existing
|
|
457
|
+
const manifestPath = join(modelsDir, 'manifest.json')
|
|
458
|
+
const manifestModels: Partial<Record<ModelTask, ModelManifestEntry>> = {}
|
|
459
|
+
|
|
460
|
+
// Preserve existing manifest entries
|
|
461
|
+
if (existsSync(manifestPath)) {
|
|
462
|
+
try {
|
|
463
|
+
const existing: ModelManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
464
|
+
if (existing.models) Object.assign(manifestModels, existing.models)
|
|
465
|
+
} catch { /* overwrite corrupt */ }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const installedTasks: ModelTask[] = []
|
|
469
|
+
let totalBytes = 0
|
|
470
|
+
|
|
471
|
+
for (const result of succeeded) {
|
|
472
|
+
const task = result.task as ModelTask
|
|
473
|
+
const entry = manifest.models[task]
|
|
474
|
+
if (!entry) continue
|
|
475
|
+
|
|
476
|
+
manifestModels[task] = {
|
|
477
|
+
version: entry.version,
|
|
478
|
+
file: entry.file,
|
|
479
|
+
sha256: entry.sha256,
|
|
480
|
+
params: entry.params,
|
|
481
|
+
accuracy: entry.accuracy ?? undefined,
|
|
482
|
+
labels: entry.labels,
|
|
483
|
+
maxSeqLen: entry.maxSeqLen,
|
|
484
|
+
}
|
|
485
|
+
installedTasks.push(task)
|
|
486
|
+
totalBytes += result.bytes
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Write manifest
|
|
490
|
+
const localManifest: ModelManifest = { models: manifestModels }
|
|
491
|
+
writeFileSync(manifestPath, JSON.stringify(localManifest, null, 2))
|
|
492
|
+
|
|
493
|
+
// Auto-enable models in config
|
|
494
|
+
const config = loadConfigFile()
|
|
495
|
+
if (!config.slm) config.slm = {}
|
|
496
|
+
config.slm.enabled = true
|
|
497
|
+
if (!config.slm.tasks) config.slm.tasks = {}
|
|
498
|
+
for (const task of installedTasks) {
|
|
499
|
+
config.slm.tasks[task] = 'model'
|
|
500
|
+
}
|
|
501
|
+
saveConfigFile(config)
|
|
502
|
+
updateConfigYml(installedTasks, 'model')
|
|
503
|
+
|
|
504
|
+
console.log(successText(`Downloaded ${succeeded.length} model${succeeded.length !== 1 ? 's' : ''} (${formatBytes(totalBytes)})`))
|
|
505
|
+
console.log(successText(`Auto-enabled ${installedTasks.join(', ')} in config`))
|
|
506
|
+
console.log(dimText(`Manifest written to ${manifestPath}`))
|
|
507
|
+
console.log()
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ─── enable ───────────────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
function enableTask(taskArg: string) {
|
|
513
|
+
const tasks = resolveTaskArg(taskArg, 'enable')
|
|
514
|
+
if (!tasks) return
|
|
515
|
+
|
|
516
|
+
console.log()
|
|
517
|
+
console.log(heading('Enable SLM Models'))
|
|
518
|
+
console.log()
|
|
519
|
+
|
|
520
|
+
const config = loadConfigFile()
|
|
521
|
+
|
|
522
|
+
// Ensure slm section exists
|
|
523
|
+
if (!config.slm) config.slm = {}
|
|
524
|
+
config.slm.enabled = true
|
|
525
|
+
if (!config.slm.tasks) config.slm.tasks = {}
|
|
526
|
+
|
|
527
|
+
for (const task of tasks) {
|
|
528
|
+
config.slm.tasks[task] = 'model'
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
saveConfigFile(config)
|
|
532
|
+
updateConfigYml(tasks, 'model')
|
|
533
|
+
|
|
534
|
+
for (const task of tasks) {
|
|
535
|
+
console.log(successText(`${task} -> model`))
|
|
536
|
+
}
|
|
537
|
+
console.log()
|
|
538
|
+
console.log(successText('SLM enabled'))
|
|
539
|
+
console.log(dimText(' Changes take effect on next server restart'))
|
|
540
|
+
console.log()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ─── disable ──────────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
function disableTask(taskArg: string) {
|
|
546
|
+
const tasks = resolveTaskArg(taskArg, 'disable')
|
|
547
|
+
if (!tasks) return
|
|
548
|
+
|
|
549
|
+
console.log()
|
|
550
|
+
console.log(heading('Disable SLM Models'))
|
|
551
|
+
console.log()
|
|
552
|
+
|
|
553
|
+
const config = loadConfigFile()
|
|
554
|
+
if (!config.slm) config.slm = {}
|
|
555
|
+
if (!config.slm.tasks) config.slm.tasks = {}
|
|
556
|
+
|
|
557
|
+
for (const task of tasks) {
|
|
558
|
+
config.slm.tasks[task] = DISABLE_MODE[task]
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// If all tasks are now regex/api, disable SLM globally
|
|
562
|
+
const allDisabled = ALL_TASKS.every(t => {
|
|
563
|
+
const mode = config.slm.tasks[t] || DISABLE_MODE[t]
|
|
564
|
+
return mode === 'regex' || mode === 'api'
|
|
565
|
+
})
|
|
566
|
+
if (allDisabled) {
|
|
567
|
+
config.slm.enabled = false
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
saveConfigFile(config)
|
|
571
|
+
updateConfigYml(tasks, 'disable')
|
|
572
|
+
|
|
573
|
+
for (const task of tasks) {
|
|
574
|
+
console.log(warningText(`${task} -> ${DISABLE_MODE[task]}`))
|
|
575
|
+
}
|
|
576
|
+
console.log()
|
|
577
|
+
if (allDisabled) {
|
|
578
|
+
console.log(warningText('All tasks disabled — SLM globally disabled'))
|
|
579
|
+
}
|
|
580
|
+
console.log(dimText(' Changes take effect on next server restart'))
|
|
581
|
+
console.log()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Resolve a task argument to a list of tasks, or null if invalid */
|
|
585
|
+
function resolveTaskArg(taskArg: string, verb: string): ModelTask[] | null {
|
|
586
|
+
if (!taskArg) {
|
|
587
|
+
console.log(errorText('Missing task argument'))
|
|
588
|
+
console.log(dimText(`Usage: claude-brain models ${verb} <task|all>`))
|
|
589
|
+
console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
590
|
+
return null
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (taskArg === 'all') return [...ALL_TASKS]
|
|
594
|
+
|
|
595
|
+
if (!ALL_TASKS.includes(taskArg as ModelTask)) {
|
|
596
|
+
console.log(errorText(`Invalid task: ${taskArg}`))
|
|
597
|
+
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
598
|
+
return null
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return [taskArg as ModelTask]
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// ─── benchmark ────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
async function benchmarkTask(task: string) {
|
|
607
|
+
if (!task) {
|
|
608
|
+
console.log(errorText('Missing task argument'))
|
|
609
|
+
console.log(dimText(`Usage: claude-brain models benchmark <task>`))
|
|
610
|
+
console.log(dimText(`Tasks: ${ALL_TASKS.join(', ')}`))
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (!ALL_TASKS.includes(task as ModelTask)) {
|
|
615
|
+
console.log(errorText(`Invalid task: ${task}`))
|
|
616
|
+
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}`))
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
console.log()
|
|
621
|
+
console.log(heading(`Benchmark: ${task}`))
|
|
622
|
+
console.log()
|
|
623
|
+
|
|
624
|
+
// Look for test data
|
|
625
|
+
const home = getClaudeBrainHome()
|
|
626
|
+
const testDataPath = join(home, 'training', 'benchmarks', 'baseline', `${task}_test.jsonl`)
|
|
627
|
+
|
|
628
|
+
if (!existsSync(testDataPath)) {
|
|
629
|
+
console.log(warningText(`Test data not found: ${testDataPath}`))
|
|
630
|
+
console.log(dimText('Generate test data first with: claude-brain export-training ' + task))
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Load test examples
|
|
635
|
+
const lines = readFileSync(testDataPath, 'utf-8').trim().split('\n').filter(Boolean)
|
|
636
|
+
if (lines.length === 0) {
|
|
637
|
+
console.log(warningText('Test file is empty'))
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
console.log(dimText(`Loaded ${lines.length} test examples from ${testDataPath}`))
|
|
642
|
+
console.log()
|
|
643
|
+
|
|
644
|
+
// Run through regex classifier for intent task
|
|
645
|
+
if (task === 'intent') {
|
|
646
|
+
await benchmarkIntent(lines)
|
|
647
|
+
} else {
|
|
648
|
+
// For non-intent tasks, just report data availability
|
|
649
|
+
console.log(dimText(`Benchmark for "${task}" requires model inference (ONNX).`))
|
|
650
|
+
console.log(dimText(`Regex-only benchmarking is available for the intent task.`))
|
|
651
|
+
console.log()
|
|
652
|
+
console.log(dimText(`Test data: ${lines.length} examples ready`))
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function benchmarkIntent(lines: string[]) {
|
|
657
|
+
// Dynamic import to avoid circular deps
|
|
658
|
+
const { IntentClassifier } = await import('@/routing/intent-classifier')
|
|
659
|
+
const classifier = new IntentClassifier()
|
|
660
|
+
|
|
661
|
+
let correct = 0
|
|
662
|
+
const labelCounts: Record<string, { tp: number; fp: number; fn: number }> = {}
|
|
663
|
+
|
|
664
|
+
for (const line of lines) {
|
|
665
|
+
let example: { input: string; output: { label: string } }
|
|
666
|
+
try {
|
|
667
|
+
example = JSON.parse(line)
|
|
668
|
+
} catch {
|
|
669
|
+
continue
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const expected = example.output?.label
|
|
673
|
+
if (!expected) continue
|
|
674
|
+
|
|
675
|
+
const result = classifier.classify(example.input)
|
|
676
|
+
const predicted = result.primary
|
|
677
|
+
|
|
678
|
+
// Initialize counters
|
|
679
|
+
if (!labelCounts[expected]) labelCounts[expected] = { tp: 0, fp: 0, fn: 0 }
|
|
680
|
+
if (!labelCounts[predicted]) labelCounts[predicted] = { tp: 0, fp: 0, fn: 0 }
|
|
681
|
+
|
|
682
|
+
if (predicted === expected) {
|
|
683
|
+
correct++
|
|
684
|
+
labelCounts[expected].tp++
|
|
685
|
+
} else {
|
|
686
|
+
labelCounts[expected].fn++
|
|
687
|
+
labelCounts[predicted].fp++
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const total = lines.length
|
|
692
|
+
const accuracy = total > 0 ? correct / total : 0
|
|
693
|
+
|
|
694
|
+
console.log(` ${theme.bold('Overall Accuracy:')} ${accuracy >= 0.7 ? successText(`${(accuracy * 100).toFixed(1)}%`) : warningText(`${(accuracy * 100).toFixed(1)}%`)} (${correct}/${total})`)
|
|
695
|
+
console.log()
|
|
696
|
+
|
|
697
|
+
// Per-class metrics
|
|
698
|
+
console.log(` ${dimText('Label'.padEnd(20))} ${dimText('Prec'.padEnd(8))} ${dimText('Recall'.padEnd(8))} ${dimText('F1'.padEnd(8))} ${dimText('Support')}`)
|
|
699
|
+
console.log(` ${dimText('-'.repeat(52))}`)
|
|
700
|
+
|
|
701
|
+
const sortedLabels = Object.keys(labelCounts).sort()
|
|
702
|
+
for (const label of sortedLabels) {
|
|
703
|
+
const { tp, fp, fn } = labelCounts[label]
|
|
704
|
+
const precision = tp + fp > 0 ? tp / (tp + fp) : 0
|
|
705
|
+
const recall = tp + fn > 0 ? tp / (tp + fn) : 0
|
|
706
|
+
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0
|
|
707
|
+
const support = tp + fn
|
|
708
|
+
|
|
709
|
+
console.log(
|
|
710
|
+
` ${theme.primary(label.padEnd(20))} ${(precision * 100).toFixed(1).padStart(5)}% ${(recall * 100).toFixed(1).padStart(5)}% ${(f1 * 100).toFixed(1).padStart(5)}% ${String(support).padStart(5)}`
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
console.log()
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ─── stats ────────────────────────────────────────────────────────
|
|
717
|
+
|
|
718
|
+
async function showStats() {
|
|
719
|
+
console.log()
|
|
720
|
+
console.log(renderLogo())
|
|
721
|
+
console.log()
|
|
722
|
+
console.log(heading('Training Data Statistics'))
|
|
723
|
+
console.log()
|
|
724
|
+
|
|
725
|
+
const stats = getTrainingStats()
|
|
726
|
+
const items: Array<{ label: string; value: string; status?: 'success' | 'warning' | 'error' | 'info' }> = []
|
|
727
|
+
|
|
728
|
+
let grandTotal = 0
|
|
729
|
+
let grandVerified = 0
|
|
730
|
+
|
|
731
|
+
for (const task of ALL_TASKS) {
|
|
732
|
+
const { total, verified } = stats[task]
|
|
733
|
+
grandTotal += total
|
|
734
|
+
grandVerified += verified
|
|
735
|
+
items.push({
|
|
736
|
+
label: task,
|
|
737
|
+
value: `${total} total, ${verified} verified`,
|
|
738
|
+
status: total > 0 ? 'success' : 'warning'
|
|
739
|
+
})
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
items.push({
|
|
743
|
+
label: 'Total',
|
|
744
|
+
value: `${grandTotal} total, ${grandVerified} verified`,
|
|
745
|
+
status: grandTotal > 0 ? 'success' : 'warning'
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
console.log(summaryPanel('Training Data', items))
|
|
749
|
+
console.log()
|
|
750
|
+
|
|
751
|
+
// Check for model_feedback table
|
|
752
|
+
try {
|
|
753
|
+
const { Database } = await import('bun:sqlite') as any
|
|
754
|
+
const dbPath = join(getClaudeBrainHome(), 'data', 'memory.db')
|
|
755
|
+
if (existsSync(dbPath)) {
|
|
756
|
+
const db = new Database(dbPath, { readonly: true })
|
|
757
|
+
const hasTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='model_feedback'").get()
|
|
758
|
+
if (hasTable) {
|
|
759
|
+
const disagreements = (db.prepare('SELECT COUNT(*) as c FROM model_feedback WHERE model_label != regex_label').get() as any)?.c ?? 0
|
|
760
|
+
const totalFeedback = (db.prepare('SELECT COUNT(*) as c FROM model_feedback').get() as any)?.c ?? 0
|
|
761
|
+
console.log(` ${dimText('Model Feedback:')} ${totalFeedback} entries, ${disagreements} disagreements`)
|
|
762
|
+
console.log()
|
|
763
|
+
}
|
|
764
|
+
db.close()
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// model_feedback table doesn't exist yet — that's fine
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ─── retrain ──────────────────────────────────────────────────────
|
|
772
|
+
|
|
773
|
+
async function retrainModels(taskFilter: string, force: boolean) {
|
|
774
|
+
console.log()
|
|
775
|
+
console.log(renderLogo())
|
|
776
|
+
console.log()
|
|
777
|
+
console.log(heading('Retrain Models'))
|
|
778
|
+
console.log()
|
|
779
|
+
|
|
780
|
+
// Validate task filter
|
|
781
|
+
if (taskFilter !== 'all' && !ALL_TASKS.includes(taskFilter as ModelTask)) {
|
|
782
|
+
console.log(errorText(`Invalid task: ${taskFilter}`))
|
|
783
|
+
console.log(dimText(`Valid tasks: ${ALL_TASKS.join(', ')}, all`))
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Build config from schema defaults + .claudebrainrc.json overrides
|
|
788
|
+
const userConfig = loadConfigFile()
|
|
789
|
+
const retrainCfg = userConfig?.slm?.retrain ?? {}
|
|
790
|
+
|
|
791
|
+
const config: RetrainConfig = {
|
|
792
|
+
minFeedbackCount: retrainCfg.minFeedbackCount ?? 100,
|
|
793
|
+
maxDisagreementRate: retrainCfg.maxDisagreementRate ?? 0.15,
|
|
794
|
+
pythonPath: retrainCfg.pythonPath ?? 'python3',
|
|
795
|
+
trainingDir: retrainCfg.trainingDir ?? '~/slm-training',
|
|
796
|
+
force,
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
console.log(` ${dimText('Training dir:')} ${config.trainingDir}`)
|
|
800
|
+
console.log(` ${dimText('Python path:')} ${config.pythonPath}`)
|
|
801
|
+
console.log(` ${dimText('Min feedback:')} ${config.minFeedbackCount}`)
|
|
802
|
+
console.log(` ${dimText('Max disagreement:')} ${(config.maxDisagreementRate * 100).toFixed(0)}%`)
|
|
803
|
+
if (force) console.log(` ${warningText('Force mode enabled — skipping checks')}`)
|
|
804
|
+
console.log()
|
|
805
|
+
|
|
806
|
+
if (taskFilter === 'all') {
|
|
807
|
+
// Retrain all tasks that need it
|
|
808
|
+
const results = await retrainAll(config)
|
|
809
|
+
|
|
810
|
+
if (results.size === 0) {
|
|
811
|
+
console.log(dimText(' No tasks needed retraining.'))
|
|
812
|
+
console.log()
|
|
813
|
+
return
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Summary
|
|
817
|
+
console.log()
|
|
818
|
+
console.log(heading('Retrain Summary'))
|
|
819
|
+
console.log()
|
|
820
|
+
for (const [task, result] of results) {
|
|
821
|
+
if (result.success) {
|
|
822
|
+
const accStr = result.newAccuracy != null
|
|
823
|
+
? `${(result.newAccuracy * 100).toFixed(1)}%`
|
|
824
|
+
: 'n/a'
|
|
825
|
+
const oldStr = result.oldAccuracy != null
|
|
826
|
+
? ` (was ${(result.oldAccuracy * 100).toFixed(1)}%)`
|
|
827
|
+
: ''
|
|
828
|
+
console.log(` ${successText(task.padEnd(12))} accuracy: ${accStr}${oldStr} ${dimText(`${result.trainingDataCount} examples, ${(result.duration / 1000).toFixed(1)}s`)}`)
|
|
829
|
+
} else {
|
|
830
|
+
console.log(` ${errorText(task.padEnd(12))} ${result.error}`)
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
// Single task
|
|
835
|
+
const task = taskFilter as ModelTask
|
|
836
|
+
|
|
837
|
+
if (!force) {
|
|
838
|
+
const check = shouldRetrain(task, config)
|
|
839
|
+
console.log(` ${dimText('Feedback count:')} ${check.feedbackCount}`)
|
|
840
|
+
console.log(` ${dimText('Disagreement rate:')} ${(check.disagreementRate * 100).toFixed(1)}%`)
|
|
841
|
+
console.log(` ${dimText('Last retrain:')} ${check.lastRetrainDate ?? 'never'}`)
|
|
842
|
+
console.log(` ${dimText('Needs retrain:')} ${check.needed ? 'yes' : 'no'} — ${check.reason}`)
|
|
843
|
+
console.log()
|
|
844
|
+
|
|
845
|
+
if (!check.needed) {
|
|
846
|
+
console.log(dimText(' Retrain not needed. Use --force to override.'))
|
|
847
|
+
console.log()
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const result = await retrainTask(task, config)
|
|
853
|
+
|
|
854
|
+
console.log()
|
|
855
|
+
if (result.success) {
|
|
856
|
+
const accStr = result.newAccuracy != null
|
|
857
|
+
? `${(result.newAccuracy * 100).toFixed(1)}%`
|
|
858
|
+
: 'n/a'
|
|
859
|
+
const oldStr = result.oldAccuracy != null
|
|
860
|
+
? ` (was ${(result.oldAccuracy * 100).toFixed(1)}%)`
|
|
861
|
+
: ''
|
|
862
|
+
console.log(successText(`Retrain complete: accuracy ${accStr}${oldStr}`))
|
|
863
|
+
console.log(dimText(` ${result.trainingDataCount} examples, ${(result.duration / 1000).toFixed(1)}s`))
|
|
864
|
+
} else {
|
|
865
|
+
console.log(errorText(`Retrain failed: ${result.error}`))
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
console.log()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ─── help ─────────────────────────────────────────────────────────
|
|
873
|
+
|
|
874
|
+
function printModelsHelp() {
|
|
875
|
+
console.log()
|
|
876
|
+
console.log(renderLogo())
|
|
877
|
+
console.log()
|
|
878
|
+
console.log(heading('SLM Model Management'))
|
|
879
|
+
console.log()
|
|
880
|
+
|
|
881
|
+
const subcommands = [
|
|
882
|
+
['list', 'Show installed models and their status'],
|
|
883
|
+
['status', 'Show inference routing and ONNX runtime status'],
|
|
884
|
+
['download', 'Download models (--source local|hf, --task <task>|all)'],
|
|
885
|
+
['enable <task|all>', 'Enable model inference for task(s)'],
|
|
886
|
+
['disable <task|all>', 'Disable model inference for task(s)'],
|
|
887
|
+
['benchmark <task>', 'Run accuracy benchmark on test data'],
|
|
888
|
+
['stats', 'Show training data statistics'],
|
|
889
|
+
['retrain [<task>|all]', 'Retrain models from feedback (--force)'],
|
|
890
|
+
]
|
|
891
|
+
|
|
892
|
+
const lines = subcommands
|
|
893
|
+
.map(([cmd, desc]) => ` ${theme.primary(cmd!.padEnd(20))} ${dimText(desc!)}`)
|
|
894
|
+
.join('\n')
|
|
895
|
+
|
|
896
|
+
console.log(theme.bold('Usage:') + ' ' + dimText('claude-brain models <subcommand>'))
|
|
897
|
+
console.log()
|
|
898
|
+
console.log(theme.bold('Subcommands:'))
|
|
899
|
+
console.log(lines)
|
|
900
|
+
console.log()
|
|
901
|
+
console.log(theme.bold('Tasks:') + ' ' + dimText(ALL_TASKS.join(', ')))
|
|
902
|
+
console.log()
|
|
903
|
+
console.log(theme.bold('Examples:'))
|
|
904
|
+
console.log(` ${dimText('claude-brain models list')}`)
|
|
905
|
+
console.log(` ${dimText('claude-brain models status')}`)
|
|
906
|
+
console.log(` ${dimText('claude-brain models download --source hf')}`)
|
|
907
|
+
console.log(` ${dimText('claude-brain models download --source hf --task intent')}`)
|
|
908
|
+
console.log(` ${dimText('claude-brain models download --source local')}`)
|
|
909
|
+
console.log(` ${dimText('claude-brain models enable all')}`)
|
|
910
|
+
console.log(` ${dimText('claude-brain models enable intent')}`)
|
|
911
|
+
console.log(` ${dimText('claude-brain models disable pattern')}`)
|
|
912
|
+
console.log(` ${dimText('claude-brain models benchmark intent')}`)
|
|
913
|
+
console.log(` ${dimText('claude-brain models stats')}`)
|
|
914
|
+
console.log(` ${dimText('claude-brain models retrain intent')}`)
|
|
915
|
+
console.log(` ${dimText('claude-brain models retrain all --force')}`)
|
|
916
|
+
console.log()
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ─── helpers ──────────────────────────────────────────────────────
|
|
920
|
+
|
|
921
|
+
const RC_FILE = '.claudebrainrc.json'
|
|
922
|
+
|
|
923
|
+
function loadConfigFile(): Record<string, any> {
|
|
924
|
+
const rcPath = join(getClaudeBrainHome(), RC_FILE)
|
|
925
|
+
if (!existsSync(rcPath)) return {}
|
|
926
|
+
try {
|
|
927
|
+
return JSON.parse(readFileSync(rcPath, 'utf-8'))
|
|
928
|
+
} catch {
|
|
929
|
+
return {}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function saveConfigFile(config: Record<string, any>): void {
|
|
934
|
+
const rcPath = join(getClaudeBrainHome(), RC_FILE)
|
|
935
|
+
writeFileSync(rcPath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Best-effort update of config.yml task lines for user visibility */
|
|
939
|
+
function updateConfigYml(tasks: ModelTask[], mode: string): void {
|
|
940
|
+
const ymlPath = join(getClaudeBrainHome(), 'config.yml')
|
|
941
|
+
if (!existsSync(ymlPath)) return
|
|
942
|
+
|
|
943
|
+
try {
|
|
944
|
+
let content = readFileSync(ymlPath, 'utf-8')
|
|
945
|
+
|
|
946
|
+
// Update each task line (e.g. " intent: regex" -> " intent: model")
|
|
947
|
+
for (const task of tasks) {
|
|
948
|
+
const newMode = mode === 'disable' ? DISABLE_MODE[task] : mode
|
|
949
|
+
const regex = new RegExp(`^(\\s*${task}:\\s*)\\S+`, 'm')
|
|
950
|
+
content = content.replace(regex, `$1${newMode}`)
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Update slm.enabled line if present
|
|
954
|
+
// Use a heuristic: find "enabled:" that appears after "slm:"
|
|
955
|
+
const slmIdx = content.indexOf('slm:')
|
|
956
|
+
if (slmIdx !== -1) {
|
|
957
|
+
const afterSlm = content.slice(slmIdx)
|
|
958
|
+
const enabledMatch = afterSlm.match(/^(\s+enabled:\s*)\S+/m)
|
|
959
|
+
if (enabledMatch) {
|
|
960
|
+
const config = loadConfigFile()
|
|
961
|
+
const slmEnabled = config.slm?.enabled ?? false
|
|
962
|
+
const newLine = `${enabledMatch[1]}${slmEnabled}`
|
|
963
|
+
content = content.slice(0, slmIdx) + afterSlm.replace(enabledMatch[0], newLine)
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
writeFileSync(ymlPath, content, 'utf-8')
|
|
968
|
+
} catch {
|
|
969
|
+
// Non-critical — .claudebrainrc.json is the authoritative config
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function formatBytes(bytes: number): string {
|
|
974
|
+
if (bytes === 0) return '0 B'
|
|
975
|
+
const units = ['B', 'KB', 'MB', 'GB']
|
|
976
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
|
977
|
+
const val = bytes / Math.pow(1024, i)
|
|
978
|
+
return `${val.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
|
|
979
|
+
}
|