ethagent 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +32 -117
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/cli/main.tsx +7 -0
  23. package/src/identity/continuity/challenges.ts +123 -0
  24. package/src/identity/continuity/envelope.ts +49 -1484
  25. package/src/identity/continuity/envelopeCreate.ts +322 -0
  26. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  27. package/src/identity/continuity/envelopeParse.ts +441 -0
  28. package/src/identity/continuity/envelopeTypes.ts +204 -0
  29. package/src/identity/continuity/envelopeVersion.ts +1 -0
  30. package/src/identity/continuity/payloadNormalization.ts +183 -0
  31. package/src/identity/continuity/publicSkills.ts +5 -5
  32. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  33. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  34. package/src/identity/continuity/skillsNormalization.ts +119 -0
  35. package/src/identity/continuity/snapshotToken.ts +28 -0
  36. package/src/identity/hub/continuity/completion.ts +67 -0
  37. package/src/identity/hub/continuity/effects.ts +5 -62
  38. package/src/identity/hub/profile/effects.ts +6 -170
  39. package/src/identity/hub/profile/operatorSave.ts +202 -0
  40. package/src/identity/registry/erc8004/metadata.ts +31 -23
  41. package/src/identity/wallet/browserWallet/html.ts +1 -57
  42. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  43. package/src/identity/wallet/page/controller.ts +1 -1
  44. package/src/identity/wallet/page/errorView.ts +122 -0
  45. package/src/identity/wallet/page/view.ts +3 -114
  46. package/src/mcp/manager.ts +8 -66
  47. package/src/mcp/managerHelpers.ts +70 -0
  48. package/src/models/ModelPicker.tsx +69 -889
  49. package/src/models/huggingface.ts +20 -137
  50. package/src/models/huggingfaceStorage.ts +136 -0
  51. package/src/models/llamacpp.ts +37 -303
  52. package/src/models/llamacppCommands.ts +44 -0
  53. package/src/models/llamacppConfig.ts +34 -0
  54. package/src/models/llamacppDiscovery.ts +176 -0
  55. package/src/models/llamacppOutput.ts +65 -0
  56. package/src/models/modelPickerCatalogFlow.ts +56 -0
  57. package/src/models/modelPickerCredentials.ts +166 -0
  58. package/src/models/modelPickerData.ts +41 -0
  59. package/src/models/modelPickerDisplay.tsx +132 -0
  60. package/src/models/modelPickerHfFlow.ts +192 -0
  61. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  62. package/src/models/modelPickerTypes.ts +69 -0
  63. package/src/models/modelPickerUninstallFlow.ts +48 -0
  64. package/src/models/modelPickerViewHelpers.ts +174 -0
  65. package/src/providers/openai-chat.ts +5 -124
  66. package/src/providers/openaiChatWire.ts +124 -0
  67. package/src/runtime/providerTurn.ts +38 -0
  68. package/src/runtime/textToolParser.ts +161 -0
  69. package/src/runtime/toolIntent.ts +1 -1
  70. package/src/runtime/turn.ts +43 -499
  71. package/src/runtime/turnNudges.ts +223 -0
  72. package/src/runtime/turnTypes.ts +86 -0
  73. package/src/ui/terminalTitle.ts +30 -0
@@ -4,6 +4,43 @@ import path from 'node:path'
4
4
  import { atomicWriteText } from '../storage/atomicWrite.js'
5
5
  import { ensureConfigDir, getConfigDir } from '../storage/config.js'
6
6
  import os from 'node:os'
7
+ import {
8
+ buildFailure,
9
+ formatInstallFailure,
10
+ humanInstallError,
11
+ installFailureDetail,
12
+ installerProgressLabel,
13
+ summarizeInstallOutput,
14
+ } from './llamacppOutput.js'
15
+ import {
16
+ getLocalRunnerConfigPath,
17
+ loadLocalRunnerConfig,
18
+ saveLocalRunnerConfig,
19
+ setLlamaCppServerPath,
20
+ type LocalRunnerConfig,
21
+ } from './llamacppConfig.js'
22
+ import { runCommand } from './llamacppCommands.js'
23
+ import {
24
+ detectLlamaCppServerBinary,
25
+ discoverLlamaCppCliPaths,
26
+ discoverLlamaCppServerPaths,
27
+ findAndPersistLlamaCppServer,
28
+ } from './llamacppDiscovery.js'
29
+
30
+ export { humanInstallError, summarizeInstallOutput } from './llamacppOutput.js'
31
+ export {
32
+ getLocalRunnerConfigPath,
33
+ loadLocalRunnerConfig,
34
+ saveLocalRunnerConfig,
35
+ setLlamaCppServerPath,
36
+ } from './llamacppConfig.js'
37
+ export {
38
+ detectLlamaCppServerBinary,
39
+ discoverLlamaCppServerPaths,
40
+ llamaCppSearchRoots,
41
+ llamaCppServerCandidates,
42
+ } from './llamacppDiscovery.js'
43
+ export type { LocalRunnerConfig } from './llamacppConfig.js'
7
44
 
8
45
  export const DEFAULT_LLAMA_HOST = process.env.LLAMACPP_HOST ?? 'http://localhost:8080'
9
46
 
@@ -15,12 +52,6 @@ export type LlamaCppStatus = {
15
52
  servedModels: string[]
16
53
  }
17
54
 
18
- type RunResult = {
19
- code: number
20
- stdout: string
21
- stderr: string
22
- }
23
-
24
55
  type RunInstallResult = { ok: true } | { ok: false; message: string; detail?: string }
25
56
 
26
57
  export type LlamaCppInstallPhase = 'checking' | 'installing' | 'finding' | 'building'
@@ -77,47 +108,6 @@ type LlamaCppStartDeps = {
77
108
  rogueDrainPollMs?: number
78
109
  }
79
110
 
80
- export type LocalRunnerConfig = {
81
- llamaServerPath?: string
82
- }
83
-
84
- function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<RunResult | null> {
85
- return new Promise(resolve => {
86
- let settled = false
87
- let child: ReturnType<typeof spawn>
88
- try {
89
- child = spawn(cmd, args, { windowsHide: true })
90
- } catch {
91
- resolve(null)
92
- return
93
- }
94
-
95
- let stdout = ''
96
- let stderr = ''
97
- const timer = setTimeout(() => {
98
- if (settled) return
99
- settled = true
100
- try { child.kill() } catch { void 0 }
101
- resolve(null)
102
- }, timeoutMs)
103
-
104
- child.stdout?.on('data', chunk => { stdout += chunk.toString() })
105
- child.stderr?.on('data', chunk => { stderr += chunk.toString() })
106
- child.on('error', () => {
107
- if (settled) return
108
- settled = true
109
- clearTimeout(timer)
110
- resolve(null)
111
- })
112
- child.on('close', code => {
113
- if (settled) return
114
- settled = true
115
- clearTimeout(timer)
116
- resolve({ code: code ?? -1, stdout, stderr })
117
- })
118
- })
119
- }
120
-
121
111
  function runInstallCommand(
122
112
  plan: LlamaCppInstallPlan,
123
113
  timeoutMs: number,
@@ -168,85 +158,6 @@ async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Respons
168
158
  }
169
159
  }
170
160
 
171
- export function getLocalRunnerConfigPath(): string {
172
- return path.join(getConfigDir(), 'local-runner.json')
173
- }
174
-
175
- export async function loadLocalRunnerConfig(): Promise<LocalRunnerConfig> {
176
- try {
177
- const raw = await fs.readFile(getLocalRunnerConfigPath(), 'utf8')
178
- const parsed = JSON.parse(raw) as unknown
179
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
180
- const value = (parsed as { llamaServerPath?: unknown }).llamaServerPath
181
- return typeof value === 'string' && value.trim() ? { llamaServerPath: value.trim() } : {}
182
- } catch (err: unknown) {
183
- if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
184
- return {}
185
- }
186
- }
187
-
188
- export async function saveLocalRunnerConfig(config: LocalRunnerConfig): Promise<void> {
189
- await ensureConfigDir()
190
- await atomicWriteText(getLocalRunnerConfigPath(), JSON.stringify(config, null, 2) + '\n')
191
- }
192
-
193
- export async function setLlamaCppServerPath(serverPath: string): Promise<void> {
194
- await saveLocalRunnerConfig({ llamaServerPath: serverPath.trim() })
195
- }
196
-
197
- export async function detectLlamaCppServerBinary(extraCandidates: string[] = []): Promise<{ path: string | null; version: string | null }> {
198
- const config = await loadLocalRunnerConfig()
199
- const candidates = [
200
- ...llamaCppServerCandidates(process.env, process.platform, config.llamaServerPath),
201
- ...extraCandidates,
202
- ]
203
- for (const candidate of candidates) {
204
- const result = await runCommand(candidate, ['--version'])
205
- if (!result) continue
206
- const output = `${result.stdout}\n${result.stderr}`.trim()
207
- if (result.code === 0 || output.length > 0) {
208
- return { path: candidate, version: firstLine(output) || 'installed' }
209
- }
210
- }
211
- return { path: null, version: null }
212
- }
213
-
214
- export function llamaCppServerCandidates(
215
- env: NodeJS.ProcessEnv = process.env,
216
- platform: NodeJS.Platform = process.platform,
217
- configuredPath?: string,
218
- ): string[] {
219
- const candidates: string[] = []
220
- appendCandidate(candidates, configuredPath)
221
- appendCandidate(candidates, env.LLAMA_SERVER_PATH)
222
- appendCandidate(candidates, env.LLAMACPP_SERVER_PATH)
223
- appendCandidate(candidates, 'llama-server')
224
- appendCandidate(candidates, 'llama-server.exe')
225
-
226
- if (platform === 'win32') {
227
- appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp', 'llama-server.exe') : undefined)
228
- appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp', 'llama-server.exe') : undefined)
229
- appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'llama-server.exe') : undefined)
230
- appendCandidate(candidates, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp', 'llama-server.exe') : undefined)
231
- appendCandidate(candidates, env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'llama.cpp', 'llama-server.exe') : undefined)
232
- appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims', 'llama-server.exe') : undefined)
233
- appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp', 'current', 'llama-server.exe') : undefined)
234
- } else if (platform === 'darwin') {
235
- appendCandidate(candidates, '/opt/homebrew/bin/llama-server')
236
- appendCandidate(candidates, '/usr/local/bin/llama-server')
237
- appendCandidate(candidates, '/opt/local/bin/llama-server')
238
- appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
239
- appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
240
- } else {
241
- appendCandidate(candidates, '/usr/local/bin/llama-server')
242
- appendCandidate(candidates, '/usr/bin/llama-server')
243
- appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
244
- appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
245
- }
246
-
247
- return candidates
248
- }
249
-
250
161
  export function llamaCppInstallPlans(platform: NodeJS.Platform = process.platform): LlamaCppInstallPlan[] {
251
162
  if (platform === 'win32') {
252
163
  return [
@@ -768,183 +679,6 @@ function startupDetail(output: string, fallback: string): string {
768
679
  return output ? `${fallback}\n${output}` : fallback
769
680
  }
770
681
 
771
- function firstLine(text: string): string {
772
- return text.split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? ''
773
- }
774
-
775
- function appendCandidate(candidates: string[], candidate: string | undefined): void {
776
- if (!candidate || candidates.includes(candidate)) return
777
- candidates.push(candidate)
778
- }
779
-
780
- export function llamaCppSearchRoots(
781
- env: NodeJS.ProcessEnv = process.env,
782
- platform: NodeJS.Platform = process.platform,
783
- ): string[] {
784
- const roots: string[] = []
785
- if (platform === 'win32') {
786
- appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Packages') : undefined)
787
- appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : undefined)
788
- appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp') : undefined)
789
- appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp') : undefined)
790
- appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp') : undefined)
791
- appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'WindowsApps') : undefined)
792
- appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp') : undefined)
793
- appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims') : undefined)
794
- appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
795
- appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
796
- return roots
797
- }
798
-
799
- appendCandidate(roots, '/opt/homebrew/bin')
800
- appendCandidate(roots, '/usr/local/bin')
801
- appendCandidate(roots, '/opt/local/bin')
802
- appendCandidate(roots, '/usr/bin')
803
- appendCandidate(roots, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin') : undefined)
804
- appendCandidate(roots, env.HOME ? path.join(env.HOME, '.local', 'bin') : undefined)
805
- appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
806
- appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
807
- return roots
808
- }
809
-
810
- export async function discoverLlamaCppServerPaths(
811
- env: NodeJS.ProcessEnv = process.env,
812
- platform: NodeJS.Platform = process.platform,
813
- ): Promise<string[]> {
814
- return discoverExecutablePaths(platform === 'win32' ? ['llama-server.exe', 'llama-server'] : ['llama-server'], env, platform)
815
- }
816
-
817
- async function discoverLlamaCppCliPaths(
818
- env: NodeJS.ProcessEnv = process.env,
819
- platform: NodeJS.Platform = process.platform,
820
- ): Promise<string[]> {
821
- return discoverExecutablePaths(platform === 'win32' ? ['llama-cli.exe', 'llama-cli'] : ['llama-cli'], env, platform)
822
- }
823
-
824
- async function discoverExecutablePaths(
825
- names: string[],
826
- env: NodeJS.ProcessEnv,
827
- platform: NodeJS.Platform,
828
- ): Promise<string[]> {
829
- const found: string[] = []
830
- const lowered = new Set(names.map(name => name.toLowerCase()))
831
- for (const root of llamaCppSearchRoots(env, platform)) {
832
- await walkForExecutable(root, lowered, found, 0, 5)
833
- if (found.length >= 20) break
834
- }
835
- return found
836
- }
837
-
838
- async function walkForExecutable(
839
- dir: string,
840
- names: Set<string>,
841
- found: string[],
842
- depth: number,
843
- maxDepth: number,
844
- ): Promise<void> {
845
- if (depth > maxDepth || found.length >= 20) return
846
- let entries: Array<import('node:fs').Dirent>
847
- try {
848
- entries = await fs.readdir(dir, { withFileTypes: true })
849
- } catch {
850
- return
851
- }
852
-
853
- for (const entry of entries) {
854
- if (found.length >= 20) return
855
- const fullPath = path.join(dir, entry.name)
856
- const lowerName = entry.name.toLowerCase()
857
- if ((entry.isFile() || entry.isSymbolicLink()) && names.has(lowerName)) {
858
- appendCandidate(found, fullPath)
859
- continue
860
- }
861
- if (entry.isDirectory() && shouldDescendRunnerDir(entry.name, depth)) {
862
- await walkForExecutable(fullPath, names, found, depth + 1, maxDepth)
863
- }
864
- }
865
- }
866
-
867
- function shouldDescendRunnerDir(name: string, depth: number): boolean {
868
- const lower = name.toLowerCase()
869
- if (/(llama|ggml|bin|build|release|debug|current|package|windowsapps|x64|arm64)/.test(lower)) return true
870
- return depth > 0 && lower.length <= 24
871
- }
872
-
873
- async function findAndPersistLlamaCppServer(
874
- platform: NodeJS.Platform = process.platform,
875
- ): Promise<{ path: string | null; version: string | null }> {
876
- const direct = await detectLlamaCppServerBinary()
877
- if (direct.path) return direct
878
- const discovered = await discoverLlamaCppServerPaths(process.env, platform)
879
- const found = await detectLlamaCppServerBinary(discovered)
880
- if (found.path) {
881
- await setLlamaCppServerPath(found.path).catch(() => {})
882
- }
883
- return found
884
- }
885
-
886
- export function summarizeInstallOutput(output: string): string | undefined {
887
- const lines = output
888
- .split(/\r?\n/)
889
- .map(cleanInstallLine)
890
- .filter(Boolean)
891
- .filter(line => !/^[\-\\|/_.=\s]+$/.test(line))
892
- .filter(line => !/^\d+(\.\d+)?\s*(B|KB|MB|GB)\s*\/\s*\d+/i.test(line))
893
- const unique = [...new Set(lines)]
894
- return unique.slice(-6).join('\n') || undefined
895
- }
896
-
897
- export function humanInstallError(plan: LlamaCppInstallPlan, code: number | null): string {
898
- if (plan.command === 'winget') return 'Windows could not install the local runner automatically.'
899
- if (plan.command === 'brew') return 'Homebrew could not install the local runner automatically.'
900
- if (plan.command === 'nix') return 'Nix could not install the local runner automatically.'
901
- if (plan.command === 'port') return 'MacPorts could not install the local runner automatically.'
902
- if (plan.command === 'git') return 'ethagent could not download the local runner source.'
903
- if (plan.command === 'cmake') return 'ethagent could not build the local runner.'
904
- return code === null
905
- ? `${plan.label} did not complete.`
906
- : `${plan.label} failed with exit code ${code}.`
907
- }
908
-
909
- function installFailureDetail(code: number | null, output: string): string | undefined {
910
- const details = [
911
- code === null ? undefined : `exit code ${code}`,
912
- summarizeInstallOutput(output),
913
- ].filter((item): item is string => Boolean(item))
914
- return details.join('\n') || undefined
915
- }
916
-
917
- function cleanInstallLine(line: string): string {
918
- return line
919
- .replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
920
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
921
- .replace(/\s+/g, ' ')
922
- .trim()
923
- }
924
-
925
- function installerProgressLabel(plan: LlamaCppInstallPlan): string {
926
- if (plan.command === 'winget') return 'installing with Windows package manager...'
927
- if (plan.command === 'brew') return 'installing with Homebrew...'
928
- if (plan.command === 'nix') return 'installing with Nix...'
929
- if (plan.command === 'port') return 'installing with MacPorts...'
930
- return `installing with ${plan.label}...`
931
- }
932
-
933
- function formatInstallFailure(label: string, result: RunInstallResult): string {
934
- if (result.ok) return label
935
- return [label, result.message, result.detail].filter(Boolean).join(': ')
936
- }
937
-
938
- function buildFailure(result: RunInstallResult): LlamaCppInstallResult {
939
- return {
940
- ok: false,
941
- code: 'build-failed',
942
- message: 'ethagent could not build the local runner.',
943
- detail: result.ok ? undefined : [result.message, result.detail].filter(Boolean).join('\n'),
944
- recovery: ['runner-path', 'retry-install', 'back'],
945
- }
946
- }
947
-
948
682
  function sourceBuildServerCandidates(buildDir: string, platform: NodeJS.Platform): string[] {
949
683
  const exe = platform === 'win32' ? 'llama-server.exe' : 'llama-server'
950
684
  return [
@@ -0,0 +1,44 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export type RunResult = {
4
+ code: number
5
+ stdout: string
6
+ stderr: string
7
+ }
8
+
9
+ export function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<RunResult | null> {
10
+ return new Promise(resolve => {
11
+ let settled = false
12
+ let child: ReturnType<typeof spawn>
13
+ try {
14
+ child = spawn(cmd, args, { windowsHide: true })
15
+ } catch {
16
+ resolve(null)
17
+ return
18
+ }
19
+
20
+ let stdout = ''
21
+ let stderr = ''
22
+ const timer = setTimeout(() => {
23
+ if (settled) return
24
+ settled = true
25
+ try { child.kill() } catch { void 0 }
26
+ resolve(null)
27
+ }, timeoutMs)
28
+
29
+ child.stdout?.on('data', chunk => { stdout += chunk.toString() })
30
+ child.stderr?.on('data', chunk => { stderr += chunk.toString() })
31
+ child.on('error', () => {
32
+ if (settled) return
33
+ settled = true
34
+ clearTimeout(timer)
35
+ resolve(null)
36
+ })
37
+ child.on('close', code => {
38
+ if (settled) return
39
+ settled = true
40
+ clearTimeout(timer)
41
+ resolve({ code: code ?? -1, stdout, stderr })
42
+ })
43
+ })
44
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { atomicWriteText } from '../storage/atomicWrite.js'
4
+ import { ensureConfigDir, getConfigDir } from '../storage/config.js'
5
+
6
+ export type LocalRunnerConfig = {
7
+ llamaServerPath?: string
8
+ }
9
+
10
+ export function getLocalRunnerConfigPath(): string {
11
+ return path.join(getConfigDir(), 'local-runner.json')
12
+ }
13
+
14
+ export async function loadLocalRunnerConfig(): Promise<LocalRunnerConfig> {
15
+ try {
16
+ const raw = await fs.readFile(getLocalRunnerConfigPath(), 'utf8')
17
+ const parsed = JSON.parse(raw) as unknown
18
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
19
+ const value = (parsed as { llamaServerPath?: unknown }).llamaServerPath
20
+ return typeof value === 'string' && value.trim() ? { llamaServerPath: value.trim() } : {}
21
+ } catch (err: unknown) {
22
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
23
+ return {}
24
+ }
25
+ }
26
+
27
+ export async function saveLocalRunnerConfig(config: LocalRunnerConfig): Promise<void> {
28
+ await ensureConfigDir()
29
+ await atomicWriteText(getLocalRunnerConfigPath(), JSON.stringify(config, null, 2) + '\n')
30
+ }
31
+
32
+ export async function setLlamaCppServerPath(serverPath: string): Promise<void> {
33
+ await saveLocalRunnerConfig({ llamaServerPath: serverPath.trim() })
34
+ }
@@ -0,0 +1,176 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getConfigDir } from '../storage/config.js'
4
+ import {
5
+ loadLocalRunnerConfig,
6
+ setLlamaCppServerPath,
7
+ } from './llamacppConfig.js'
8
+ import { runCommand } from './llamacppCommands.js'
9
+
10
+ export async function detectLlamaCppServerBinary(extraCandidates: string[] = []): Promise<{ path: string | null; version: string | null }> {
11
+ const config = await loadLocalRunnerConfig()
12
+ const candidates = [
13
+ ...llamaCppServerCandidates(process.env, process.platform, config.llamaServerPath),
14
+ ...extraCandidates,
15
+ ]
16
+ for (const candidate of candidates) {
17
+ const result = await runCommand(candidate, ['--version'])
18
+ if (!result) continue
19
+ const output = `${result.stdout}\n${result.stderr}`.trim()
20
+ if (result.code === 0 || output.length > 0) {
21
+ return { path: candidate, version: firstLine(output) || 'installed' }
22
+ }
23
+ }
24
+ return { path: null, version: null }
25
+ }
26
+
27
+ export function llamaCppServerCandidates(
28
+ env: NodeJS.ProcessEnv = process.env,
29
+ platform: NodeJS.Platform = process.platform,
30
+ configuredPath?: string,
31
+ ): string[] {
32
+ const candidates: string[] = []
33
+ appendCandidate(candidates, configuredPath)
34
+ appendCandidate(candidates, env.LLAMA_SERVER_PATH)
35
+ appendCandidate(candidates, env.LLAMACPP_SERVER_PATH)
36
+ appendCandidate(candidates, 'llama-server')
37
+ appendCandidate(candidates, 'llama-server.exe')
38
+
39
+ if (platform === 'win32') {
40
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp', 'llama-server.exe') : undefined)
41
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp', 'llama-server.exe') : undefined)
42
+ appendCandidate(candidates, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps', 'llama-server.exe') : undefined)
43
+ appendCandidate(candidates, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp', 'llama-server.exe') : undefined)
44
+ appendCandidate(candidates, env['ProgramFiles(x86)'] ? path.join(env['ProgramFiles(x86)'], 'llama.cpp', 'llama-server.exe') : undefined)
45
+ appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims', 'llama-server.exe') : undefined)
46
+ appendCandidate(candidates, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp', 'current', 'llama-server.exe') : undefined)
47
+ } else if (platform === 'darwin') {
48
+ appendCandidate(candidates, '/opt/homebrew/bin/llama-server')
49
+ appendCandidate(candidates, '/usr/local/bin/llama-server')
50
+ appendCandidate(candidates, '/opt/local/bin/llama-server')
51
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
52
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
53
+ } else {
54
+ appendCandidate(candidates, '/usr/local/bin/llama-server')
55
+ appendCandidate(candidates, '/usr/bin/llama-server')
56
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin', 'llama-server') : undefined)
57
+ appendCandidate(candidates, env.HOME ? path.join(env.HOME, '.local', 'bin', 'llama-server') : undefined)
58
+ }
59
+
60
+ return candidates
61
+ }
62
+
63
+ export function llamaCppSearchRoots(
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ platform: NodeJS.Platform = process.platform,
66
+ ): string[] {
67
+ const roots: string[] = []
68
+ if (platform === 'win32') {
69
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WinGet', 'Packages') : undefined)
70
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Microsoft', 'WindowsApps') : undefined)
71
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'Programs', 'llama.cpp') : undefined)
72
+ appendCandidate(roots, env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, 'llama.cpp') : undefined)
73
+ appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'llama.cpp') : undefined)
74
+ appendCandidate(roots, env.ProgramFiles ? path.join(env.ProgramFiles, 'WindowsApps') : undefined)
75
+ appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'apps', 'llama.cpp') : undefined)
76
+ appendCandidate(roots, env.USERPROFILE ? path.join(env.USERPROFILE, 'scoop', 'shims') : undefined)
77
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
78
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
79
+ return roots
80
+ }
81
+
82
+ appendCandidate(roots, '/opt/homebrew/bin')
83
+ appendCandidate(roots, '/usr/local/bin')
84
+ appendCandidate(roots, '/opt/local/bin')
85
+ appendCandidate(roots, '/usr/bin')
86
+ appendCandidate(roots, env.HOME ? path.join(env.HOME, '.nix-profile', 'bin') : undefined)
87
+ appendCandidate(roots, env.HOME ? path.join(env.HOME, '.local', 'bin') : undefined)
88
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build'))
89
+ appendCandidate(roots, path.join(getConfigDir(), 'runners', 'llama.cpp', 'build', 'bin'))
90
+ return roots
91
+ }
92
+
93
+ export async function discoverLlamaCppServerPaths(
94
+ env: NodeJS.ProcessEnv = process.env,
95
+ platform: NodeJS.Platform = process.platform,
96
+ ): Promise<string[]> {
97
+ return discoverExecutablePaths(platform === 'win32' ? ['llama-server.exe', 'llama-server'] : ['llama-server'], env, platform)
98
+ }
99
+
100
+ export async function discoverLlamaCppCliPaths(
101
+ env: NodeJS.ProcessEnv = process.env,
102
+ platform: NodeJS.Platform = process.platform,
103
+ ): Promise<string[]> {
104
+ return discoverExecutablePaths(platform === 'win32' ? ['llama-cli.exe', 'llama-cli'] : ['llama-cli'], env, platform)
105
+ }
106
+
107
+ export async function findAndPersistLlamaCppServer(
108
+ platform: NodeJS.Platform = process.platform,
109
+ ): Promise<{ path: string | null; version: string | null }> {
110
+ const direct = await detectLlamaCppServerBinary()
111
+ if (direct.path) return direct
112
+ const discovered = await discoverLlamaCppServerPaths(process.env, platform)
113
+ const found = await detectLlamaCppServerBinary(discovered)
114
+ if (found.path) {
115
+ await setLlamaCppServerPath(found.path).catch(() => {})
116
+ }
117
+ return found
118
+ }
119
+
120
+ async function discoverExecutablePaths(
121
+ names: string[],
122
+ env: NodeJS.ProcessEnv,
123
+ platform: NodeJS.Platform,
124
+ ): Promise<string[]> {
125
+ const found: string[] = []
126
+ const lowered = new Set(names.map(name => name.toLowerCase()))
127
+ for (const root of llamaCppSearchRoots(env, platform)) {
128
+ await walkForExecutable(root, lowered, found, 0, 5)
129
+ if (found.length >= 20) break
130
+ }
131
+ return found
132
+ }
133
+
134
+ async function walkForExecutable(
135
+ dir: string,
136
+ names: Set<string>,
137
+ found: string[],
138
+ depth: number,
139
+ maxDepth: number,
140
+ ): Promise<void> {
141
+ if (depth > maxDepth || found.length >= 20) return
142
+ let entries: Array<import('node:fs').Dirent>
143
+ try {
144
+ entries = await fs.readdir(dir, { withFileTypes: true })
145
+ } catch {
146
+ return
147
+ }
148
+
149
+ for (const entry of entries) {
150
+ if (found.length >= 20) return
151
+ const fullPath = path.join(dir, entry.name)
152
+ const lowerName = entry.name.toLowerCase()
153
+ if ((entry.isFile() || entry.isSymbolicLink()) && names.has(lowerName)) {
154
+ appendCandidate(found, fullPath)
155
+ continue
156
+ }
157
+ if (entry.isDirectory() && shouldDescendRunnerDir(entry.name, depth)) {
158
+ await walkForExecutable(fullPath, names, found, depth + 1, maxDepth)
159
+ }
160
+ }
161
+ }
162
+
163
+ function shouldDescendRunnerDir(name: string, depth: number): boolean {
164
+ const lower = name.toLowerCase()
165
+ if (/(llama|ggml|bin|build|release|debug|current|package|windowsapps|x64|arm64)/.test(lower)) return true
166
+ return depth > 0 && lower.length <= 24
167
+ }
168
+
169
+ function firstLine(text: string): string {
170
+ return text.split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? ''
171
+ }
172
+
173
+ function appendCandidate(candidates: string[], candidate: string | undefined): void {
174
+ if (!candidate || candidates.includes(candidate)) return
175
+ candidates.push(candidate)
176
+ }