ethagent 2.2.0 → 2.4.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 (168) hide show
  1. package/README.md +11 -0
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +3 -7
  4. package/src/app/FirstRunTimeline.tsx +1 -1
  5. package/src/chat/ChatBottomPane.tsx +29 -11
  6. package/src/chat/ChatScreen.tsx +169 -38
  7. package/src/chat/ConversationStack.tsx +1 -1
  8. package/src/chat/MessageList.tsx +185 -72
  9. package/src/chat/SessionStatus.tsx +3 -1
  10. package/src/chat/chatScreenUtils.ts +11 -15
  11. package/src/chat/chatSessionState.ts +5 -2
  12. package/src/chat/chatTurnOrchestrator.ts +7 -9
  13. package/src/chat/commands.ts +26 -26
  14. package/src/chat/display/DiffView.tsx +193 -0
  15. package/src/chat/display/SyntaxText.tsx +192 -0
  16. package/src/chat/display/toolCallDisplay.ts +103 -0
  17. package/src/chat/display/toolResultDisplay.ts +19 -0
  18. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
  19. package/src/chat/input/imageRefs.ts +30 -0
  20. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  21. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  22. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  23. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  24. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  25. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  26. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  27. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  28. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
  29. package/src/chat/views/RewindView.tsx +410 -0
  30. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  31. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  32. package/src/identity/hub/Routes.tsx +13 -13
  33. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  34. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  35. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  36. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  37. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  38. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  39. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  40. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  41. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  42. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  43. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  44. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  45. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  46. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  47. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  48. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  49. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  50. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  51. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  57. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  58. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  59. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  60. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  61. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  62. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  63. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  64. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  65. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  66. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  67. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  68. package/src/identity/hub/identityHubReducer.ts +3 -3
  69. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  70. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  71. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  72. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  73. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  74. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  75. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  76. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  77. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  78. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  79. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  80. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  81. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  82. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  83. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  84. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  85. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  87. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  88. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  89. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  90. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  91. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  92. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  93. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  94. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  95. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  96. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  97. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  98. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  99. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  100. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  101. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  102. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  103. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  105. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  106. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  107. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  108. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  109. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  110. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  111. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  112. package/src/identity/hub/useIdentityHubController.ts +11 -11
  113. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  114. package/src/models/ModelPicker.tsx +143 -9
  115. package/src/models/catalog.ts +2 -1
  116. package/src/models/huggingface.ts +180 -2
  117. package/src/models/llamacpp.ts +110 -15
  118. package/src/models/llamacppPreflight.ts +30 -11
  119. package/src/models/modelPickerOptions.ts +16 -15
  120. package/src/models/providerDisplay.ts +16 -0
  121. package/src/providers/anthropic.ts +36 -5
  122. package/src/providers/contracts.ts +9 -1
  123. package/src/providers/errors.ts +6 -4
  124. package/src/providers/gemini.ts +29 -3
  125. package/src/providers/openai-chat.ts +83 -3
  126. package/src/providers/openai-responses-format.ts +29 -8
  127. package/src/providers/openai-responses.ts +22 -7
  128. package/src/providers/registry.ts +1 -0
  129. package/src/runtime/sessionMode.ts +1 -1
  130. package/src/runtime/systemPrompt.ts +3 -1
  131. package/src/runtime/toolExecution.ts +9 -6
  132. package/src/runtime/turn.ts +29 -0
  133. package/src/storage/config.ts +1 -0
  134. package/src/storage/rewind.ts +20 -0
  135. package/src/storage/sessions.ts +16 -3
  136. package/src/tools/bashSafety.ts +7 -3
  137. package/src/tools/bashTool.ts +1 -1
  138. package/src/tools/contracts.ts +3 -0
  139. package/src/tools/deleteFileTool.ts +8 -3
  140. package/src/tools/editTool.ts +10 -5
  141. package/src/tools/fileDiff.ts +261 -0
  142. package/src/tools/privateContinuityEditTool.ts +5 -1
  143. package/src/tools/writeFileTool.ts +8 -3
  144. package/src/ui/Spinner.tsx +39 -5
  145. package/src/ui/TextInput.tsx +2 -2
  146. package/src/ui/theme.ts +19 -0
  147. package/src/utils/clipboard.ts +10 -7
  148. package/src/utils/images.ts +140 -0
  149. package/src/utils/messages.ts +2 -0
  150. package/src/chat/RewindView.tsx +0 -386
  151. package/src/chat/toolResultDisplay.ts +0 -8
  152. package/src/identity/hub/effects/index.ts +0 -73
  153. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  154. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  155. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  156. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  157. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  158. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  159. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  160. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  161. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  162. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  163. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  164. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  165. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  166. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  167. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  168. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -13,6 +13,7 @@ import {
13
13
  installLlamaCppRunner,
14
14
  setLlamaCppServerPath,
15
15
  startLlamaCppServer,
16
+ stopLlamaCppServer,
16
17
  type LlamaCppInstallProgress,
17
18
  type LlamaCppInstallResult,
18
19
  type LlamaCppStartResult,
@@ -32,6 +33,8 @@ import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storag
32
33
  import { clearModelCatalogCache, discoverProviderModels, isOpenAIOAuthAllowedModel, OPENAI_OAUTH_DEFAULT_MODEL, type ModelCatalogResult } from './catalog.js'
33
34
  import { contextWindowInfo } from '../runtime/compaction.js'
34
35
  import {
36
+ addMmprojToInstalledModel,
37
+ backfillMmprojForModels,
35
38
  createHfDownloadPlan,
36
39
  downloadHfModel,
37
40
  fetchHuggingFaceRepoInfo,
@@ -68,7 +71,7 @@ import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDi
68
71
  import { fetchUncensoredGgufCatalog, type UncensoredCatalogEntry } from './uncensoredCatalog.js'
69
72
 
70
73
  export type ModelPickerSelection =
71
- | { kind: 'llamacpp'; model: string }
74
+ | { kind: 'llamacpp'; model: string; mmprojPath?: string }
72
75
  | { kind: 'cloud'; provider: CloudProviderId; model: string; keyJustSet: boolean }
73
76
 
74
77
  type ModelPickerProps = {
@@ -113,6 +116,9 @@ type State =
113
116
  | { kind: 'localRunnerPathEntry'; data: LoadedData; model: LocalHfModel; submitting: boolean; error?: string }
114
117
  | { kind: 'localRunnerStarting'; data: LoadedData; model: LocalHfModel; startedAt: number }
115
118
  | { kind: 'localRunnerStartFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppStartResult, { ok: false }> }
119
+ | { kind: 'mmprojOffer'; data: LoadedData; model: LocalHfModel }
120
+ | { kind: 'mmprojDownloading'; data: LoadedData; model: LocalHfModel; progress: HfDownloadProgress }
121
+ | { kind: 'mmprojError'; data: LoadedData; model: LocalHfModel; message: string }
116
122
 
117
123
  export const ModelPicker: React.FC<ModelPickerProps> = ({
118
124
  currentConfig,
@@ -244,6 +250,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
244
250
  const canDownload = plan.review.risk !== 'high' && plan.review.runtime === 'llama.cpp runnable'
245
251
  const fit = state.data.machineSpec ? estimateGgufMachineFit(plan.sizeBytes, state.data.machineSpec) : null
246
252
  const recommended = state.data.machineSpec ? recommendGgufFile(plan.repo, ggufFiles(plan.repo), state.data.machineSpec) : null
253
+ const mmproj = plan.mmprojCandidate
247
254
  return (
248
255
  <Surface
249
256
  title="Review Model Link"
@@ -260,15 +267,22 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
260
267
  <Text color={riskColor(plan.review.risk)}>safety: {safetyLabel(plan.review.risk)} · source: {credibilityLabel(plan.review.credibility)}</Text>
261
268
  <Text color={theme.dim}>signals: {formatSignals(plan.repo.downloads, plan.repo.likes)}</Text>
262
269
  <Text color={theme.dim}>notes: {friendlyReasons(plan.review.reasons).join('; ')}</Text>
270
+ {mmproj ? (
271
+ <Text color={theme.dim}>vision encoder available: {friendlyFileName(mmproj.filename)} (+{formatBytes(mmproj.sizeBytes)})</Text>
272
+ ) : null}
263
273
  </Box>
264
- <Select<'download' | 'pick' | 'cancel'>
274
+ <Select<'download' | 'downloadWithMmproj' | 'pick' | 'cancel'>
265
275
  options={[
266
- { value: 'download', label: 'Download This Model', disabled: !canDownload },
276
+ ...(mmproj ? [{ value: 'downloadWithMmproj' as const, label: `Download Model + Vision Encoder (+${formatBytes(mmproj.sizeBytes)}) · recommended`, disabled: !canDownload }] : []),
277
+ { value: 'download', label: mmproj ? 'Download Without Vision Encoder' : 'Download This Model', disabled: !canDownload },
267
278
  { value: 'pick', label: 'Pick Another File' },
268
279
  { value: 'cancel', label: 'Cancel' },
269
280
  ]}
270
281
  onSubmit={choice => {
271
282
  if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
283
+ else if (choice === 'downloadWithMmproj') {
284
+ void startHfDownload({ ...state, plan: { ...plan, includeMmproj: true } }, setState, hfAbortRef, onPick)
285
+ }
272
286
  else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
273
287
  else setState({ kind: 'list', data: state.data })
274
288
  }}
@@ -291,6 +305,68 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
291
305
  )
292
306
  }
293
307
 
308
+ if (state.kind === 'mmprojOffer') {
309
+ const sizeLabel = state.model.mmprojSizeBytes ? `+${formatBytes(state.model.mmprojSizeBytes)}` : 'additional download'
310
+ return (
311
+ <Surface
312
+ title="Add Image Support?"
313
+ subtitle={`${state.model.displayName} has a vision encoder available in its Hugging Face repo.`}
314
+ footer="enter select · esc back"
315
+ >
316
+ <Box flexDirection="column" marginBottom={1}>
317
+ <Text color={theme.dim}>Loading the vision encoder lets this model accept pasted images.</Text>
318
+ <Text color={theme.dim}>Without it, image paste is declined at submit time.</Text>
319
+ </Box>
320
+ <Select<'add' | 'skip' | 'cancel'>
321
+ options={[
322
+ { value: 'add', label: `Add Vision Encoder (${sizeLabel}) And Use` },
323
+ { value: 'skip', label: 'Use Without Image Support' },
324
+ { value: 'cancel', label: 'Cancel' },
325
+ ]}
326
+ onSubmit={choice => {
327
+ if (choice === 'add') void downloadMmprojAndContinue(state, setState, onPick)
328
+ else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
329
+ else setState({ kind: 'list', data: state.data })
330
+ }}
331
+ onCancel={() => setState({ kind: 'list', data: state.data })}
332
+ />
333
+ </Surface>
334
+ )
335
+ }
336
+
337
+ if (state.kind === 'mmprojDownloading') {
338
+ const total = state.progress.total ?? state.model.mmprojSizeBytes ?? 0
339
+ const completed = state.progress.completed ?? 0
340
+ const progress = total > 0 ? completed / total : 0
341
+ const suffix = total > 0 ? `${formatBytes(completed)} / ${formatBytes(total)}` : formatBytes(completed)
342
+ return (
343
+ <Surface title="Downloading Vision Encoder" subtitle={state.model.displayName}>
344
+ <Text color={theme.dim}>{state.progress.status}</Text>
345
+ <ProgressBar progress={progress} suffix={suffix} />
346
+ </Surface>
347
+ )
348
+ }
349
+
350
+ if (state.kind === 'mmprojError') {
351
+ return (
352
+ <Surface title="Vision Encoder Download Failed" subtitle={state.message} tone="error" footer="enter select · esc back">
353
+ <Select<'retry' | 'skip' | 'back'>
354
+ options={[
355
+ { value: 'retry', label: 'Retry Download' },
356
+ { value: 'skip', label: 'Use Without Image Support' },
357
+ { value: 'back', label: 'Back To Picker' },
358
+ ]}
359
+ onSubmit={choice => {
360
+ if (choice === 'retry') setState({ kind: 'mmprojOffer', data: state.data, model: state.model })
361
+ else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
362
+ else setState({ kind: 'list', data: state.data })
363
+ }}
364
+ onCancel={() => setState({ kind: 'list', data: state.data })}
365
+ />
366
+ </Surface>
367
+ )
368
+ }
369
+
294
370
  if (state.kind === 'hfDone') {
295
371
  return (
296
372
  <Surface
@@ -612,9 +688,11 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
612
688
  ) : (
613
689
  <Select
614
690
  options={[
615
- { value: 'signin', label: 'Sign in Again' },
616
- { value: 'signout', label: 'Sign Out' },
617
- { value: 'cancel', label: 'Back' },
691
+ { value: 'hdr:account', label: 'Account', disabled: true, role: 'section', bold: true },
692
+ { value: 'signin', label: 'Sign in Again', indent: 2 },
693
+ { value: 'signout', label: 'Sign Out', indent: 2 },
694
+ { value: 'hdr:nav', label: 'Navigation', disabled: true, role: 'section', bold: true },
695
+ { value: 'cancel', label: 'Back', indent: 2 },
618
696
  ]}
619
697
  onSubmit={(value) => {
620
698
  if (value === 'signin') {
@@ -832,6 +910,18 @@ function handleSubmit(
832
910
  })()
833
911
  return
834
912
  }
913
+ if (value.startsWith('hfmmproj:') && state.kind === 'list') {
914
+ const id = value.slice('hfmmproj:'.length)
915
+ void (async () => {
916
+ const local = await findLocalHfModel(id)
917
+ if (!local) {
918
+ setState({ kind: 'hfError', data: state.data, message: 'local model metadata was not found' })
919
+ return
920
+ }
921
+ setState({ kind: 'mmprojOffer', data: state.data, model: local })
922
+ })()
923
+ return
924
+ }
835
925
  if (value.startsWith('uc:') && state.kind === 'localCatalog') {
836
926
  const entry = state.catalog.find(item => catalogOptionValue(item.repo.repoId, item.file.filename) === value)
837
927
  if (entry) void reviewCatalogModel(state, entry, setState)
@@ -1248,6 +1338,8 @@ function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, {
1248
1338
  return result.message
1249
1339
  case 'runner-not-installed':
1250
1340
  return 'this machine still needs a local runner'
1341
+ case 'untracked-server':
1342
+ return result.message
1251
1343
  }
1252
1344
  }
1253
1345
 
@@ -1443,6 +1535,39 @@ async function uninstallLocalModel(
1443
1535
  }
1444
1536
  }
1445
1537
 
1538
+ async function downloadMmprojAndContinue(
1539
+ state: Extract<State, { kind: 'mmprojOffer' }>,
1540
+ setState: (s: State) => void,
1541
+ onPick: (sel: ModelPickerSelection) => void,
1542
+ ): Promise<void> {
1543
+ setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress: { status: 'starting' } })
1544
+ try {
1545
+ for await (const progress of addMmprojToInstalledModel(state.model.id)) {
1546
+ setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress })
1547
+ }
1548
+ } catch (err: unknown) {
1549
+ setState({ kind: 'mmprojError', data: state.data, model: state.model, message: (err as Error).message })
1550
+ return
1551
+ }
1552
+ const updated = await findLocalHfModel(state.model.id)
1553
+ if (!updated || !updated.mmprojPath) {
1554
+ setState({ kind: 'mmprojError', data: state.data, model: state.model, message: 'projector downloaded but path was not persisted' })
1555
+ return
1556
+ }
1557
+ const stopResult = await stopLlamaCppServer().catch(() => null)
1558
+ if (stopResult && stopResult.ok && stopResult.reason === 'untracked-server') {
1559
+ setState({
1560
+ kind: 'mmprojError',
1561
+ data: state.data,
1562
+ model: updated,
1563
+ message: 'Vision encoder downloaded, but a llama-server is already running and ethagent did not launch it. Quit ethagent, stop the external llama-server (taskkill /F /IM llama-server.exe on Windows, pkill llama-server on macOS or Linux), then reopen ethagent to load the projector.',
1564
+ })
1565
+ return
1566
+ }
1567
+ const data = { ...state.data, hfModels: await loadHfPickerModels() }
1568
+ await startAndPickHfModel(updated, { kind: 'mmprojOffer', data, model: updated }, setState, onPick)
1569
+ }
1570
+
1446
1571
  async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
1447
1572
  const hfModels = await loadHfPickerModels()
1448
1573
  return {
@@ -1453,7 +1578,7 @@ async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
1453
1578
 
1454
1579
  async function startAndPickHfModel(
1455
1580
  model: LocalHfModel,
1456
- state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' }>,
1581
+ state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' | 'mmprojOffer' | 'mmprojError' }>,
1457
1582
  setState: (s: State) => void,
1458
1583
  onPick: (sel: ModelPickerSelection) => void,
1459
1584
  ): Promise<void> {
@@ -1461,10 +1586,15 @@ async function startAndPickHfModel(
1461
1586
  setState({ kind: 'hfError', data: state.data, message: 'blocked high-risk model; choose a model from a more credible source' })
1462
1587
  return
1463
1588
  }
1589
+ if (model.mmprojAvailable && !model.mmprojPath && state.kind !== 'mmprojOffer' && state.kind !== 'mmprojError') {
1590
+ setState({ kind: 'mmprojOffer', data: state.data, model })
1591
+ return
1592
+ }
1464
1593
  setState({ kind: 'localRunnerStarting', data: state.data, model, startedAt: Date.now() })
1465
1594
  const result = await startLlamaCppServer({
1466
1595
  modelPath: model.localPath,
1467
1596
  modelAlias: model.id,
1597
+ mmprojPath: model.mmprojPath,
1468
1598
  })
1469
1599
  const llamaCpp = await probeLlamaCpp()
1470
1600
  const data = { ...state.data, llamaCpp }
@@ -1476,7 +1606,7 @@ async function startAndPickHfModel(
1476
1606
  setState({ kind: 'localRunnerStartFail', data, model, result })
1477
1607
  return
1478
1608
  }
1479
- onPick({ kind: 'llamacpp', model: model.id })
1609
+ onPick({ kind: 'llamacpp', model: model.id, mmprojPath: model.mmprojPath })
1480
1610
  }
1481
1611
 
1482
1612
  async function installRunnerAndStart(
@@ -1574,7 +1704,8 @@ function formatContextWindow(tokens: number): string {
1574
1704
 
1575
1705
  async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']> {
1576
1706
  const installed = await loadLocalHfModels()
1577
- return installed.map(model => ({
1707
+ const backfilled = await backfillMmprojForModels(installed)
1708
+ return backfilled.map(model => ({
1578
1709
  id: model.id,
1579
1710
  displayName: model.displayName,
1580
1711
  sizeBytes: model.sizeBytes,
@@ -1582,6 +1713,9 @@ async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']>
1582
1713
  risk: model.risk,
1583
1714
  task: model.task,
1584
1715
  status: model.status,
1716
+ mmprojPath: model.mmprojPath,
1717
+ mmprojAvailable: model.mmprojAvailable,
1718
+ mmprojSizeBytes: model.mmprojSizeBytes,
1585
1719
  }))
1586
1720
  }
1587
1721
 
@@ -2,6 +2,7 @@ import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storag
2
2
  import { getKey } from '../storage/secrets.js'
3
3
  import { loadLocalHfModels } from './huggingface.js'
4
4
  import { hasOpenAIOAuthCredentials } from '../auth/openaiOAuth/credentials.js'
5
+ import { providerDisplayName } from './providerDisplay.js'
5
6
 
6
7
  const OPENAI_OAUTH_MODEL_IDS = ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'] as const
7
8
 
@@ -85,7 +86,7 @@ export async function discoverProviderModels(
85
86
  if (provider === 'openai' && await hasOpenAIOAuthCredentials()) {
86
87
  return openAIOAuthCatalog()
87
88
  }
88
- return fallbackResult(config, `missing ${provider} API key`)
89
+ return fallbackResult(config, `missing ${providerDisplayName(provider)} API key`)
89
90
  }
90
91
 
91
92
  const baseUrl = provider === 'openai' ? openAIBaseUrlFor(config) : ''
@@ -54,6 +54,12 @@ export type HfSafetyReview = {
54
54
  reasons: string[]
55
55
  }
56
56
 
57
+ export type HfMmprojCandidate = {
58
+ filename: string
59
+ sizeBytes: number
60
+ localPath: string
61
+ }
62
+
57
63
  export type HfDownloadPlan = {
58
64
  repo: HuggingFaceRepoInfo
59
65
  repoId: string
@@ -64,6 +70,8 @@ export type HfDownloadPlan = {
64
70
  localPath: string
65
71
  displayName: string
66
72
  review: HfSafetyReview
73
+ mmprojCandidate?: HfMmprojCandidate
74
+ includeMmproj?: boolean
67
75
  }
68
76
 
69
77
  export type LocalHfModel = {
@@ -90,6 +98,9 @@ export type LocalHfModel = {
90
98
  installedAt: string
91
99
  status: LocalHfStatus
92
100
  sha256?: string
101
+ mmprojPath?: string
102
+ mmprojAvailable?: boolean
103
+ mmprojSizeBytes?: number
93
104
  }
94
105
 
95
106
  export type HfDownloadProgress = {
@@ -291,6 +302,14 @@ export function ggufFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
291
302
  .sort((a, b) => a.filename.localeCompare(b.filename))
292
303
  }
293
304
 
305
+ export function isMmprojFilename(filename: string): boolean {
306
+ return filename.toLowerCase().startsWith('mmproj-') && filename.toLowerCase().endsWith('.gguf')
307
+ }
308
+
309
+ export function findMmprojSibling(repo: HuggingFaceRepoInfo): HuggingFaceSibling | undefined {
310
+ return repo.siblings.find(file => isMmprojFilename(file.filename))
311
+ }
312
+
294
313
  export async function createHfDownloadPlan(
295
314
  input: string,
296
315
  filename?: string,
@@ -320,6 +339,14 @@ export async function createHfDownloadPlan(
320
339
  requestedRevision,
321
340
  resolvedRevision,
322
341
  })
342
+ const mmprojSibling = findMmprojSibling(repo)
343
+ const mmprojCandidate: HfMmprojCandidate | undefined = mmprojSibling
344
+ ? {
345
+ filename: mmprojSibling.filename,
346
+ sizeBytes: mmprojSibling.sizeBytes ?? 0,
347
+ localPath: localPathFor(repo.repoId, resolvedRevision, mmprojSibling.filename),
348
+ }
349
+ : undefined
323
350
  return {
324
351
  repo,
325
352
  repoId: repo.repoId,
@@ -330,6 +357,7 @@ export async function createHfDownloadPlan(
330
357
  localPath: localPathFor(repo.repoId, resolvedRevision, selected.filename),
331
358
  displayName: displayNameFor(repo.repoId, selected.filename),
332
359
  review,
360
+ mmprojCandidate,
333
361
  }
334
362
  }
335
363
 
@@ -432,10 +460,151 @@ export async function* downloadHfModel(
432
460
  }
433
461
 
434
462
  await fs.rename(partialPath, plan.localPath)
435
- await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready'))
463
+
464
+ let mmprojPath: string | undefined
465
+ if (plan.includeMmproj && plan.mmprojCandidate) {
466
+ yield* downloadMmprojFile(plan.repoId, plan.resolvedRevision, plan.mmprojCandidate, signal, fetchImpl)
467
+ mmprojPath = plan.mmprojCandidate.localPath
468
+ }
469
+
470
+ await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready', mmprojPath))
436
471
  yield { status: 'success', completed, total: Number.isFinite(total) ? total : completed }
437
472
  }
438
473
 
474
+ async function* downloadMmprojFile(
475
+ repoId: string,
476
+ resolvedRevision: string,
477
+ candidate: HfMmprojCandidate,
478
+ signal: AbortSignal | undefined,
479
+ fetchImpl: FetchImpl,
480
+ ): AsyncIterable<HfDownloadProgress> {
481
+ await fs.mkdir(path.dirname(candidate.localPath), { recursive: true })
482
+ const partialPath = `${candidate.localPath}.partial`
483
+ const response = await fetchImpl(resolveUrl(repoId, resolvedRevision, candidate.filename), { signal })
484
+ if (!response.ok || !response.body) {
485
+ throw new Error(response.ok ? 'empty projector download body' : `projector download HTTP ${response.status}`)
486
+ }
487
+
488
+ const total = Number.parseInt(response.headers.get('content-length') ?? '', 10)
489
+ const handle = await fs.open(partialPath, 'w')
490
+ let completed = 0
491
+ let complete = false
492
+ let lastProgressAt = Date.now()
493
+ let lastProgressBytes = 0
494
+ yield { status: 'downloading-mmproj', completed, total: Number.isFinite(total) ? total : undefined }
495
+ try {
496
+ const reader = response.body.getReader()
497
+ while (true) {
498
+ const { done, value } = await reader.read()
499
+ if (done) break
500
+ if (signal?.aborted) throw new Error('Cancelled')
501
+ const buffer = Buffer.from(value)
502
+ await handle.write(buffer)
503
+ completed += buffer.byteLength
504
+ const now = Date.now()
505
+ if (shouldReportDownloadProgress(completed, lastProgressBytes, now, lastProgressAt)) {
506
+ lastProgressAt = now
507
+ lastProgressBytes = completed
508
+ yield { status: 'downloading-mmproj', completed, total: Number.isFinite(total) ? total : undefined }
509
+ }
510
+ }
511
+ complete = true
512
+ } finally {
513
+ await handle.close()
514
+ if (!complete) {
515
+ await fs.unlink(partialPath).catch(() => {})
516
+ }
517
+ }
518
+
519
+ await fs.rename(partialPath, candidate.localPath)
520
+ }
521
+
522
+ export async function backfillMmprojAvailability(
523
+ model: LocalHfModel,
524
+ fetchImpl: FetchImpl = fetch,
525
+ ): Promise<LocalHfModel> {
526
+ if (model.mmprojAvailable !== undefined) return model
527
+ try {
528
+ const repo = await fetchHuggingFaceRepoInfo({ repoId: model.repoId }, fetchImpl)
529
+ const sibling = findMmprojSibling(repo)
530
+ const next: LocalHfModel = {
531
+ ...model,
532
+ mmprojAvailable: Boolean(sibling),
533
+ mmprojSizeBytes: sibling?.sizeBytes,
534
+ }
535
+ await upsertLocalHfModel(next)
536
+ return next
537
+ } catch {
538
+ return model
539
+ }
540
+ }
541
+
542
+ export async function backfillMmprojForModels(
543
+ models: LocalHfModel[],
544
+ fetchImpl: FetchImpl = fetch,
545
+ ): Promise<LocalHfModel[]> {
546
+ const repoIdToProbe = new Map<string, Promise<HuggingFaceRepoInfo | null>>()
547
+ for (const model of models) {
548
+ if (model.mmprojAvailable !== undefined) continue
549
+ if (repoIdToProbe.has(model.repoId)) continue
550
+ repoIdToProbe.set(
551
+ model.repoId,
552
+ fetchHuggingFaceRepoInfo({ repoId: model.repoId }, fetchImpl).catch(() => null),
553
+ )
554
+ }
555
+ if (repoIdToProbe.size === 0) return models
556
+ const resolved = new Map<string, HuggingFaceRepoInfo | null>()
557
+ for (const [repoId, promise] of repoIdToProbe) {
558
+ resolved.set(repoId, await promise)
559
+ }
560
+ const out: LocalHfModel[] = []
561
+ for (const model of models) {
562
+ if (model.mmprojAvailable !== undefined) {
563
+ out.push(model)
564
+ continue
565
+ }
566
+ const repo = resolved.get(model.repoId)
567
+ if (!repo) {
568
+ out.push(model)
569
+ continue
570
+ }
571
+ const sibling = findMmprojSibling(repo)
572
+ const next: LocalHfModel = {
573
+ ...model,
574
+ mmprojAvailable: Boolean(sibling),
575
+ mmprojSizeBytes: sibling?.sizeBytes,
576
+ }
577
+ await upsertLocalHfModel(next)
578
+ out.push(next)
579
+ }
580
+ return out
581
+ }
582
+
583
+ export async function* addMmprojToInstalledModel(
584
+ modelId: string,
585
+ signal?: AbortSignal,
586
+ deps: { fetchImpl?: FetchImpl } = {},
587
+ ): AsyncIterable<HfDownloadProgress> {
588
+ const fetchImpl = deps.fetchImpl ?? fetch
589
+ const existing = await findLocalHfModel(modelId)
590
+ if (!existing) throw new Error(`model not installed: ${modelId}`)
591
+ if (existing.mmprojPath) {
592
+ yield { status: 'success', completed: 0 }
593
+ return
594
+ }
595
+ const repo = await fetchHuggingFaceRepoInfo({ repoId: existing.repoId }, fetchImpl)
596
+ const sibling = findMmprojSibling(repo)
597
+ if (!sibling) throw new Error(`no vision encoder available for ${existing.repoId}`)
598
+ const candidate: HfMmprojCandidate = {
599
+ filename: sibling.filename,
600
+ sizeBytes: sibling.sizeBytes ?? 0,
601
+ localPath: localPathFor(existing.repoId, existing.resolvedRevision, sibling.filename),
602
+ }
603
+ yield* downloadMmprojFile(existing.repoId, existing.resolvedRevision, candidate, signal, fetchImpl)
604
+ await upsertLocalHfModel({ ...existing, mmprojPath: candidate.localPath })
605
+ yield { status: 'success', completed: candidate.sizeBytes }
606
+ }
607
+
439
608
  export function shouldReportDownloadProgress(
440
609
  completed: number,
441
610
  lastCompleted: number,
@@ -446,7 +615,13 @@ export function shouldReportDownloadProgress(
446
615
  || completed - lastCompleted >= DOWNLOAD_PROGRESS_MIN_BYTES
447
616
  }
448
617
 
449
- export function modelFromPlan(plan: HfDownloadPlan, sha256: string | undefined, status: LocalHfStatus): LocalHfModel {
618
+ export function modelFromPlan(
619
+ plan: HfDownloadPlan,
620
+ sha256: string | undefined,
621
+ status: LocalHfStatus,
622
+ mmprojPath?: string,
623
+ ): LocalHfModel {
624
+ const mmprojAvailable = Boolean(plan.mmprojCandidate)
450
625
  const now = new Date().toISOString()
451
626
  return {
452
627
  id: localModelId(plan.repoId, plan.filename),
@@ -472,6 +647,9 @@ export function modelFromPlan(plan: HfDownloadPlan, sha256: string | undefined,
472
647
  installedAt: now,
473
648
  status,
474
649
  sha256,
650
+ mmprojPath,
651
+ mmprojAvailable,
652
+ mmprojSizeBytes: plan.mmprojCandidate?.sizeBytes,
475
653
  }
476
654
  }
477
655