ethagent 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -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,651 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+ import { getConfigDir, ensureConfigDir } from '../storage/config.js'
5
+ import { atomicWriteText } from '../storage/atomicWrite.js'
6
+
7
+ export type HfFileFormat = 'gguf' | 'safetensors' | 'pickle/bin' | 'unknown'
8
+ export type HfRuntime = 'llama.cpp runnable' | 'download-only' | 'unsupported'
9
+ export type HfTask = 'chat/instruct' | 'base' | 'code' | 'embedding' | 'vision' | 'unknown'
10
+ export type HfSizeClass = 'tiny' | 'small' | 'medium' | 'large'
11
+ export type HfRisk = 'low' | 'medium' | 'high'
12
+ export type HfCredibility = 'established' | 'normal' | 'low-signal'
13
+ export type LocalHfStatus = 'ready' | 'incomplete'
14
+
15
+ export type HuggingFaceRef = {
16
+ repoId: string
17
+ revision?: string
18
+ filename?: string
19
+ }
20
+
21
+ export type HuggingFaceSibling = {
22
+ filename: string
23
+ sizeBytes?: number
24
+ }
25
+
26
+ export type HuggingFaceRepoInfo = {
27
+ repoId: string
28
+ author?: string
29
+ sha?: string
30
+ license?: string
31
+ downloads?: number
32
+ likes?: number
33
+ lastModified?: string
34
+ tags: string[]
35
+ siblings: HuggingFaceSibling[]
36
+ }
37
+
38
+ export type HuggingFaceModelSearchItem = {
39
+ repoId: string
40
+ downloads?: number
41
+ likes?: number
42
+ lastModified?: string
43
+ tags: string[]
44
+ }
45
+
46
+ export type HfSafetyReview = {
47
+ risk: HfRisk
48
+ credibility: HfCredibility
49
+ format: HfFileFormat
50
+ runtime: HfRuntime
51
+ task: HfTask
52
+ sizeClass: HfSizeClass
53
+ quantization?: string
54
+ reasons: string[]
55
+ }
56
+
57
+ export type HfDownloadPlan = {
58
+ repo: HuggingFaceRepoInfo
59
+ repoId: string
60
+ requestedRevision: string
61
+ resolvedRevision: string
62
+ filename: string
63
+ sizeBytes: number
64
+ localPath: string
65
+ displayName: string
66
+ review: HfSafetyReview
67
+ }
68
+
69
+ export type LocalHfModel = {
70
+ id: string
71
+ provider: 'llamacpp'
72
+ repoId: string
73
+ requestedRevision: string
74
+ resolvedRevision: string
75
+ filename: string
76
+ displayName: string
77
+ localPath: string
78
+ sizeBytes: number
79
+ format: HfFileFormat
80
+ runtime: HfRuntime
81
+ task: HfTask
82
+ sizeClass: HfSizeClass
83
+ quantization?: string
84
+ risk: HfRisk
85
+ credibility: HfCredibility
86
+ license?: string
87
+ downloads?: number
88
+ likes?: number
89
+ reviewedAt: string
90
+ installedAt: string
91
+ status: LocalHfStatus
92
+ sha256?: string
93
+ }
94
+
95
+ export type HfDownloadProgress = {
96
+ status: string
97
+ completed?: number
98
+ total?: number
99
+ }
100
+
101
+ type FetchImpl = typeof fetch
102
+ type UninstallDeps = {
103
+ unlink?: (target: string) => Promise<void>
104
+ rmdir?: (target: string) => Promise<void>
105
+ }
106
+
107
+ type ModelInfoResponse = {
108
+ id?: unknown
109
+ author?: unknown
110
+ sha?: unknown
111
+ downloads?: unknown
112
+ likes?: unknown
113
+ lastModified?: unknown
114
+ tags?: unknown
115
+ cardData?: unknown
116
+ siblings?: unknown
117
+ }
118
+
119
+ type ModelSearchResponseItem = {
120
+ id?: unknown
121
+ modelId?: unknown
122
+ downloads?: unknown
123
+ likes?: unknown
124
+ lastModified?: unknown
125
+ tags?: unknown
126
+ }
127
+
128
+ const HF_BASE_URL = 'https://huggingface.co'
129
+ const DEFAULT_REVISION = 'main'
130
+ const COMMIT_RE = /^[a-f0-9]{40}$/i
131
+ const DOWNLOAD_PROGRESS_MIN_MS = 100
132
+ const DOWNLOAD_PROGRESS_MIN_BYTES = 16 * 1024 * 1024
133
+
134
+ export function getLocalHfModelsPath(): string {
135
+ return path.join(getConfigDir(), 'local-models.json')
136
+ }
137
+
138
+ export function getLocalHfCacheDir(): string {
139
+ return path.join(getConfigDir(), 'models', 'huggingface')
140
+ }
141
+
142
+ export async function loadLocalHfModels(): Promise<LocalHfModel[]> {
143
+ try {
144
+ const raw = await fs.readFile(getLocalHfModelsPath(), 'utf8')
145
+ const parsed = JSON.parse(raw) as unknown
146
+ if (!Array.isArray(parsed)) return []
147
+ return parsed.filter(isLocalHfModel)
148
+ } catch (err: unknown) {
149
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
150
+ return []
151
+ }
152
+ }
153
+
154
+ export async function saveLocalHfModels(models: LocalHfModel[]): Promise<void> {
155
+ await ensureConfigDir()
156
+ await atomicWriteText(getLocalHfModelsPath(), JSON.stringify(models, null, 2) + '\n')
157
+ }
158
+
159
+ export async function upsertLocalHfModel(model: LocalHfModel): Promise<void> {
160
+ const current = await loadLocalHfModels()
161
+ const next = [
162
+ model,
163
+ ...current.filter(existing => existing.id !== model.id),
164
+ ]
165
+ await saveLocalHfModels(next)
166
+ }
167
+
168
+ export async function findLocalHfModel(id: string): Promise<LocalHfModel | null> {
169
+ const models = await loadLocalHfModels()
170
+ return models.find(model => model.id === id) ?? null
171
+ }
172
+
173
+ export async function uninstallLocalHfModel(
174
+ id: string,
175
+ deps: UninstallDeps = {},
176
+ ): Promise<LocalHfModel | null> {
177
+ const models = await loadLocalHfModels()
178
+ const model = models.find(item => item.id === id)
179
+ if (!model) return null
180
+
181
+ const cacheRoot = path.resolve(getLocalHfCacheDir())
182
+ const modelPath = path.resolve(model.localPath)
183
+ const partialPath = path.resolve(`${model.localPath}.partial`)
184
+ if (!isPathInside(cacheRoot, modelPath) || !isPathInside(cacheRoot, partialPath)) {
185
+ throw new Error('refusing to uninstall a local model outside EthAgent model cache')
186
+ }
187
+
188
+ const unlink = deps.unlink ?? ((target: string) => fs.unlink(target))
189
+ const rmdir = deps.rmdir ?? ((target: string) => fs.rmdir(target))
190
+ await unlinkIfPresent(modelPath, unlink)
191
+ await unlinkIfPresent(partialPath, unlink)
192
+ await cleanupEmptyParents(path.dirname(modelPath), cacheRoot, rmdir)
193
+
194
+ await saveLocalHfModels(models.filter(item => item.id !== id))
195
+ return model
196
+ }
197
+
198
+ export function parseHuggingFaceRef(input: string): HuggingFaceRef {
199
+ const trimmed = input.trim()
200
+ if (!trimmed) throw new Error('Hugging Face model link or repo id is required')
201
+
202
+ if (/^https?:\/\//i.test(trimmed)) {
203
+ const url = new URL(trimmed)
204
+ const host = url.hostname.toLowerCase()
205
+ if (host !== 'huggingface.co' && host !== 'www.huggingface.co') {
206
+ throw new Error('expected a huggingface.co model link')
207
+ }
208
+ const parts = url.pathname.split('/').filter(Boolean)
209
+ if (parts.length < 2) throw new Error('expected a Hugging Face repo link')
210
+ const repoId = `${decodeURIComponent(parts[0]!)}/${decodeURIComponent(parts[1]!)}`
211
+ const mode = parts[2]
212
+ if (mode === 'blob' || mode === 'resolve' || mode === 'tree') {
213
+ const revision = parts[3] ? decodeURIComponent(parts[3]) : undefined
214
+ const filename = parts.length > 4
215
+ ? parts.slice(4).map(part => decodeURIComponent(part)).join('/')
216
+ : undefined
217
+ return { repoId, revision, filename }
218
+ }
219
+ return { repoId }
220
+ }
221
+
222
+ const withoutPrefix = trimmed.replace(/^hf:\/\//i, '')
223
+ const parts = withoutPrefix.split('/').filter(Boolean)
224
+ if (parts.length < 2) throw new Error('expected repo id like org/model or a huggingface.co link')
225
+ const repoId = `${parts[0]!}/${parts[1]!}`
226
+ let fileParts = parts.slice(2)
227
+ const mode = fileParts[0]
228
+ if ((mode === 'blob' || mode === 'resolve' || mode === 'tree') && fileParts.length >= 2) {
229
+ fileParts = fileParts.slice(2)
230
+ }
231
+ const filename = fileParts.length > 0 ? fileParts.join('/') : undefined
232
+ return { repoId, filename }
233
+ }
234
+
235
+ export async function fetchHuggingFaceRepoInfo(
236
+ ref: HuggingFaceRef,
237
+ fetchImpl: FetchImpl = fetch,
238
+ ): Promise<HuggingFaceRepoInfo> {
239
+ const url = new URL(`${HF_BASE_URL}/api/models/${encodeRepoPath(ref.repoId)}`)
240
+ url.searchParams.set('blobs', 'true')
241
+ if (ref.revision) url.searchParams.set('revision', ref.revision)
242
+ const response = await fetchImpl(url, { headers: { Accept: 'application/json' } })
243
+ if (!response.ok) {
244
+ if (response.status === 401 || response.status === 403) {
245
+ throw new Error('repo is gated or private')
246
+ }
247
+ if (response.status === 404) throw new Error('Hugging Face repo not found')
248
+ throw new Error(`Hugging Face API HTTP ${response.status}`)
249
+ }
250
+ const data = await response.json() as ModelInfoResponse
251
+ const tags = Array.isArray(data.tags)
252
+ ? data.tags.filter((tag): tag is string => typeof tag === 'string')
253
+ : []
254
+ const siblings = Array.isArray(data.siblings)
255
+ ? data.siblings.flatMap(sibling => parseSibling(sibling))
256
+ : []
257
+ return {
258
+ repoId: typeof data.id === 'string' ? data.id : ref.repoId,
259
+ author: typeof data.author === 'string' ? data.author : undefined,
260
+ sha: typeof data.sha === 'string' ? data.sha : undefined,
261
+ license: licenseFrom(data.cardData, tags),
262
+ downloads: typeof data.downloads === 'number' ? data.downloads : undefined,
263
+ likes: typeof data.likes === 'number' ? data.likes : undefined,
264
+ lastModified: typeof data.lastModified === 'string' ? data.lastModified : undefined,
265
+ tags,
266
+ siblings,
267
+ }
268
+ }
269
+
270
+ export async function searchHuggingFaceModels(
271
+ query: string,
272
+ options: { limit?: number; filter?: string } = {},
273
+ fetchImpl: FetchImpl = fetch,
274
+ ): Promise<HuggingFaceModelSearchItem[]> {
275
+ const url = new URL(`${HF_BASE_URL}/api/models`)
276
+ url.searchParams.set('search', query)
277
+ url.searchParams.set('sort', 'downloads')
278
+ url.searchParams.set('direction', '-1')
279
+ url.searchParams.set('limit', String(options.limit ?? 20))
280
+ if (options.filter) url.searchParams.set('filter', options.filter)
281
+ const response = await fetchImpl(url, { headers: { Accept: 'application/json' } })
282
+ if (!response.ok) throw new Error(`Hugging Face catalog HTTP ${response.status}`)
283
+ const data = await response.json() as unknown
284
+ if (!Array.isArray(data)) return []
285
+ return data.flatMap(parseSearchItem)
286
+ }
287
+
288
+ export function ggufFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
289
+ return repo.siblings
290
+ .filter(file => file.filename.toLowerCase().endsWith('.gguf'))
291
+ .sort((a, b) => a.filename.localeCompare(b.filename))
292
+ }
293
+
294
+ export async function createHfDownloadPlan(
295
+ input: string,
296
+ filename?: string,
297
+ deps: { fetchImpl?: FetchImpl; now?: () => Date } = {},
298
+ ): Promise<HfDownloadPlan> {
299
+ const ref = parseHuggingFaceRef(input)
300
+ const selectedFilename = filename?.trim() || ref.filename
301
+ const repo = await fetchHuggingFaceRepoInfo(ref, deps.fetchImpl)
302
+ const files = ggufFiles(repo)
303
+ if (files.length === 0) {
304
+ throw new Error('no compatible local model files found for this link')
305
+ }
306
+ const selected = selectedFilename
307
+ ? files.find(file => file.filename === selectedFilename)
308
+ : files[0]
309
+ if (!selected) {
310
+ throw new Error(`compatible file not found: ${selectedFilename}`)
311
+ }
312
+
313
+ const requestedRevision = ref.revision ?? DEFAULT_REVISION
314
+ const resolvedRevision = repo.sha || requestedRevision
315
+ const sizeBytes = selected.sizeBytes ?? 0
316
+ const review = reviewHfModel({
317
+ repo,
318
+ filename: selected.filename,
319
+ sizeBytes,
320
+ requestedRevision,
321
+ resolvedRevision,
322
+ })
323
+ return {
324
+ repo,
325
+ repoId: repo.repoId,
326
+ requestedRevision,
327
+ resolvedRevision,
328
+ filename: selected.filename,
329
+ sizeBytes,
330
+ localPath: localPathFor(repo.repoId, resolvedRevision, selected.filename),
331
+ displayName: displayNameFor(repo.repoId, selected.filename),
332
+ review,
333
+ }
334
+ }
335
+
336
+ export function reviewHfModel(args: {
337
+ repo: HuggingFaceRepoInfo
338
+ filename: string
339
+ sizeBytes: number
340
+ requestedRevision: string
341
+ resolvedRevision: string
342
+ }): HfSafetyReview {
343
+ const format = fileFormat(args.filename)
344
+ const runtime: HfRuntime =
345
+ format === 'gguf' ? 'llama.cpp runnable'
346
+ : format === 'safetensors' ? 'download-only'
347
+ : 'unsupported'
348
+ const quantization = quantizationFromFilename(args.filename)
349
+ const task = taskFor(args.repo, args.filename)
350
+ const sizeClass = sizeClassFor(args.sizeBytes)
351
+ const credibility = credibilityFor(args.repo)
352
+ const pinned = COMMIT_RE.test(args.requestedRevision) || COMMIT_RE.test(args.resolvedRevision)
353
+ const repoHasRiskyFiles = args.repo.siblings.some(file => fileFormat(file.filename) === 'pickle/bin')
354
+ const reasons: string[] = []
355
+
356
+ if (format !== 'gguf') reasons.push('selected file is not compatible with local chat')
357
+ if (!pinned) reasons.push('revision is mutable')
358
+ if (!args.repo.license) reasons.push('license is missing or unknown')
359
+ if (credibility === 'low-signal') reasons.push('repo has limited public usage signals')
360
+ if (repoHasRiskyFiles) reasons.push('repo also contains pickle/bin model files')
361
+
362
+ const risk: HfRisk =
363
+ format !== 'gguf'
364
+ ? 'high'
365
+ : repoHasRiskyFiles
366
+ ? 'medium'
367
+ : pinned && args.repo.license && credibility !== 'low-signal'
368
+ ? 'low'
369
+ : 'medium'
370
+
371
+ if (reasons.length === 0) reasons.push('compatible local model file with usable repo metadata')
372
+
373
+ return {
374
+ risk,
375
+ credibility,
376
+ format,
377
+ runtime,
378
+ task,
379
+ sizeClass,
380
+ quantization,
381
+ reasons,
382
+ }
383
+ }
384
+
385
+ export async function* downloadHfModel(
386
+ plan: HfDownloadPlan,
387
+ signal?: AbortSignal,
388
+ fetchImpl: FetchImpl = fetch,
389
+ ): AsyncIterable<HfDownloadProgress> {
390
+ if (plan.review.runtime !== 'llama.cpp runnable') {
391
+ throw new Error('selected file is not compatible with local chat')
392
+ }
393
+
394
+ await fs.mkdir(path.dirname(plan.localPath), { recursive: true })
395
+ const partialPath = `${plan.localPath}.partial`
396
+ const response = await fetchImpl(resolveUrl(plan.repoId, plan.resolvedRevision, plan.filename), { signal })
397
+ if (!response.ok || !response.body) {
398
+ throw new Error(response.ok ? 'empty download body' : `download HTTP ${response.status}`)
399
+ }
400
+
401
+ const total = Number.parseInt(response.headers.get('content-length') ?? '', 10)
402
+ const hash = createHash('sha256')
403
+ const handle = await fs.open(partialPath, 'w')
404
+ let completed = 0
405
+ let complete = false
406
+ let lastProgressAt = Date.now()
407
+ let lastProgressBytes = 0
408
+ yield { status: 'starting', completed, total: Number.isFinite(total) ? total : undefined }
409
+ try {
410
+ const reader = response.body.getReader()
411
+ while (true) {
412
+ const { done, value } = await reader.read()
413
+ if (done) break
414
+ if (signal?.aborted) throw new Error('cancelled')
415
+ const buffer = Buffer.from(value)
416
+ hash.update(buffer)
417
+ await handle.write(buffer)
418
+ completed += buffer.byteLength
419
+ const now = Date.now()
420
+ if (shouldReportDownloadProgress(completed, lastProgressBytes, now, lastProgressAt)) {
421
+ lastProgressAt = now
422
+ lastProgressBytes = completed
423
+ yield { status: 'downloading', completed, total: Number.isFinite(total) ? total : undefined }
424
+ }
425
+ }
426
+ complete = true
427
+ } finally {
428
+ await handle.close()
429
+ if (!complete) {
430
+ await fs.unlink(partialPath).catch(() => {})
431
+ }
432
+ }
433
+
434
+ await fs.rename(partialPath, plan.localPath)
435
+ await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready'))
436
+ yield { status: 'success', completed, total: Number.isFinite(total) ? total : completed }
437
+ }
438
+
439
+ export function shouldReportDownloadProgress(
440
+ completed: number,
441
+ lastCompleted: number,
442
+ nowMs: number,
443
+ lastReportedMs: number,
444
+ ): boolean {
445
+ return nowMs - lastReportedMs >= DOWNLOAD_PROGRESS_MIN_MS
446
+ || completed - lastCompleted >= DOWNLOAD_PROGRESS_MIN_BYTES
447
+ }
448
+
449
+ export function modelFromPlan(plan: HfDownloadPlan, sha256: string | undefined, status: LocalHfStatus): LocalHfModel {
450
+ const now = new Date().toISOString()
451
+ return {
452
+ id: localModelId(plan.repoId, plan.filename),
453
+ provider: 'llamacpp',
454
+ repoId: plan.repoId,
455
+ requestedRevision: plan.requestedRevision,
456
+ resolvedRevision: plan.resolvedRevision,
457
+ filename: plan.filename,
458
+ displayName: plan.displayName,
459
+ localPath: plan.localPath,
460
+ sizeBytes: plan.sizeBytes,
461
+ format: plan.review.format,
462
+ runtime: plan.review.runtime,
463
+ task: plan.review.task,
464
+ sizeClass: plan.review.sizeClass,
465
+ quantization: plan.review.quantization,
466
+ risk: plan.review.risk,
467
+ credibility: plan.review.credibility,
468
+ license: plan.repo.license,
469
+ downloads: plan.repo.downloads,
470
+ likes: plan.repo.likes,
471
+ reviewedAt: now,
472
+ installedAt: now,
473
+ status,
474
+ sha256,
475
+ }
476
+ }
477
+
478
+ export function localModelId(repoId: string, filename: string): string {
479
+ return `${repoId}#${filename}`
480
+ }
481
+
482
+ export function displayNameFor(repoId: string, filename: string): string {
483
+ const basename = filename.split('/').pop() ?? filename
484
+ return `${repoId} / ${basename}`
485
+ }
486
+
487
+ export function fileFormat(filename: string): HfFileFormat {
488
+ const lower = filename.toLowerCase()
489
+ if (lower.endsWith('.gguf')) return 'gguf'
490
+ if (lower.endsWith('.safetensors')) return 'safetensors'
491
+ if (/\.(bin|pt|pth|pkl|pickle)$/.test(lower)) return 'pickle/bin'
492
+ return 'unknown'
493
+ }
494
+
495
+ export function quantizationFromFilename(filename: string): string | undefined {
496
+ const match = filename.toUpperCase().match(/(?:^|[-_.])((?:IQ|Q)\d(?:_[A-Z0-9]+)*|F16|BF16|F32)(?=$|[-_.])/)
497
+ return match?.[1]
498
+ }
499
+
500
+ function localPathFor(repoId: string, revision: string, filename: string): string {
501
+ const repoParts = repoId.split('/').map(safePathPart)
502
+ const fileParts = filename.split('/').map(safePathPart)
503
+ return path.join(getLocalHfCacheDir(), ...repoParts, safePathPart(revision), ...fileParts)
504
+ }
505
+
506
+ async function unlinkIfPresent(
507
+ target: string,
508
+ unlink: (target: string) => Promise<void>,
509
+ ): Promise<void> {
510
+ try {
511
+ await unlink(target)
512
+ } catch (err: unknown) {
513
+ const code = (err as NodeJS.ErrnoException).code
514
+ if (code === 'ENOENT') return
515
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
516
+ throw new Error('that model file is currently in use. stop the local runner and try uninstall again.')
517
+ }
518
+ throw err
519
+ }
520
+ }
521
+
522
+ async function cleanupEmptyParents(
523
+ startDir: string,
524
+ cacheRoot: string,
525
+ rmdir: (target: string) => Promise<void>,
526
+ ): Promise<void> {
527
+ let current = path.resolve(startDir)
528
+ while (isPathInside(cacheRoot, current) && current !== cacheRoot) {
529
+ try {
530
+ await rmdir(current)
531
+ } catch (err: unknown) {
532
+ const code = (err as NodeJS.ErrnoException).code
533
+ if (code === 'ENOENT') {
534
+ current = path.dirname(current)
535
+ continue
536
+ }
537
+ if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'EPERM') return
538
+ throw err
539
+ }
540
+ current = path.dirname(current)
541
+ }
542
+ }
543
+
544
+ function isPathInside(root: string, target: string): boolean {
545
+ const relative = path.relative(root, target)
546
+ return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)
547
+ }
548
+
549
+ function resolveUrl(repoId: string, revision: string, filename: string): string {
550
+ return `${HF_BASE_URL}/${encodeRepoPath(repoId)}/resolve/${encodeURIComponent(revision)}/${encodePath(filename)}?download=true`
551
+ }
552
+
553
+ function encodeRepoPath(repoId: string): string {
554
+ return repoId.split('/').map(part => encodeURIComponent(part)).join('/')
555
+ }
556
+
557
+ function encodePath(value: string): string {
558
+ return value.split('/').map(part => encodeURIComponent(part)).join('/')
559
+ }
560
+
561
+ function safePathPart(value: string): string {
562
+ const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+$/, '_')
563
+ return cleaned || '_'
564
+ }
565
+
566
+ function parseSibling(value: unknown): HuggingFaceSibling[] {
567
+ if (!value || typeof value !== 'object') return []
568
+ const record = value as { rfilename?: unknown; filename?: unknown; size?: unknown; lfs?: unknown }
569
+ const filename = typeof record.rfilename === 'string'
570
+ ? record.rfilename
571
+ : typeof record.filename === 'string' ? record.filename : ''
572
+ if (!filename) return []
573
+ return [{
574
+ filename,
575
+ sizeBytes: siblingSizeBytes(record),
576
+ }]
577
+ }
578
+
579
+ function parseSearchItem(value: unknown): HuggingFaceModelSearchItem[] {
580
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return []
581
+ const item = value as ModelSearchResponseItem
582
+ const repoId = typeof item.id === 'string'
583
+ ? item.id
584
+ : typeof item.modelId === 'string' ? item.modelId : ''
585
+ if (!repoId.includes('/')) return []
586
+ return [{
587
+ repoId,
588
+ downloads: typeof item.downloads === 'number' ? item.downloads : undefined,
589
+ likes: typeof item.likes === 'number' ? item.likes : undefined,
590
+ lastModified: typeof item.lastModified === 'string' ? item.lastModified : undefined,
591
+ tags: Array.isArray(item.tags)
592
+ ? item.tags.filter((tag): tag is string => typeof tag === 'string')
593
+ : [],
594
+ }]
595
+ }
596
+
597
+ function siblingSizeBytes(record: { size?: unknown; lfs?: unknown }): number | undefined {
598
+ if (typeof record.size === 'number' && Number.isFinite(record.size) && record.size >= 0) {
599
+ return record.size
600
+ }
601
+ if (!record.lfs || typeof record.lfs !== 'object' || Array.isArray(record.lfs)) return undefined
602
+ const size = (record.lfs as { size?: unknown }).size
603
+ return typeof size === 'number' && Number.isFinite(size) && size >= 0 ? size : undefined
604
+ }
605
+
606
+ function licenseFrom(cardData: unknown, tags: string[]): string | undefined {
607
+ if (cardData && typeof cardData === 'object' && !Array.isArray(cardData)) {
608
+ const license = (cardData as { license?: unknown }).license
609
+ if (typeof license === 'string' && license.trim()) return license.trim()
610
+ }
611
+ const tag = tags.find(item => item.startsWith('license:'))
612
+ return tag ? tag.slice('license:'.length) : undefined
613
+ }
614
+
615
+ function credibilityFor(repo: HuggingFaceRepoInfo): HfCredibility {
616
+ const downloads = repo.downloads ?? 0
617
+ const likes = repo.likes ?? 0
618
+ if (downloads >= 10_000 || likes >= 100) return 'established'
619
+ if (downloads >= 100 || likes >= 5 || Boolean(repo.license)) return 'normal'
620
+ return 'low-signal'
621
+ }
622
+
623
+ function taskFor(repo: HuggingFaceRepoInfo, filename: string): HfTask {
624
+ const haystack = [repo.repoId, filename, ...repo.tags].join(' ').toLowerCase()
625
+ if (/(embed|embedding)/.test(haystack)) return 'embedding'
626
+ if (/(vision|vlm|multimodal)/.test(haystack)) return 'vision'
627
+ if (/(code|coder|coding)/.test(haystack)) return 'code'
628
+ if (/(chat|instruct|assistant)/.test(haystack)) return 'chat/instruct'
629
+ if (/(base)/.test(haystack)) return 'base'
630
+ return 'unknown'
631
+ }
632
+
633
+ function sizeClassFor(sizeBytes: number): HfSizeClass {
634
+ const gb = sizeBytes / 1e9
635
+ if (gb < 2) return 'tiny'
636
+ if (gb < 8) return 'small'
637
+ if (gb < 24) return 'medium'
638
+ return 'large'
639
+ }
640
+
641
+ function isLocalHfModel(value: unknown): value is LocalHfModel {
642
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
643
+ const item = value as Partial<LocalHfModel>
644
+ return item.provider === 'llamacpp'
645
+ && typeof item.id === 'string'
646
+ && typeof item.repoId === 'string'
647
+ && typeof item.filename === 'string'
648
+ && typeof item.displayName === 'string'
649
+ && typeof item.localPath === 'string'
650
+ && item.status === 'ready'
651
+ }