ethagent 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { atomicWriteText } from '../storage/atomicWrite.js'
|
|
5
|
+
import { ensureConfigDir, getConfigDir } from '../storage/config.js'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LLAMA_HOST = process.env.LLAMACPP_HOST ?? 'http://localhost:8080'
|
|
9
|
+
|
|
10
|
+
export type LlamaCppStatus = {
|
|
11
|
+
binaryPresent: boolean
|
|
12
|
+
binaryPath: string | null
|
|
13
|
+
version: string | null
|
|
14
|
+
serverUp: boolean
|
|
15
|
+
servedModels: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type RunResult = {
|
|
19
|
+
code: number
|
|
20
|
+
stdout: string
|
|
21
|
+
stderr: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type RunInstallResult = { ok: true } | { ok: false; message: string; detail?: string }
|
|
25
|
+
|
|
26
|
+
export type LlamaCppInstallPhase = 'checking' | 'installing' | 'finding' | 'building'
|
|
27
|
+
export type LlamaCppInstallRecovery = 'retry-install' | 'source-build' | 'runner-path' | 'back'
|
|
28
|
+
|
|
29
|
+
export type LlamaCppInstallProgress = {
|
|
30
|
+
phase: LlamaCppInstallPhase
|
|
31
|
+
label: string
|
|
32
|
+
progress: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type LlamaCppInstallResult =
|
|
36
|
+
| { ok: true; serverPath?: string }
|
|
37
|
+
| {
|
|
38
|
+
ok: false
|
|
39
|
+
code: 'install-failed' | 'server-not-found' | 'missing-tools' | 'build-failed'
|
|
40
|
+
message: string
|
|
41
|
+
detail?: string
|
|
42
|
+
recovery: LlamaCppInstallRecovery[]
|
|
43
|
+
candidatePaths?: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type LlamaCppStartFailureCode =
|
|
47
|
+
| 'runner-not-installed'
|
|
48
|
+
| 'model-file-missing'
|
|
49
|
+
| 'different-model-running'
|
|
50
|
+
| 'spawn-failed'
|
|
51
|
+
| 'runner-exited'
|
|
52
|
+
| 'readiness-timeout'
|
|
53
|
+
|
|
54
|
+
export type LlamaCppStartResult =
|
|
55
|
+
| { ok: true; alreadyRunning: boolean }
|
|
56
|
+
| {
|
|
57
|
+
ok: false
|
|
58
|
+
code: LlamaCppStartFailureCode
|
|
59
|
+
message: string
|
|
60
|
+
detail?: string
|
|
61
|
+
servedModels?: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type LlamaCppInstallPlan = {
|
|
65
|
+
command: string
|
|
66
|
+
args: string[]
|
|
67
|
+
label: string
|
|
68
|
+
timeoutMs?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type LlamaCppStartDeps = {
|
|
72
|
+
access?: typeof fs.access
|
|
73
|
+
binaryPath?: string
|
|
74
|
+
spawnImpl?: (command: string, args: readonly string[], options: NonNullable<Parameters<typeof spawn>[2]>) => ReturnType<typeof spawn>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type LocalRunnerConfig = {
|
|
78
|
+
llamaServerPath?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<RunResult | null> {
|
|
82
|
+
return new Promise(resolve => {
|
|
83
|
+
let settled = false
|
|
84
|
+
let child: ReturnType<typeof spawn>
|
|
85
|
+
try {
|
|
86
|
+
child = spawn(cmd, args, { windowsHide: true })
|
|
87
|
+
} catch {
|
|
88
|
+
resolve(null)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let stdout = ''
|
|
93
|
+
let stderr = ''
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
if (settled) return
|
|
96
|
+
settled = true
|
|
97
|
+
try { child.kill() } catch { void 0 }
|
|
98
|
+
resolve(null)
|
|
99
|
+
}, timeoutMs)
|
|
100
|
+
|
|
101
|
+
child.stdout?.on('data', chunk => { stdout += chunk.toString() })
|
|
102
|
+
child.stderr?.on('data', chunk => { stderr += chunk.toString() })
|
|
103
|
+
child.on('error', () => {
|
|
104
|
+
if (settled) return
|
|
105
|
+
settled = true
|
|
106
|
+
clearTimeout(timer)
|
|
107
|
+
resolve(null)
|
|
108
|
+
})
|
|
109
|
+
child.on('close', code => {
|
|
110
|
+
if (settled) return
|
|
111
|
+
settled = true
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
resolve({ code: code ?? -1, stdout, stderr })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function runInstallCommand(
|
|
119
|
+
plan: LlamaCppInstallPlan,
|
|
120
|
+
timeoutMs: number,
|
|
121
|
+
): Promise<RunInstallResult> {
|
|
122
|
+
return new Promise(resolve => {
|
|
123
|
+
let child: ReturnType<typeof spawn>
|
|
124
|
+
try {
|
|
125
|
+
child = spawn(plan.command, plan.args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
126
|
+
} catch (err) {
|
|
127
|
+
resolve({ ok: false, message: (err as Error).message })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let settled = false
|
|
132
|
+
const settle = (result: RunInstallResult): void => {
|
|
133
|
+
if (settled) return
|
|
134
|
+
settled = true
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
try { child.kill() } catch { void 0 }
|
|
137
|
+
resolve(result)
|
|
138
|
+
}
|
|
139
|
+
const timer = setTimeout(() => settle({ ok: false, message: `${plan.label} timed out` }), timeoutMs)
|
|
140
|
+
let output = ''
|
|
141
|
+
const onData = (chunk: Buffer | string): void => { output += chunk.toString() }
|
|
142
|
+
child.stdout?.on('data', onData)
|
|
143
|
+
child.stderr?.on('data', onData)
|
|
144
|
+
child.on('error', err => settle({ ok: false, message: err.message }))
|
|
145
|
+
child.on('close', code => {
|
|
146
|
+
if (code === 0) settle({ ok: true })
|
|
147
|
+
else settle({
|
|
148
|
+
ok: false,
|
|
149
|
+
message: humanInstallError(plan, code),
|
|
150
|
+
detail: installFailureDetail(code, output),
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response | null> {
|
|
157
|
+
const controller = new AbortController()
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
159
|
+
try {
|
|
160
|
+
return await fetch(url, { signal: controller.signal })
|
|
161
|
+
} catch {
|
|
162
|
+
return null
|
|
163
|
+
} finally {
|
|
164
|
+
clearTimeout(timer)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function getLocalRunnerConfigPath(): string {
|
|
169
|
+
return path.join(getConfigDir(), 'local-runner.json')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function loadLocalRunnerConfig(): Promise<LocalRunnerConfig> {
|
|
173
|
+
try {
|
|
174
|
+
const raw = await fs.readFile(getLocalRunnerConfigPath(), 'utf8')
|
|
175
|
+
const parsed = JSON.parse(raw) as unknown
|
|
176
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
177
|
+
const value = (parsed as { llamaServerPath?: unknown }).llamaServerPath
|
|
178
|
+
return typeof value === 'string' && value.trim() ? { llamaServerPath: value.trim() } : {}
|
|
179
|
+
} catch (err: unknown) {
|
|
180
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
181
|
+
return {}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function saveLocalRunnerConfig(config: LocalRunnerConfig): Promise<void> {
|
|
186
|
+
await ensureConfigDir()
|
|
187
|
+
await atomicWriteText(getLocalRunnerConfigPath(), JSON.stringify(config, null, 2) + '\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function setLlamaCppServerPath(serverPath: string): Promise<void> {
|
|
191
|
+
await saveLocalRunnerConfig({ llamaServerPath: serverPath.trim() })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function detectLlamaCppServerBinary(extraCandidates: string[] = []): Promise<{ path: string | null; version: string | null }> {
|
|
195
|
+
const config = await loadLocalRunnerConfig()
|
|
196
|
+
const candidates = [
|
|
197
|
+
...llamaCppServerCandidates(process.env, process.platform, config.llamaServerPath),
|
|
198
|
+
...extraCandidates,
|
|
199
|
+
]
|
|
200
|
+
for (const candidate of candidates) {
|
|
201
|
+
const result = await runCommand(candidate, ['--version'])
|
|
202
|
+
if (!result) continue
|
|
203
|
+
const output = `${result.stdout}\n${result.stderr}`.trim()
|
|
204
|
+
if (result.code === 0 || output.length > 0) {
|
|
205
|
+
return { path: candidate, version: firstLine(output) || 'installed' }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { path: null, version: null }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function llamaCppServerCandidates(
|
|
212
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
213
|
+
platform: NodeJS.Platform = process.platform,
|
|
214
|
+
configuredPath?: string,
|
|
215
|
+
): string[] {
|
|
216
|
+
const candidates: string[] = []
|
|
217
|
+
appendCandidate(candidates, configuredPath)
|
|
218
|
+
appendCandidate(candidates, env.LLAMA_SERVER_PATH)
|
|
219
|
+
appendCandidate(candidates, env.LLAMACPP_SERVER_PATH)
|
|
220
|
+
appendCandidate(candidates, 'llama-server')
|
|
221
|
+
appendCandidate(candidates, 'llama-server.exe')
|
|
222
|
+
|
|
223
|
+
if (platform === 'win32') {
|
|
224
|
+
appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp', 'llama-server.exe') : undefined)
|
|
225
|
+
appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp', 'llama-server.exe') : undefined)
|
|
226
|
+
appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'llama-server.exe') : undefined)
|
|
227
|
+
appendCandidate(candidates, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp', 'llama-server.exe') : undefined)
|
|
228
|
+
appendCandidate(candidates, env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'llama.cpp', 'llama-server.exe') : undefined)
|
|
229
|
+
appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims', 'llama-server.exe') : undefined)
|
|
230
|
+
appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp', 'current', 'llama-server.exe') : undefined)
|
|
231
|
+
} else if (platform === 'darwin') {
|
|
232
|
+
appendCandidate(candidates, '/opt/homebrew/bin/llama-server')
|
|
233
|
+
appendCandidate(candidates, '/usr/local/bin/llama-server')
|
|
234
|
+
appendCandidate(candidates, '/opt/local/bin/llama-server')
|
|
235
|
+
appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
|
|
236
|
+
appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
|
|
237
|
+
} else {
|
|
238
|
+
appendCandidate(candidates, '/usr/local/bin/llama-server')
|
|
239
|
+
appendCandidate(candidates, '/usr/bin/llama-server')
|
|
240
|
+
appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
|
|
241
|
+
appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return candidates
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function llamaCppInstallPlans(platform: NodeJS.Platform = process.platform): LlamaCppInstallPlan[] {
|
|
248
|
+
if (platform === 'win32') {
|
|
249
|
+
return [
|
|
250
|
+
{
|
|
251
|
+
label: 'winget llama.cpp',
|
|
252
|
+
command: 'winget',
|
|
253
|
+
args: ['install', 'llama.cpp', '--accept-source-agreements', '--accept-package-agreements'],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
label: 'winget llama.cpp exact id',
|
|
257
|
+
command: 'winget',
|
|
258
|
+
args: ['install', '--id', 'ggml.llamacpp', '-e', '--accept-source-agreements', '--accept-package-agreements'],
|
|
259
|
+
},
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
if (platform === 'darwin') {
|
|
263
|
+
return [
|
|
264
|
+
{ label: 'brew llama.cpp', command: 'brew', args: ['install', 'llama.cpp'] },
|
|
265
|
+
{ label: 'nix llama.cpp', command: 'nix', args: ['profile', 'install', 'nixpkgs#llama-cpp'] },
|
|
266
|
+
{ label: 'macports llama.cpp', command: 'port', args: ['install', 'llama.cpp'] },
|
|
267
|
+
]
|
|
268
|
+
}
|
|
269
|
+
return [
|
|
270
|
+
{ label: 'brew llama.cpp', command: 'brew', args: ['install', 'llama.cpp'] },
|
|
271
|
+
{ label: 'nix llama.cpp', command: 'nix', args: ['profile', 'install', 'nixpkgs#llama-cpp'] },
|
|
272
|
+
]
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function installLlamaCppRunner(
|
|
276
|
+
onProgress?: (progress: LlamaCppInstallProgress) => void,
|
|
277
|
+
platform: NodeJS.Platform = process.platform,
|
|
278
|
+
): Promise<LlamaCppInstallResult> {
|
|
279
|
+
const plans = llamaCppInstallPlans(platform)
|
|
280
|
+
const failures: string[] = []
|
|
281
|
+
onProgress?.({ phase: 'checking', label: 'checking local runner installers', progress: 0.08 })
|
|
282
|
+
for (const plan of plans) {
|
|
283
|
+
onProgress?.({ phase: 'installing', label: installerProgressLabel(plan), progress: 0.34 })
|
|
284
|
+
const result = await runInstallCommand(plan, plan.timeoutMs ?? 10 * 60_000)
|
|
285
|
+
if (result.ok) {
|
|
286
|
+
onProgress?.({ phase: 'finding', label: 'finding llama-server', progress: 0.78 })
|
|
287
|
+
const binary = await findAndPersistLlamaCppServer(platform)
|
|
288
|
+
if (binary.path) return { ok: true, serverPath: binary.path }
|
|
289
|
+
const cliPaths = await discoverLlamaCppCliPaths(process.env, platform)
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
code: 'server-not-found',
|
|
293
|
+
message: 'llama.cpp installed, but the local server was not found.',
|
|
294
|
+
detail: cliPaths.length > 0
|
|
295
|
+
? `Found llama-cli, but ethagent needs llama-server to run local chat.\n${cliPaths.slice(0, 3).join('\n')}`
|
|
296
|
+
: 'The package manager finished, but it did not expose llama-server on this machine.',
|
|
297
|
+
recovery: ['source-build', 'runner-path', 'retry-install', 'back'],
|
|
298
|
+
candidatePaths: await discoverLlamaCppServerPaths(process.env, platform),
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
failures.push(formatInstallFailure(plan.label, result))
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
code: 'install-failed',
|
|
306
|
+
message: failures.length > 0
|
|
307
|
+
? 'ethagent could not install the local runner automatically.'
|
|
308
|
+
: 'no supported local runner installer was found for this platform.',
|
|
309
|
+
detail: failures.join('\n'),
|
|
310
|
+
recovery: ['retry-install', 'source-build', 'runner-path', 'back'],
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function buildLlamaCppRunner(
|
|
315
|
+
onProgress?: (progress: LlamaCppInstallProgress) => void,
|
|
316
|
+
platform: NodeJS.Platform = process.platform,
|
|
317
|
+
): Promise<LlamaCppInstallResult> {
|
|
318
|
+
return installLlamaCppFromSource(onProgress, platform)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function isLlamaCppServerUp(host: string = DEFAULT_LLAMA_HOST, timeoutMs = 800): Promise<boolean> {
|
|
322
|
+
const response = await fetchServedModels(host, timeoutMs)
|
|
323
|
+
return response.up
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export async function listServedModels(host: string = DEFAULT_LLAMA_HOST): Promise<string[]> {
|
|
327
|
+
const response = await fetchServedModels(host, 1500)
|
|
328
|
+
return response.models
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function fetchServedModels(host: string = DEFAULT_LLAMA_HOST, timeoutMs = 1500): Promise<{ up: boolean; models: string[] }> {
|
|
332
|
+
const response = await fetchWithTimeout(`${host.replace(/\/+$/, '')}/v1/models`, timeoutMs)
|
|
333
|
+
if (!response || !response.ok) return { up: false, models: [] }
|
|
334
|
+
try {
|
|
335
|
+
const data = await response.json() as { data?: Array<{ id?: unknown }> }
|
|
336
|
+
const models = (data.data ?? [])
|
|
337
|
+
.map(item => typeof item.id === 'string' ? item.id : '')
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
return { up: true, models }
|
|
340
|
+
} catch {
|
|
341
|
+
return { up: true, models: [] }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function detectLlamaCpp(host: string = DEFAULT_LLAMA_HOST): Promise<LlamaCppStatus> {
|
|
346
|
+
const [binary, serverUp] = await Promise.all([
|
|
347
|
+
detectLlamaCppServerBinary(),
|
|
348
|
+
isLlamaCppServerUp(host),
|
|
349
|
+
])
|
|
350
|
+
const servedModels = serverUp ? await listServedModels(host) : []
|
|
351
|
+
return {
|
|
352
|
+
binaryPresent: binary.path !== null,
|
|
353
|
+
binaryPath: binary.path,
|
|
354
|
+
version: binary.version,
|
|
355
|
+
serverUp,
|
|
356
|
+
servedModels,
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export async function startLlamaCppServer(args: {
|
|
361
|
+
modelPath: string
|
|
362
|
+
modelAlias: string
|
|
363
|
+
host?: string
|
|
364
|
+
ctxSize?: number
|
|
365
|
+
readinessTimeoutMs?: number
|
|
366
|
+
pollMs?: number
|
|
367
|
+
deps?: LlamaCppStartDeps
|
|
368
|
+
}): Promise<LlamaCppStartResult> {
|
|
369
|
+
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
370
|
+
const initialStatus = await servedModelStatus(host, args.modelAlias)
|
|
371
|
+
if (initialStatus.state === 'ready') return { ok: true, alreadyRunning: true }
|
|
372
|
+
if (initialStatus.state === 'different') {
|
|
373
|
+
return startFailure('different-model-running', {
|
|
374
|
+
servedModels: initialStatus.models,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
await (args.deps?.access ?? fs.access)(args.modelPath)
|
|
380
|
+
} catch {
|
|
381
|
+
return startFailure('model-file-missing', { detail: args.modelPath })
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const binaryPath = args.deps?.binaryPath ?? (await findAndPersistLlamaCppServer()).path
|
|
385
|
+
if (!binaryPath) {
|
|
386
|
+
return startFailure('runner-not-installed')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const url = new URL(host)
|
|
390
|
+
const listenHost = url.hostname || '127.0.0.1'
|
|
391
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '8080')
|
|
392
|
+
const spawnImpl = args.deps?.spawnImpl ?? spawn
|
|
393
|
+
let child: ReturnType<typeof spawn>
|
|
394
|
+
try {
|
|
395
|
+
child = spawnImpl(binaryPath, [
|
|
396
|
+
'-m',
|
|
397
|
+
args.modelPath,
|
|
398
|
+
'--host',
|
|
399
|
+
listenHost,
|
|
400
|
+
'--port',
|
|
401
|
+
port,
|
|
402
|
+
'--alias',
|
|
403
|
+
args.modelAlias,
|
|
404
|
+
'--ctx-size',
|
|
405
|
+
String(args.ctxSize ?? 32768),
|
|
406
|
+
'--jinja',
|
|
407
|
+
], {
|
|
408
|
+
detached: true,
|
|
409
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
410
|
+
windowsHide: true,
|
|
411
|
+
})
|
|
412
|
+
} catch (err) {
|
|
413
|
+
return startFailure('spawn-failed', { detail: (err as Error).message })
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const capture = createStartupCapture(child)
|
|
417
|
+
let childFailure: LlamaCppStartResult | null = null
|
|
418
|
+
child.on('error', err => {
|
|
419
|
+
childFailure = startFailure('spawn-failed', { detail: startupDetail(capture(), err.message) })
|
|
420
|
+
})
|
|
421
|
+
child.on('exit', (code, signal) => {
|
|
422
|
+
childFailure ??= startFailure('runner-exited', {
|
|
423
|
+
detail: startupDetail(capture(), `exit ${code ?? 'unknown'}${signal ? ` signal ${signal}` : ''}`),
|
|
424
|
+
})
|
|
425
|
+
})
|
|
426
|
+
child.unref()
|
|
427
|
+
|
|
428
|
+
const ready = await waitForServedModel({
|
|
429
|
+
host,
|
|
430
|
+
modelAlias: args.modelAlias,
|
|
431
|
+
timeoutMs: args.readinessTimeoutMs ?? 90_000,
|
|
432
|
+
pollMs: args.pollMs ?? 500,
|
|
433
|
+
childFailure: () => childFailure,
|
|
434
|
+
})
|
|
435
|
+
if (ready.ok) return { ok: true, alreadyRunning: false }
|
|
436
|
+
if (ready.code === 'readiness-timeout') {
|
|
437
|
+
return startFailure('readiness-timeout', { detail: capture() })
|
|
438
|
+
}
|
|
439
|
+
return ready
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function waitForServedModel(args: {
|
|
443
|
+
host: string
|
|
444
|
+
modelAlias: string
|
|
445
|
+
timeoutMs: number
|
|
446
|
+
pollMs: number
|
|
447
|
+
childFailure: () => LlamaCppStartResult | null
|
|
448
|
+
}): Promise<{ ok: true } | Extract<LlamaCppStartResult, { ok: false }>> {
|
|
449
|
+
const deadline = Date.now() + args.timeoutMs
|
|
450
|
+
while (Date.now() < deadline) {
|
|
451
|
+
const status = await servedModelStatus(args.host, args.modelAlias)
|
|
452
|
+
if (status.state === 'ready') return { ok: true }
|
|
453
|
+
if (status.state === 'different') return startFailure('different-model-running', { servedModels: status.models })
|
|
454
|
+
const failure = args.childFailure()
|
|
455
|
+
if (failure && !failure.ok) return failure
|
|
456
|
+
await new Promise<void>(resolve => setTimeout(resolve, args.pollMs))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
for (let i = 0; i < 3; i++) {
|
|
460
|
+
const status = await servedModelStatus(args.host, args.modelAlias)
|
|
461
|
+
if (status.state === 'ready') return { ok: true }
|
|
462
|
+
if (status.state === 'different') return startFailure('different-model-running', { servedModels: status.models })
|
|
463
|
+
const failure = args.childFailure()
|
|
464
|
+
if (failure && !failure.ok) return failure
|
|
465
|
+
await new Promise<void>(resolve => setTimeout(resolve, args.pollMs))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return startFailure('readiness-timeout')
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function servedModelStatus(host: string, modelAlias: string): Promise<
|
|
472
|
+
| { state: 'not-up'; models: string[] }
|
|
473
|
+
| { state: 'ready'; models: string[] }
|
|
474
|
+
| { state: 'different'; models: string[] }
|
|
475
|
+
> {
|
|
476
|
+
const { up, models } = await fetchServedModels(host, 1500)
|
|
477
|
+
if (!up) return { state: 'not-up', models }
|
|
478
|
+
if (models.length === 0 || models.includes(modelAlias)) return { state: 'ready', models }
|
|
479
|
+
return { state: 'different', models }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function startFailure(
|
|
483
|
+
code: LlamaCppStartFailureCode,
|
|
484
|
+
options: { detail?: string; servedModels?: string[] } = {},
|
|
485
|
+
): Extract<LlamaCppStartResult, { ok: false }> {
|
|
486
|
+
const servedModels = options.servedModels?.filter(Boolean) ?? []
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
code,
|
|
490
|
+
message: startFailureMessage(code, servedModels, options.detail),
|
|
491
|
+
detail: options.detail || undefined,
|
|
492
|
+
servedModels: servedModels.length > 0 ? servedModels : undefined,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: string[], detail?: string): string {
|
|
497
|
+
switch (code) {
|
|
498
|
+
case 'runner-not-installed':
|
|
499
|
+
return 'local model runner is not installed yet'
|
|
500
|
+
case 'model-file-missing':
|
|
501
|
+
return detail ? `model file not found: ${detail}` : 'model file was not found'
|
|
502
|
+
case 'different-model-running':
|
|
503
|
+
return `a different local model is already running (${servedModels.join(', ')}); stop it before switching models`
|
|
504
|
+
case 'spawn-failed':
|
|
505
|
+
return 'local runner could not be started'
|
|
506
|
+
case 'runner-exited':
|
|
507
|
+
return 'local runner closed before becoming ready'
|
|
508
|
+
case 'readiness-timeout':
|
|
509
|
+
return 'local runner is still loading or did not answer in time'
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function createStartupCapture(child: ReturnType<typeof spawn>): () => string {
|
|
514
|
+
let output = ''
|
|
515
|
+
const capture = (chunk: Buffer | string): void => {
|
|
516
|
+
output = `${output}${chunk.toString()}`.slice(-4000)
|
|
517
|
+
}
|
|
518
|
+
child.stdout?.on('data', capture)
|
|
519
|
+
child.stderr?.on('data', capture)
|
|
520
|
+
return () => summarizeInstallOutput(output) ?? ''
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function startupDetail(output: string, fallback: string): string {
|
|
524
|
+
return output ? `${fallback}\n${output}` : fallback
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function firstLine(text: string): string {
|
|
528
|
+
return text.split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? ''
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function appendCandidate(candidates: string[], candidate: string | undefined): void {
|
|
532
|
+
if (!candidate || candidates.includes(candidate)) return
|
|
533
|
+
candidates.push(candidate)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function llamaCppSearchRoots(
|
|
537
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
538
|
+
platform: NodeJS.Platform = process.platform,
|
|
539
|
+
): string[] {
|
|
540
|
+
const roots: string[] = []
|
|
541
|
+
if (platform === 'win32') {
|
|
542
|
+
appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Packages') : undefined)
|
|
543
|
+
appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : undefined)
|
|
544
|
+
appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp') : undefined)
|
|
545
|
+
appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp') : undefined)
|
|
546
|
+
appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp') : undefined)
|
|
547
|
+
appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'WindowsApps') : undefined)
|
|
548
|
+
appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp') : undefined)
|
|
549
|
+
appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims') : undefined)
|
|
550
|
+
appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
|
|
551
|
+
appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
|
|
552
|
+
return roots
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
appendCandidate(roots, '/opt/homebrew/bin')
|
|
556
|
+
appendCandidate(roots, '/usr/local/bin')
|
|
557
|
+
appendCandidate(roots, '/opt/local/bin')
|
|
558
|
+
appendCandidate(roots, '/usr/bin')
|
|
559
|
+
appendCandidate(roots, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin') : undefined)
|
|
560
|
+
appendCandidate(roots, env.HOME ? path.join(env.HOME, '.local', 'bin') : undefined)
|
|
561
|
+
appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
|
|
562
|
+
appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
|
|
563
|
+
return roots
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export async function discoverLlamaCppServerPaths(
|
|
567
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
568
|
+
platform: NodeJS.Platform = process.platform,
|
|
569
|
+
): Promise<string[]> {
|
|
570
|
+
return discoverExecutablePaths(platform === 'win32' ? ['llama-server.exe', 'llama-server'] : ['llama-server'], env, platform)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function discoverLlamaCppCliPaths(
|
|
574
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
575
|
+
platform: NodeJS.Platform = process.platform,
|
|
576
|
+
): Promise<string[]> {
|
|
577
|
+
return discoverExecutablePaths(platform === 'win32' ? ['llama-cli.exe', 'llama-cli'] : ['llama-cli'], env, platform)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function discoverExecutablePaths(
|
|
581
|
+
names: string[],
|
|
582
|
+
env: NodeJS.ProcessEnv,
|
|
583
|
+
platform: NodeJS.Platform,
|
|
584
|
+
): Promise<string[]> {
|
|
585
|
+
const found: string[] = []
|
|
586
|
+
const lowered = new Set(names.map(name => name.toLowerCase()))
|
|
587
|
+
for (const root of llamaCppSearchRoots(env, platform)) {
|
|
588
|
+
await walkForExecutable(root, lowered, found, 0, 5)
|
|
589
|
+
if (found.length >= 20) break
|
|
590
|
+
}
|
|
591
|
+
return found
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function walkForExecutable(
|
|
595
|
+
dir: string,
|
|
596
|
+
names: Set<string>,
|
|
597
|
+
found: string[],
|
|
598
|
+
depth: number,
|
|
599
|
+
maxDepth: number,
|
|
600
|
+
): Promise<void> {
|
|
601
|
+
if (depth > maxDepth || found.length >= 20) return
|
|
602
|
+
let entries: Array<import('node:fs').Dirent>
|
|
603
|
+
try {
|
|
604
|
+
entries = await fs.readdir(dir, { withFileTypes: true })
|
|
605
|
+
} catch {
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
for (const entry of entries) {
|
|
610
|
+
if (found.length >= 20) return
|
|
611
|
+
const fullPath = path.join(dir, entry.name)
|
|
612
|
+
const lowerName = entry.name.toLowerCase()
|
|
613
|
+
if ((entry.isFile() || entry.isSymbolicLink()) && names.has(lowerName)) {
|
|
614
|
+
appendCandidate(found, fullPath)
|
|
615
|
+
continue
|
|
616
|
+
}
|
|
617
|
+
if (entry.isDirectory() && shouldDescendRunnerDir(entry.name, depth)) {
|
|
618
|
+
await walkForExecutable(fullPath, names, found, depth + 1, maxDepth)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function shouldDescendRunnerDir(name: string, depth: number): boolean {
|
|
624
|
+
const lower = name.toLowerCase()
|
|
625
|
+
if (/(llama|ggml|bin|build|release|debug|current|package|windowsapps|x64|arm64)/.test(lower)) return true
|
|
626
|
+
return depth > 0 && lower.length <= 24
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function findAndPersistLlamaCppServer(
|
|
630
|
+
platform: NodeJS.Platform = process.platform,
|
|
631
|
+
): Promise<{ path: string | null; version: string | null }> {
|
|
632
|
+
const direct = await detectLlamaCppServerBinary()
|
|
633
|
+
if (direct.path) return direct
|
|
634
|
+
const discovered = await discoverLlamaCppServerPaths(process.env, platform)
|
|
635
|
+
const found = await detectLlamaCppServerBinary(discovered)
|
|
636
|
+
if (found.path) {
|
|
637
|
+
await setLlamaCppServerPath(found.path).catch(() => {})
|
|
638
|
+
}
|
|
639
|
+
return found
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export function summarizeInstallOutput(output: string): string | undefined {
|
|
643
|
+
const lines = output
|
|
644
|
+
.split(/\r?\n/)
|
|
645
|
+
.map(cleanInstallLine)
|
|
646
|
+
.filter(Boolean)
|
|
647
|
+
.filter(line => !/^[\-\\|/_.=\s]+$/.test(line))
|
|
648
|
+
.filter(line => !/^\d+(\.\d+)?\s*(B|KB|MB|GB)\s*\/\s*\d+/i.test(line))
|
|
649
|
+
const unique = [...new Set(lines)]
|
|
650
|
+
return unique.slice(-6).join('\n') || undefined
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function humanInstallError(plan: LlamaCppInstallPlan, code: number | null): string {
|
|
654
|
+
if (plan.command === 'winget') return 'Windows could not install the local runner automatically.'
|
|
655
|
+
if (plan.command === 'brew') return 'Homebrew could not install the local runner automatically.'
|
|
656
|
+
if (plan.command === 'nix') return 'Nix could not install the local runner automatically.'
|
|
657
|
+
if (plan.command === 'port') return 'MacPorts could not install the local runner automatically.'
|
|
658
|
+
if (plan.command === 'git') return 'ethagent could not download the local runner source.'
|
|
659
|
+
if (plan.command === 'cmake') return 'ethagent could not build the local runner.'
|
|
660
|
+
return code === null
|
|
661
|
+
? `${plan.label} did not complete.`
|
|
662
|
+
: `${plan.label} failed with exit code ${code}.`
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function installFailureDetail(code: number | null, output: string): string | undefined {
|
|
666
|
+
const details = [
|
|
667
|
+
code === null ? undefined : `exit code ${code}`,
|
|
668
|
+
summarizeInstallOutput(output),
|
|
669
|
+
].filter((item): item is string => Boolean(item))
|
|
670
|
+
return details.join('\n') || undefined
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function cleanInstallLine(line: string): string {
|
|
674
|
+
return line
|
|
675
|
+
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
|
|
676
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
677
|
+
.replace(/\s+/g, ' ')
|
|
678
|
+
.trim()
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function installerProgressLabel(plan: LlamaCppInstallPlan): string {
|
|
682
|
+
if (plan.command === 'winget') return 'installing with Windows package manager'
|
|
683
|
+
if (plan.command === 'brew') return 'installing with Homebrew'
|
|
684
|
+
if (plan.command === 'nix') return 'installing with Nix'
|
|
685
|
+
if (plan.command === 'port') return 'installing with MacPorts'
|
|
686
|
+
return `installing with ${plan.label}`
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function formatInstallFailure(label: string, result: RunInstallResult): string {
|
|
690
|
+
if (result.ok) return label
|
|
691
|
+
return [label, result.message, result.detail].filter(Boolean).join(': ')
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function buildFailure(result: RunInstallResult): LlamaCppInstallResult {
|
|
695
|
+
return {
|
|
696
|
+
ok: false,
|
|
697
|
+
code: 'build-failed',
|
|
698
|
+
message: 'ethagent could not build the local runner.',
|
|
699
|
+
detail: result.ok ? undefined : [result.message, result.detail].filter(Boolean).join('\n'),
|
|
700
|
+
recovery: ['runner-path', 'retry-install', 'back'],
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sourceBuildServerCandidates(buildDir: string, platform: NodeJS.Platform): string[] {
|
|
705
|
+
const exe = platform === 'win32' ? 'llama-server.exe' : 'llama-server'
|
|
706
|
+
return [
|
|
707
|
+
path.join(buildDir, 'bin', exe),
|
|
708
|
+
path.join(buildDir, 'bin', 'Release', exe),
|
|
709
|
+
path.join(buildDir, 'bin', 'Debug', exe),
|
|
710
|
+
path.join(buildDir, 'Release', exe),
|
|
711
|
+
path.join(buildDir, 'Debug', exe),
|
|
712
|
+
]
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function firstAccessible(candidates: string[]): Promise<string | null> {
|
|
716
|
+
for (const candidate of candidates) {
|
|
717
|
+
try {
|
|
718
|
+
await fs.access(candidate)
|
|
719
|
+
return candidate
|
|
720
|
+
} catch {
|
|
721
|
+
continue
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function installLlamaCppFromSource(
|
|
728
|
+
onProgress?: (progress: LlamaCppInstallProgress) => void,
|
|
729
|
+
platform: NodeJS.Platform = process.platform,
|
|
730
|
+
): Promise<LlamaCppInstallResult> {
|
|
731
|
+
const root = path.join(getConfigDir(), 'runners')
|
|
732
|
+
const repoDir = path.join(root, 'llama.cpp')
|
|
733
|
+
const buildDir = path.join(repoDir, 'build')
|
|
734
|
+
const serverPath = path.join(buildDir, 'bin', platform === 'win32' ? 'llama-server.exe' : 'llama-server')
|
|
735
|
+
await ensureConfigDir()
|
|
736
|
+
await fs.mkdir(root, { recursive: true })
|
|
737
|
+
|
|
738
|
+
onProgress?.({ phase: 'checking', label: 'checking build tools', progress: 0.08 })
|
|
739
|
+
const hasGit = await runCommand('git', ['--version'])
|
|
740
|
+
if (!hasGit || hasGit.code !== 0) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
code: 'missing-tools',
|
|
744
|
+
message: 'git is required to build the local runner.',
|
|
745
|
+
recovery: ['runner-path', 'retry-install', 'back'],
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const hasCmake = await runCommand('cmake', ['--version'])
|
|
749
|
+
if (!hasCmake || hasCmake.code !== 0) {
|
|
750
|
+
return {
|
|
751
|
+
ok: false,
|
|
752
|
+
code: 'missing-tools',
|
|
753
|
+
message: 'cmake is required to build the local runner.',
|
|
754
|
+
recovery: ['runner-path', 'retry-install', 'back'],
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
await fs.access(path.join(repoDir, '.git'))
|
|
760
|
+
onProgress?.({ phase: 'building', label: 'updating local runner source', progress: 0.22 })
|
|
761
|
+
const update = await runInstallCommand(
|
|
762
|
+
{ label: 'update llama.cpp source', command: 'git', args: ['-C', repoDir, 'pull', '--ff-only'], timeoutMs: 5 * 60_000 },
|
|
763
|
+
5 * 60_000,
|
|
764
|
+
)
|
|
765
|
+
if (!update.ok) return buildFailure(update)
|
|
766
|
+
} catch {
|
|
767
|
+
onProgress?.({ phase: 'building', label: 'downloading local runner source', progress: 0.22 })
|
|
768
|
+
const clone = await runInstallCommand(
|
|
769
|
+
{ label: 'clone llama.cpp source', command: 'git', args: ['clone', '--depth', '1', 'https://github.com/ggml-org/llama.cpp.git', repoDir], timeoutMs: 10 * 60_000 },
|
|
770
|
+
10 * 60_000,
|
|
771
|
+
)
|
|
772
|
+
if (!clone.ok) return buildFailure(clone)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
onProgress?.({ phase: 'building', label: 'configuring local runner', progress: 0.48 })
|
|
776
|
+
const configure = await runInstallCommand(
|
|
777
|
+
{ label: 'configure llama.cpp', command: 'cmake', args: ['-S', repoDir, '-B', buildDir, '-DCMAKE_BUILD_TYPE=Release'], timeoutMs: 5 * 60_000 },
|
|
778
|
+
5 * 60_000,
|
|
779
|
+
)
|
|
780
|
+
if (!configure.ok) return buildFailure(configure)
|
|
781
|
+
|
|
782
|
+
onProgress?.({ phase: 'building', label: 'building local runner', progress: 0.68 })
|
|
783
|
+
const build = await runInstallCommand(
|
|
784
|
+
{
|
|
785
|
+
label: 'build llama-server',
|
|
786
|
+
command: 'cmake',
|
|
787
|
+
args: ['--build', buildDir, '--config', 'Release', '--target', 'llama-server', '-j', String(Math.max(1, os.cpus().length - 1))],
|
|
788
|
+
timeoutMs: 30 * 60_000,
|
|
789
|
+
},
|
|
790
|
+
30 * 60_000,
|
|
791
|
+
)
|
|
792
|
+
if (!build.ok) return buildFailure(build)
|
|
793
|
+
|
|
794
|
+
const builtServerPath = await firstAccessible(sourceBuildServerCandidates(buildDir, platform))
|
|
795
|
+
?? (await discoverLlamaCppServerPaths(process.env, platform))[0]
|
|
796
|
+
if (builtServerPath) {
|
|
797
|
+
await setLlamaCppServerPath(builtServerPath)
|
|
798
|
+
onProgress?.({ phase: 'finding', label: 'local runner ready', progress: 1 })
|
|
799
|
+
return { ok: true, serverPath: builtServerPath }
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
ok: false,
|
|
804
|
+
code: 'server-not-found',
|
|
805
|
+
message: 'built the local runner, but llama-server was not found.',
|
|
806
|
+
detail: serverPath,
|
|
807
|
+
recovery: ['runner-path', 'retry-install', 'back'],
|
|
808
|
+
candidatePaths: sourceBuildServerCandidates(buildDir, platform),
|
|
809
|
+
}
|
|
810
|
+
}
|