ethagent 0.2.1 → 1.0.1

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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,150 @@
1
+ import {
2
+ startLlamaCppServer,
3
+ type LlamaCppStartFailureCode,
4
+ type LlamaCppStartResult,
5
+ } from './llamacpp.js'
6
+ import { findLocalHfModel, type LocalHfModel } from './huggingface.js'
7
+ import { localProviderBaseUrlFor, type EthagentConfig } from '../storage/config.js'
8
+ import { formatModelDisplayName } from './modelDisplay.js'
9
+
10
+ export type LlamaCppPreflightResult =
11
+ | { ok: true; alreadyRunning: boolean }
12
+ | {
13
+ ok: false
14
+ code: LlamaCppStartFailureCode
15
+ message: string
16
+ detail?: string
17
+ servedModels?: string[]
18
+ }
19
+
20
+ export type LlamaCppPreflightDeps = {
21
+ fetchImpl?: typeof fetch
22
+ findLocalModel?: typeof findLocalHfModel
23
+ startServer?: typeof startLlamaCppServer
24
+ timeoutMs?: number
25
+ }
26
+
27
+ type ModelsProbe =
28
+ | { up: true; models: string[] }
29
+ | { up: false; models: [] }
30
+
31
+ export async function ensureLlamaCppRunnerReady(
32
+ config: EthagentConfig,
33
+ deps: LlamaCppPreflightDeps = {},
34
+ ): Promise<LlamaCppPreflightResult> {
35
+ if (config.provider !== 'llamacpp') return { ok: true, alreadyRunning: true }
36
+
37
+ const baseUrl = localProviderBaseUrlFor('llamacpp', config.baseUrl)
38
+ const local = await (deps.findLocalModel ?? findLocalHfModel)(config.model)
39
+ if (!local || local.status !== 'ready') {
40
+ return {
41
+ ok: false,
42
+ code: 'model-file-missing',
43
+ message: formatPreflightFailure(
44
+ 'local model is not imported',
45
+ config.model,
46
+ 'choose an imported Hugging Face GGUF model from view full catalog or add a local model file',
47
+ ),
48
+ }
49
+ }
50
+
51
+ const probe = await probeLlamaCppModels(baseUrl, deps)
52
+ if (probe.up) {
53
+ if (probe.models.length === 0 || probe.models.includes(config.model)) {
54
+ return { ok: true, alreadyRunning: true }
55
+ }
56
+ return {
57
+ ok: false,
58
+ code: 'different-model-running',
59
+ message: formatPreflightFailure(
60
+ 'local runner is serving a different model',
61
+ config.model,
62
+ `a different local model is already running (${probe.models.join(', ')}); stop it before switching models`,
63
+ ),
64
+ servedModels: probe.models,
65
+ }
66
+ }
67
+
68
+ const result = await (deps.startServer ?? startLlamaCppServer)({
69
+ modelPath: local.localPath,
70
+ modelAlias: local.id,
71
+ host: llamaCppServerHostFromBaseUrl(baseUrl),
72
+ })
73
+ if (result.ok) return { ok: true, alreadyRunning: result.alreadyRunning }
74
+ return withPreflightMessage(result, local)
75
+ }
76
+
77
+ export async function probeLlamaCppModels(
78
+ baseUrl: string,
79
+ deps: Pick<LlamaCppPreflightDeps, 'fetchImpl' | 'timeoutMs'> = {},
80
+ ): Promise<ModelsProbe> {
81
+ const controller = new AbortController()
82
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 800)
83
+ try {
84
+ const response = await (deps.fetchImpl ?? fetch)(llamaCppModelsEndpointForBaseUrl(baseUrl), {
85
+ signal: controller.signal,
86
+ })
87
+ if (!response.ok) return { up: false, models: [] }
88
+ const data = await response.json() as { data?: Array<{ id?: unknown }> }
89
+ return {
90
+ up: true,
91
+ models: (data.data ?? [])
92
+ .map(item => typeof item.id === 'string' ? item.id : '')
93
+ .filter(Boolean),
94
+ }
95
+ } catch {
96
+ return { up: false, models: [] }
97
+ } finally {
98
+ clearTimeout(timer)
99
+ }
100
+ }
101
+
102
+ export function llamaCppModelsEndpointForBaseUrl(baseUrl: string): string {
103
+ const url = new URL(baseUrl)
104
+ const path = stripTrailingSlash(url.pathname)
105
+ url.pathname = path.endsWith('/v1') ? `${path}/models` : `${path}/v1/models`
106
+ url.search = ''
107
+ url.hash = ''
108
+ return url.toString()
109
+ }
110
+
111
+ export function llamaCppServerHostFromBaseUrl(baseUrl: string): string {
112
+ const url = new URL(baseUrl)
113
+ const path = stripTrailingSlash(url.pathname)
114
+ url.pathname = path.endsWith('/v1') ? stripTrailingSlash(path.slice(0, -3)) || '/' : path || '/'
115
+ url.search = ''
116
+ url.hash = ''
117
+ return stripTrailingSlash(url.toString())
118
+ }
119
+
120
+ function withPreflightMessage(
121
+ result: Extract<LlamaCppStartResult, { ok: false }>,
122
+ local: LocalHfModel,
123
+ ): Extract<LlamaCppPreflightResult, { ok: false }> {
124
+ return {
125
+ ok: false,
126
+ code: result.code,
127
+ message: formatPreflightFailure(
128
+ 'local runner is not reachable',
129
+ local.id,
130
+ result.message,
131
+ local.displayName,
132
+ ),
133
+ detail: result.detail,
134
+ servedModels: result.servedModels,
135
+ }
136
+ }
137
+
138
+ function formatPreflightFailure(
139
+ prefix: string,
140
+ modelId: string,
141
+ reason: string,
142
+ displayName?: string,
143
+ ): string {
144
+ const model = formatModelDisplayName('llamacpp', modelId, { displayName, maxLength: 64 })
145
+ return `${prefix}; failed to start ${model}: ${reason}`
146
+ }
147
+
148
+ function stripTrailingSlash(value: string): string {
149
+ return value.replace(/\/+$/, '')
150
+ }
@@ -0,0 +1,105 @@
1
+ export type ModelDisplayProvider = string
2
+
3
+ type ModelDisplayOptions = {
4
+ maxLength?: number
5
+ displayName?: string
6
+ }
7
+
8
+ const DEFAULT_MODEL_DISPLAY_MAX = 64
9
+ const HF_SEPARATOR = ' / '
10
+
11
+ export function formatModelDisplayName(
12
+ provider: ModelDisplayProvider,
13
+ model: string,
14
+ options: ModelDisplayOptions = {},
15
+ ): string {
16
+ const maxLength = options.maxLength ?? DEFAULT_MODEL_DISPLAY_MAX
17
+ if (provider === 'llamacpp') {
18
+ return formatLocalHfModelDisplayName(model, {
19
+ maxLength,
20
+ displayName: options.displayName,
21
+ })
22
+ }
23
+ return truncateMiddle(model, maxLength)
24
+ }
25
+
26
+ export function formatLocalHfModelDisplayName(
27
+ modelId: string,
28
+ options: ModelDisplayOptions = {},
29
+ ): string {
30
+ const maxLength = options.maxLength ?? DEFAULT_MODEL_DISPLAY_MAX
31
+ const parsed = parseLocalHfModelId(modelId)
32
+ const label = options.displayName?.trim()
33
+ if (label) {
34
+ const parts = splitLocalHfDisplayName(label)
35
+ if (parts) return formatRepoAndFile(parts.repoId, parts.filename, maxLength)
36
+ if (parsed) return formatRepoAndFile(parsed.repoId, parsed.filename, maxLength)
37
+ return truncateMiddle(label, maxLength)
38
+ }
39
+
40
+ if (parsed) return formatRepoAndFile(parsed.repoId, parsed.filename, maxLength)
41
+ return truncateMiddle(modelId, maxLength)
42
+ }
43
+
44
+ export function truncateMiddle(value: string, maxLength: number): string {
45
+ if (maxLength <= 0) return ''
46
+ if (value.length <= maxLength) return value
47
+ if (maxLength <= 3) return value.slice(0, maxLength)
48
+ const remaining = maxLength - 3
49
+ const head = Math.ceil(remaining / 2)
50
+ const tail = Math.floor(remaining / 2)
51
+ return `${value.slice(0, head)}...${value.slice(value.length - tail)}`
52
+ }
53
+
54
+ function parseLocalHfModelId(modelId: string): { repoId: string; filename: string } | null {
55
+ const hash = modelId.indexOf('#')
56
+ if (hash <= 0 || hash === modelId.length - 1) return null
57
+ return {
58
+ repoId: modelId.slice(0, hash),
59
+ filename: modelId.slice(hash + 1),
60
+ }
61
+ }
62
+
63
+ function splitLocalHfDisplayName(label: string): { repoId: string; filename: string } | null {
64
+ const separator = label.indexOf(HF_SEPARATOR)
65
+ if (separator <= 0 || separator === label.length - HF_SEPARATOR.length) return null
66
+ return {
67
+ repoId: label.slice(0, separator),
68
+ filename: label.slice(separator + HF_SEPARATOR.length),
69
+ }
70
+ }
71
+
72
+ function formatRepoAndFile(repoId: string, filename: string, maxLength: number): string {
73
+ const file = friendlyFilename(filename)
74
+ const full = `${repoId}${HF_SEPARATOR}${file}`
75
+ if (full.length <= maxLength) return full
76
+
77
+ const separatorBudget = HF_SEPARATOR.length
78
+ const partBudget = maxLength - separatorBudget
79
+ if (partBudget <= 8) return truncateMiddle(full, maxLength)
80
+
81
+ let repoMax = Math.min(repoId.length, Math.max(8, Math.floor(partBudget * 0.45)))
82
+ let fileMax = partBudget - repoMax
83
+
84
+ if (fileMax > file.length) {
85
+ repoMax = Math.min(repoId.length, repoMax + fileMax - file.length)
86
+ fileMax = file.length
87
+ }
88
+ if (repoMax > repoId.length) {
89
+ fileMax = Math.min(file.length, fileMax + repoMax - repoId.length)
90
+ repoMax = repoId.length
91
+ }
92
+ if (fileMax < 8 && partBudget >= 16) {
93
+ fileMax = 8
94
+ repoMax = partBudget - fileMax
95
+ }
96
+
97
+ return truncateMiddle(
98
+ `${truncateMiddle(repoId, repoMax)}${HF_SEPARATOR}${truncateMiddle(file, fileMax)}`,
99
+ maxLength,
100
+ )
101
+ }
102
+
103
+ function friendlyFilename(filename: string): string {
104
+ return filename.split('/').pop() ?? filename
105
+ }
@@ -0,0 +1,421 @@
1
+ import { defaultModelFor, type ProviderId } from '../storage/config.js'
2
+ import { type ModelCatalogEntry, type ModelCatalogResult } from './catalog.js'
3
+ import type { HfRisk, HfTask } from './huggingface.js'
4
+ import type { SpecSnapshot } from './runtimeDetection.js'
5
+ import { contextWindowInfo } from '../runtime/compaction.js'
6
+ import { type SelectOption } from '../ui/Select.js'
7
+ import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
8
+ import { localModelId, quantizationFromFilename } from './huggingface.js'
9
+ import type { UncensoredCatalogEntry } from './uncensoredCatalog.js'
10
+
11
+ export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
12
+
13
+ export const MODEL_PICKER_CLOUD_PROVIDERS: CloudProviderId[] = ['openai', 'anthropic', 'gemini']
14
+ export const LOCAL_MODEL_LINK_HINT = 'paste a GGUF link'
15
+ export const LOCAL_MODEL_LINK_EXAMPLE = 'e.g. https://huggingface.co/Qwen/Qwen3-8B-GGUF'
16
+
17
+ export type LocalHfPickerModel = {
18
+ id: string
19
+ displayName: string
20
+ sizeBytes: number
21
+ quantization?: string
22
+ risk: HfRisk
23
+ task: HfTask
24
+ status: 'ready' | 'incomplete'
25
+ }
26
+
27
+ export type ModelPickerOptionsData = {
28
+ llamaCpp: {
29
+ binaryPresent: boolean
30
+ serverUp: boolean
31
+ error?: string
32
+ }
33
+ hfModels: LocalHfPickerModel[]
34
+ machineSpec?: SpecSnapshot
35
+ cloudKeys: Partial<Record<ProviderId, boolean>>
36
+ cloudCatalogs: Partial<Record<ProviderId, ModelCatalogResult>>
37
+ }
38
+
39
+ export type ModelPickerContextFit = {
40
+ usedTokens: number
41
+ thresholdPercent?: number
42
+ }
43
+
44
+ export type ModelPickerOptionsContext = {
45
+ currentProvider: ProviderId
46
+ currentModel: string
47
+ contextFit?: ModelPickerContextFit | null
48
+ }
49
+
50
+ const CURATED_CLOUD_MODEL_LIMIT = 3
51
+ const PROVIDER_INDENT = 2
52
+ const CHILD_INDENT = 4
53
+
54
+ export function buildModelPickerOptions(
55
+ data: ModelPickerOptionsData,
56
+ context: ModelPickerOptionsContext,
57
+ ): SelectOption<string>[] {
58
+ const options: SelectOption<string>[] = []
59
+
60
+ options.push(sectionOption('hdr:local', 'local models'))
61
+ appendHfModelOptions(options, data, context, 'added from links', 46)
62
+ options.push(utilityOption('hf:download', 'add local model file', LOCAL_MODEL_LINK_HINT))
63
+ options.push(utilityOption('local:catalog', 'view full catalog', 'from configured hugging face repo'))
64
+ if (data.hfModels.length > 0) {
65
+ options.push(utilityOption('local:uninstall', 'uninstall downloaded GGUF'))
66
+ }
67
+
68
+ options.push(sectionOption('hdr:cloud', 'cloud'))
69
+ for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
70
+ options.push(groupOption(`hdr:cloud:${provider}`, provider))
71
+ const keySet = data.cloudKeys[provider] === true
72
+ if (!keySet) {
73
+ options.push(utilityOption(`key:set:${provider}`, 'api key · add'))
74
+ continue
75
+ }
76
+
77
+ const catalog = data.cloudCatalogs[provider]
78
+ if (catalog?.status === 'fallback') {
79
+ const reason = catalog.error ? ` · ${catalog.error}` : ''
80
+ options.push(noticeOption(
81
+ `hdr:cloud-fallback:${provider}`,
82
+ `catalog unavailable${reason} · showing configured model`,
83
+ CHILD_INDENT,
84
+ ))
85
+ }
86
+
87
+ const models = orderModelsForContextFit(provider, cloudPickerModels(provider, catalog, context), context.contextFit)
88
+ if (models.length === 0) {
89
+ options.push(noticeOption(`hdr:cloud-empty:${provider}`, 'no selectable models', CHILD_INDENT))
90
+ }
91
+ for (const model of models) {
92
+ const active = context.currentProvider === provider && context.currentModel === model
93
+ const displayName = formatModelDisplayName(provider, model, { maxLength: 58 })
94
+ options.push(rowOption(
95
+ `c:${provider}:${model}`,
96
+ contextFitLabel(provider, model, `${displayName}${active ? ' *' : ''}`, context.contextFit),
97
+ ))
98
+ }
99
+ options.push(utilityOption(`catalog:${provider}`, 'full catalog'))
100
+ options.push(utilityOption(`key:manage:${provider}`, 'api key · manage'))
101
+ }
102
+
103
+ return options
104
+ }
105
+
106
+ export function buildLocalModelCatalogOptions(
107
+ data: ModelPickerOptionsData,
108
+ context: ModelPickerOptionsContext,
109
+ catalog: UncensoredCatalogEntry[] = [],
110
+ ): SelectOption<string>[] {
111
+ const options: SelectOption<string>[] = []
112
+ options.push(sectionOption('hdr:local-catalog', 'view full catalog'))
113
+ options.push(groupOption('hdr:uncensored:catalog', 'hugging face gguf files'))
114
+ if (catalog.length === 0) {
115
+ options.push(noticeOption('hdr:uncensored-empty', 'setup files unavailable; paste a GGUF link instead', CHILD_INDENT))
116
+ } else {
117
+ for (const entry of catalog) {
118
+ const id = localModelId(entry.repo.repoId, entry.file.filename)
119
+ const displayName = formatLocalHfModelDisplayName(id, {
120
+ displayName: entry.file.filename.split('/').pop() ?? entry.file.filename,
121
+ maxLength: 56,
122
+ })
123
+ const quant = quantLabel(entry.file.filename)
124
+ options.push(rowOption(
125
+ catalogOptionValue(entry.repo.repoId, entry.file.filename),
126
+ displayName,
127
+ undefined,
128
+ modelMetadataSubtext(`${quant} · ${formatSize(entry.file.sizeBytes ?? 0)}`, [
129
+ entry.recommended ? 'recommended for this machine' : '',
130
+ entry.installed ? 'installed' : '',
131
+ ]),
132
+ ))
133
+ }
134
+ }
135
+
136
+ appendHfModelOptions(options, data, context, 'downloaded GGUF', 50)
137
+ options.push(utilityOption('hf:download', 'add local model file', LOCAL_MODEL_LINK_HINT))
138
+
139
+ if (data.hfModels.length > 0) {
140
+ options.push(utilityOption('local:uninstall', 'uninstall downloaded GGUF'))
141
+ }
142
+ return options
143
+ }
144
+
145
+ function appendHfModelOptions(
146
+ options: SelectOption<string>[],
147
+ data: ModelPickerOptionsData,
148
+ context: ModelPickerOptionsContext,
149
+ groupLabel: string,
150
+ maxLength: number,
151
+ ): void {
152
+ options.push(groupOption('hdr:local:hf', groupLabel))
153
+ if (data.hfModels.length === 0) {
154
+ options.push(noticeOption('hdr:hf-empty', 'no downloaded files', CHILD_INDENT))
155
+ return
156
+ }
157
+
158
+ const models = orderModelsForContextFit(
159
+ 'llamacpp',
160
+ data.hfModels.map(model => model.id),
161
+ context.contextFit,
162
+ )
163
+ const byId = new Map(data.hfModels.map(model => [model.id, model]))
164
+ for (const id of models) {
165
+ const model = byId.get(id)
166
+ if (!model) continue
167
+ const active = context.currentProvider === 'llamacpp' && id === context.currentModel
168
+ const size = formatSize(model.sizeBytes)
169
+ const displayName = formatLocalHfModelDisplayName(id, {
170
+ displayName: model.displayName,
171
+ maxLength,
172
+ })
173
+ options.push(rowOption(
174
+ `hf:${id}`,
175
+ contextFitLabel('llamacpp', id, `${active ? '* ' : ' '}${displayName}`, context.contextFit),
176
+ undefined,
177
+ modelMetadataSubtext(size, ['installed']),
178
+ ))
179
+ }
180
+ }
181
+
182
+ export function catalogOptionValue(repoId: string, filename: string): string {
183
+ return `uc:${repoId}#${filename}`
184
+ }
185
+
186
+ export function cloudPickerModels(
187
+ provider: CloudProviderId,
188
+ catalog: ModelCatalogResult | undefined,
189
+ _context: ModelPickerOptionsContext,
190
+ ): string[] {
191
+ const entries = catalog?.entries ?? []
192
+ const discovered = catalog?.status === 'ok'
193
+ ? curateDiscoveredCloudEntries(provider, entries).map(entry => entry.id)
194
+ : entries.map(entry => entry.id)
195
+ const models = dedupeStrings(discovered)
196
+
197
+ if (catalog?.status !== 'ok' && entries.length === 0) {
198
+ appendUnique(models, defaultModelFor(provider))
199
+ }
200
+
201
+ return models
202
+ }
203
+
204
+ export function curateDiscoveredCloudEntries(
205
+ provider: CloudProviderId,
206
+ entries: ModelCatalogEntry[],
207
+ ): ModelCatalogEntry[] {
208
+ const unique = dedupeEntries(entries)
209
+ const eligible = unique.filter(entry => isCuratedModelCandidate(provider, entry.id))
210
+ return rankEntriesByRecency(eligible).slice(0, CURATED_CLOUD_MODEL_LIMIT).map(item => item.entry)
211
+ }
212
+
213
+ export function orderModelsForContextFit(
214
+ provider: ProviderId,
215
+ models: string[],
216
+ contextFit?: ModelPickerContextFit | null,
217
+ ): string[] {
218
+ if (!contextFit) return models
219
+ return models
220
+ .map((model, index) => ({ model, index, fit: modelContextFit(provider, model, contextFit) }))
221
+ .sort((a, b) => {
222
+ if (a.fit.fits !== b.fit.fits) return a.fit.fits ? -1 : 1
223
+ return b.fit.windowTokens - a.fit.windowTokens || a.index - b.index
224
+ })
225
+ .map(item => item.model)
226
+ }
227
+
228
+ function contextFitLabel(
229
+ provider: ProviderId,
230
+ model: string,
231
+ baseLabel: string,
232
+ contextFit?: ModelPickerContextFit | null,
233
+ ): string {
234
+ if (!contextFit) return baseLabel
235
+ const fit = modelContextFit(provider, model, contextFit)
236
+ return `${baseLabel} ${formatContextWindow(fit.windowTokens)} ctx ${fit.percent}%`
237
+ }
238
+
239
+ function modelContextFit(provider: ProviderId, model: string, contextFit: ModelPickerContextFit): {
240
+ fits: boolean
241
+ percent: number
242
+ windowTokens: number
243
+ } {
244
+ const windowTokens = contextWindowInfo(provider, model).tokens
245
+ const percent = windowTokens > 0 ? Math.round((contextFit.usedTokens / windowTokens) * 100) : 0
246
+ const threshold = contextFit.thresholdPercent ?? 90
247
+ return { fits: percent < threshold, percent, windowTokens }
248
+ }
249
+
250
+ function quantLabel(filename: string): string {
251
+ if (filename.toLowerCase().startsWith('mmproj-')) return 'Vision encoder'
252
+ return quantizationFromFilename(filename) ?? 'GGUF'
253
+ }
254
+
255
+ function sectionOption(value: string, label: string): SelectOption<string> {
256
+ return {
257
+ value,
258
+ label,
259
+ disabled: true,
260
+ role: 'section',
261
+ bold: true,
262
+ }
263
+ }
264
+
265
+ function groupOption(value: string, label: string): SelectOption<string> {
266
+ return {
267
+ value,
268
+ label,
269
+ disabled: true,
270
+ role: 'group',
271
+ bold: true,
272
+ indent: PROVIDER_INDENT,
273
+ }
274
+ }
275
+
276
+ function noticeOption(value: string, label: string, indent = 0): SelectOption<string> {
277
+ return {
278
+ value,
279
+ label,
280
+ disabled: true,
281
+ role: 'notice',
282
+ prefix: 'note',
283
+ indent,
284
+ }
285
+ }
286
+
287
+ function rowOption(value: string, label: string, hint?: string, subtext?: string): SelectOption<string> {
288
+ return {
289
+ value,
290
+ label,
291
+ subtext,
292
+ hint,
293
+ role: 'option',
294
+ indent: CHILD_INDENT,
295
+ }
296
+ }
297
+
298
+ function utilityOption(value: string, label: string, hint?: string): SelectOption<string> {
299
+ return {
300
+ value,
301
+ label,
302
+ hint,
303
+ role: 'utility',
304
+ indent: CHILD_INDENT,
305
+ }
306
+ }
307
+
308
+ function formatSize(bytes: number): string {
309
+ if (bytes <= 0) return ''
310
+ const gb = bytes / 1e9
311
+ if (gb >= 1) return `${gb.toFixed(1)} GB`
312
+ return `${Math.round(bytes / 1e6)} MB`
313
+ }
314
+
315
+ function modelMetadataSubtext(size: string, indicators: string[]): string | undefined {
316
+ return [size, ...indicators].filter(Boolean).join(' · ') || undefined
317
+ }
318
+
319
+ function formatContextWindow(tokens: number): string {
320
+ if (tokens >= 1_000_000) {
321
+ const millions = tokens / 1_000_000
322
+ return Number.isInteger(millions) ? `${millions}m` : `${millions.toFixed(1)}m`
323
+ }
324
+ if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
325
+ return String(tokens)
326
+ }
327
+
328
+ type RankedEntry = {
329
+ entry: ModelCatalogEntry
330
+ score: number
331
+ index: number
332
+ }
333
+
334
+ function rankEntriesByRecency(entries: ModelCatalogEntry[]): RankedEntry[] {
335
+ return entries
336
+ .map((entry, index) => ({
337
+ entry,
338
+ index,
339
+ score: recencyScore(entry.id),
340
+ }))
341
+ .sort((a, b) => b.score - a.score || a.index - b.index)
342
+ }
343
+
344
+ function recencyScore(id: string): number {
345
+ return dateScore(id) || versionScore(id)
346
+ }
347
+
348
+ function dateScore(id: string): number {
349
+ const iso = id.match(/(?:^|[-_])(\d{4})[-_](\d{2})[-_](\d{2})(?=$|[-_])/)
350
+ if (iso) return Number(`${iso[1]}${iso[2]}${iso[3]}`)
351
+ const compact = id.match(/(?:^|[-_])(\d{8})(?=$|[-_])/)
352
+ if (compact) return Number(compact[1])
353
+ const monthYear = id.match(/(?:^|[-_])(\d{1,2})[-_](\d{4})(?=$|[-_])/)
354
+ if (monthYear) return Number(`${monthYear[2]}${monthYear[1]?.padStart(2, '0')}00`)
355
+ return 0
356
+ }
357
+
358
+ function versionScore(id: string): number {
359
+ const match = id.match(/(?:^|[-_])(\d+(?:[.-]\d+){0,3})(?=$|[-_])/)
360
+ if (!match) return 0
361
+ const version = match[1]
362
+ if (!version) return 0
363
+ return version
364
+ .split(/[.-]/)
365
+ .slice(0, 4)
366
+ .reduce((score, part, index) => {
367
+ const value = Number.parseInt(part, 10)
368
+ if (!Number.isFinite(value)) return score
369
+ return score + value * Math.pow(100, 3 - index)
370
+ }, 0)
371
+ }
372
+
373
+ const CURATED_EXCLUDED_TOKENS = [
374
+ 'alpha',
375
+ 'beta',
376
+ 'deep-research',
377
+ 'dev',
378
+ 'experimental',
379
+ 'preview',
380
+ 'test',
381
+ ] as const
382
+
383
+ function isCuratedModelCandidate(provider: CloudProviderId, id: string): boolean {
384
+ const lower = id.toLowerCase()
385
+ if (provider === 'gemini' && !lower.startsWith('gemini-')) return false
386
+ return !CURATED_EXCLUDED_TOKENS.some(token => hasToken(lower, token))
387
+ }
388
+
389
+ function hasToken(id: string, token: string): boolean {
390
+ return new RegExp(`(^|[-_.])${escapeRegExp(token)}($|[-_.])`).test(id)
391
+ }
392
+
393
+ function escapeRegExp(value: string): string {
394
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
395
+ }
396
+
397
+ function dedupeEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] {
398
+ const seen = new Set<string>()
399
+ const out: ModelCatalogEntry[] = []
400
+ for (const entry of entries) {
401
+ if (seen.has(entry.id)) continue
402
+ seen.add(entry.id)
403
+ out.push(entry)
404
+ }
405
+ return out
406
+ }
407
+
408
+ function dedupeStrings(values: string[]): string[] {
409
+ const seen = new Set<string>()
410
+ const out: string[] = []
411
+ for (const value of values) {
412
+ if (seen.has(value)) continue
413
+ seen.add(value)
414
+ out.push(value)
415
+ }
416
+ return out
417
+ }
418
+
419
+ function appendUnique(values: string[], value: string): void {
420
+ if (!values.includes(value)) values.push(value)
421
+ }