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.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fileFormat,
|
|
3
|
+
quantizationFromFilename,
|
|
4
|
+
type HuggingFaceRepoInfo,
|
|
5
|
+
type HuggingFaceSibling,
|
|
6
|
+
} from './huggingface.js'
|
|
7
|
+
import type { SpecSnapshot } from './runtimeDetection.js'
|
|
8
|
+
|
|
9
|
+
const GB = 1024 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
/** Featured local model repo for first-run setup and the model picker catalog. */
|
|
12
|
+
export const FEATURED_HF_REPO = 'HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive'
|
|
13
|
+
export const FEATURED_HF_REPO_URL = `https://huggingface.co/${FEATURED_HF_REPO}`
|
|
14
|
+
|
|
15
|
+
export type GgufMachineFit = 'fits' | 'tight' | 'too-large' | 'unknown'
|
|
16
|
+
|
|
17
|
+
export type GgufFileRecommendation = {
|
|
18
|
+
file: HuggingFaceSibling
|
|
19
|
+
fit: GgufMachineFit
|
|
20
|
+
score: number
|
|
21
|
+
budgetBytes: number
|
|
22
|
+
estimatedRequiredBytes?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function recommendGgufFile(
|
|
26
|
+
repo: HuggingFaceRepoInfo,
|
|
27
|
+
files: HuggingFaceSibling[],
|
|
28
|
+
spec: SpecSnapshot,
|
|
29
|
+
): GgufFileRecommendation | null {
|
|
30
|
+
return orderGgufFilesForSpec(repo, files, spec)[0] ?? null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function orderGgufFilesForSpec(
|
|
34
|
+
repo: HuggingFaceRepoInfo,
|
|
35
|
+
files: HuggingFaceSibling[],
|
|
36
|
+
spec: SpecSnapshot,
|
|
37
|
+
): GgufFileRecommendation[] {
|
|
38
|
+
return files
|
|
39
|
+
.filter(file => fileFormat(file.filename) === 'gguf')
|
|
40
|
+
.map(file => scoreGgufFile(repo, file, spec))
|
|
41
|
+
.sort((a, b) =>
|
|
42
|
+
b.score - a.score
|
|
43
|
+
|| fitRank(b.fit) - fitRank(a.fit)
|
|
44
|
+
|| (a.file.sizeBytes ?? Number.MAX_SAFE_INTEGER) - (b.file.sizeBytes ?? Number.MAX_SAFE_INTEGER)
|
|
45
|
+
|| a.file.filename.localeCompare(b.file.filename),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function estimateGgufMachineFit(sizeBytes: number | undefined, spec: SpecSnapshot): {
|
|
50
|
+
fit: GgufMachineFit
|
|
51
|
+
budgetBytes: number
|
|
52
|
+
estimatedRequiredBytes?: number
|
|
53
|
+
} {
|
|
54
|
+
const budgetBytes = ggufBudgetBytes(spec)
|
|
55
|
+
if (!sizeBytes || sizeBytes <= 0) return { fit: 'unknown', budgetBytes }
|
|
56
|
+
const estimatedRequiredBytes = Math.ceil(sizeBytes * 1.25 + GB)
|
|
57
|
+
if (estimatedRequiredBytes <= budgetBytes) return { fit: 'fits', budgetBytes, estimatedRequiredBytes }
|
|
58
|
+
if (estimatedRequiredBytes <= budgetBytes * 1.15) return { fit: 'tight', budgetBytes, estimatedRequiredBytes }
|
|
59
|
+
return { fit: 'too-large', budgetBytes, estimatedRequiredBytes }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scoreGgufFile(
|
|
63
|
+
repo: HuggingFaceRepoInfo,
|
|
64
|
+
file: HuggingFaceSibling,
|
|
65
|
+
spec: SpecSnapshot,
|
|
66
|
+
): GgufFileRecommendation {
|
|
67
|
+
const fit = estimateGgufMachineFit(file.sizeBytes, spec)
|
|
68
|
+
const lower = `${repo.repoId} ${file.filename} ${repo.tags.join(' ')}`.toLowerCase()
|
|
69
|
+
const score =
|
|
70
|
+
fitScore(fit.fit)
|
|
71
|
+
+ taskScore(lower)
|
|
72
|
+
+ quantizationScore(quantizationFromFilename(file.filename))
|
|
73
|
+
+ sizeScore(file.sizeBytes, fit.fit)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
file,
|
|
77
|
+
fit: fit.fit,
|
|
78
|
+
budgetBytes: fit.budgetBytes,
|
|
79
|
+
estimatedRequiredBytes: fit.estimatedRequiredBytes,
|
|
80
|
+
score,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ggufBudgetBytes(spec: SpecSnapshot): number {
|
|
85
|
+
if (spec.isAppleSilicon) return Math.floor(spec.effectiveRamBytes * 0.7)
|
|
86
|
+
const cpuBudget = Math.floor(spec.effectiveRamBytes * 0.55)
|
|
87
|
+
if (spec.gpuVramBytes !== null && spec.gpuVramBytes >= 8 * GB) {
|
|
88
|
+
return Math.max(cpuBudget, Math.floor(spec.gpuVramBytes * 0.85))
|
|
89
|
+
}
|
|
90
|
+
return cpuBudget
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fitScore(fit: GgufMachineFit): number {
|
|
94
|
+
switch (fit) {
|
|
95
|
+
case 'fits': return 1000
|
|
96
|
+
case 'tight': return 600
|
|
97
|
+
case 'unknown': return 250
|
|
98
|
+
case 'too-large': return -1000
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fitRank(fit: GgufMachineFit): number {
|
|
103
|
+
switch (fit) {
|
|
104
|
+
case 'fits': return 3
|
|
105
|
+
case 'tight': return 2
|
|
106
|
+
case 'unknown': return 1
|
|
107
|
+
case 'too-large': return 0
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function taskScore(text: string): number {
|
|
112
|
+
if (/(embed|embedding|rerank)/.test(text)) return -350
|
|
113
|
+
if (/(vision|vlm|multimodal)/.test(text)) return -250
|
|
114
|
+
if (/(instruct|chat|assistant)/.test(text)) return 250
|
|
115
|
+
if (/(code|coder|coding)/.test(text)) return 120
|
|
116
|
+
if (/(^|[-_\s])base($|[-_\s])/.test(text)) return -100
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function quantizationScore(quantization: string | undefined): number {
|
|
121
|
+
if (!quantization) return 0
|
|
122
|
+
if (quantization === 'Q8_0') return 210
|
|
123
|
+
if (quantization === 'Q6_K') return 195
|
|
124
|
+
if (quantization === 'Q5_K_M') return 185
|
|
125
|
+
if (quantization === 'Q5_K_S') return 175
|
|
126
|
+
if (quantization.startsWith('Q5')) return 165
|
|
127
|
+
if (quantization === 'Q4_K_M') return 155
|
|
128
|
+
if (quantization === 'Q4_K_S') return 140
|
|
129
|
+
if (quantization.startsWith('Q4')) return 125
|
|
130
|
+
if (quantization.startsWith('IQ4')) return 115
|
|
131
|
+
if (quantization.startsWith('IQ3') || quantization.startsWith('Q3')) return 85
|
|
132
|
+
if (quantization === 'F16' || quantization === 'BF16') return 150
|
|
133
|
+
if (quantization === 'F32') return 90
|
|
134
|
+
return 50
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sizeScore(sizeBytes: number | undefined, fit: GgufMachineFit): number {
|
|
138
|
+
if (!sizeBytes || fit === 'too-large') return 0
|
|
139
|
+
return Math.min(sizeBytes / GB, 20) * 4
|
|
140
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
export type SpecSnapshot = {
|
|
5
|
+
platform: NodeJS.Platform
|
|
6
|
+
arch: string
|
|
7
|
+
cpuCores: number
|
|
8
|
+
totalRamBytes: number
|
|
9
|
+
effectiveRamBytes: number
|
|
10
|
+
isAppleSilicon: boolean
|
|
11
|
+
gpuVramBytes: number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<{ code: number; stdout: string; stderr: string } | null> {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
let settled = false
|
|
17
|
+
let child: ReturnType<typeof spawn>
|
|
18
|
+
try {
|
|
19
|
+
child = spawn(cmd, args, { windowsHide: true })
|
|
20
|
+
} catch {
|
|
21
|
+
resolve(null)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
let stdout = ''
|
|
25
|
+
let stderr = ''
|
|
26
|
+
const timer = setTimeout(() => {
|
|
27
|
+
if (settled) return
|
|
28
|
+
settled = true
|
|
29
|
+
try { child.kill() } catch { void 0 }
|
|
30
|
+
resolve(null)
|
|
31
|
+
}, timeoutMs)
|
|
32
|
+
|
|
33
|
+
child.stdout?.on('data', d => { stdout += d.toString() })
|
|
34
|
+
child.stderr?.on('data', d => { stderr += d.toString() })
|
|
35
|
+
child.on('error', () => {
|
|
36
|
+
if (settled) return
|
|
37
|
+
settled = true
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
resolve(null)
|
|
40
|
+
})
|
|
41
|
+
child.on('close', code => {
|
|
42
|
+
if (settled) return
|
|
43
|
+
settled = true
|
|
44
|
+
clearTimeout(timer)
|
|
45
|
+
resolve({ code: code ?? -1, stdout, stderr })
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function detectNvidiaVram(): Promise<number | null> {
|
|
51
|
+
const result = await runCommand('nvidia-smi', ['--query-gpu=memory.total', '--format=csv,noheader,nounits'])
|
|
52
|
+
if (!result || result.code !== 0) return null
|
|
53
|
+
const firstLine = result.stdout.split('\n').map(l => l.trim()).find(Boolean)
|
|
54
|
+
if (!firstLine) return null
|
|
55
|
+
const mib = Number.parseInt(firstLine, 10)
|
|
56
|
+
if (!Number.isFinite(mib) || mib <= 0) return null
|
|
57
|
+
return mib * 1024 * 1024
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function detectSpec(): Promise<SpecSnapshot> {
|
|
61
|
+
const platform = process.platform
|
|
62
|
+
const arch = process.arch
|
|
63
|
+
const cpuCores = os.cpus().length
|
|
64
|
+
const totalRamBytes = os.totalmem()
|
|
65
|
+
const isAppleSilicon = platform === 'darwin' && arch === 'arm64'
|
|
66
|
+
const effectiveRamBytes = isAppleSilicon ? Math.floor(totalRamBytes * 0.75) : totalRamBytes
|
|
67
|
+
|
|
68
|
+
const [gpuVramBytes] = await Promise.all([
|
|
69
|
+
isAppleSilicon ? Promise.resolve(null) : detectNvidiaVram(),
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
platform,
|
|
74
|
+
arch,
|
|
75
|
+
cpuCores,
|
|
76
|
+
totalRamBytes,
|
|
77
|
+
effectiveRamBytes,
|
|
78
|
+
isAppleSilicon,
|
|
79
|
+
gpuVramBytes,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fetchHuggingFaceRepoInfo,
|
|
3
|
+
ggufFiles,
|
|
4
|
+
localModelId,
|
|
5
|
+
type HuggingFaceRepoInfo,
|
|
6
|
+
type HuggingFaceSibling,
|
|
7
|
+
type LocalHfModel,
|
|
8
|
+
} from './huggingface.js'
|
|
9
|
+
import {
|
|
10
|
+
FEATURED_HF_REPO,
|
|
11
|
+
estimateGgufMachineFit,
|
|
12
|
+
recommendGgufFile,
|
|
13
|
+
type GgufMachineFit,
|
|
14
|
+
} from './modelRecommendation.js'
|
|
15
|
+
import type { SpecSnapshot } from './runtimeDetection.js'
|
|
16
|
+
|
|
17
|
+
export type UncensoredCatalogEntry = {
|
|
18
|
+
repo: HuggingFaceRepoInfo
|
|
19
|
+
file: HuggingFaceSibling
|
|
20
|
+
fit: GgufMachineFit
|
|
21
|
+
recommended: boolean
|
|
22
|
+
installed: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type FetchImpl = typeof fetch
|
|
26
|
+
|
|
27
|
+
const GB = 1024 * 1024 * 1024
|
|
28
|
+
const FEATURED_FILE_ORDER = [
|
|
29
|
+
{ filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf', fallbackSizeBytes: Math.round(17 * GB) },
|
|
30
|
+
{ filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q8_0.gguf', fallbackSizeBytes: Math.round(8.9 * GB) },
|
|
31
|
+
{ filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf', fallbackSizeBytes: Math.round(6.9 * GB) },
|
|
32
|
+
{ filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf', fallbackSizeBytes: Math.round(5.3 * GB) },
|
|
33
|
+
{ filename: 'mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf', fallbackSizeBytes: Math.round(0.88 * GB) },
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
export async function fetchUncensoredGgufCatalog(args: {
|
|
37
|
+
machineSpec?: SpecSnapshot
|
|
38
|
+
installedModels: LocalHfModel[]
|
|
39
|
+
fetchImpl?: FetchImpl
|
|
40
|
+
limit?: number
|
|
41
|
+
}): Promise<UncensoredCatalogEntry[]> {
|
|
42
|
+
const fetchImpl = args.fetchImpl ?? fetch
|
|
43
|
+
const repos = await Promise.allSettled([
|
|
44
|
+
fetchHuggingFaceRepoInfo({ repoId: FEATURED_HF_REPO }, fetchImpl),
|
|
45
|
+
])
|
|
46
|
+
const installed = new Set(args.installedModels.filter(model => model.status === 'ready').map(model => model.id))
|
|
47
|
+
const entries: UncensoredCatalogEntry[] = []
|
|
48
|
+
|
|
49
|
+
for (const result of repos) {
|
|
50
|
+
if (result.status !== 'fulfilled') continue
|
|
51
|
+
const repo = result.value
|
|
52
|
+
if (repo.repoId !== FEATURED_HF_REPO) continue
|
|
53
|
+
const files = pickFeaturedFiles(repo)
|
|
54
|
+
if (files.length === 0) continue
|
|
55
|
+
const runnable = files.filter(file => !isVisionEncoder(file.filename))
|
|
56
|
+
const recommendedFilename = args.machineSpec
|
|
57
|
+
? recommendGgufFile(repo, runnable, args.machineSpec)?.file.filename
|
|
58
|
+
: undefined
|
|
59
|
+
for (const file of files.slice(0, args.limit ?? FEATURED_FILE_ORDER.length)) {
|
|
60
|
+
const recommended = recommendedFilename === file.filename
|
|
61
|
+
entries.push({
|
|
62
|
+
repo,
|
|
63
|
+
file,
|
|
64
|
+
fit: args.machineSpec ? estimateGgufMachineFit(file.sizeBytes, args.machineSpec).fit : 'unknown',
|
|
65
|
+
recommended,
|
|
66
|
+
installed: installed.has(localModelId(repo.repoId, file.filename)),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return entries
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function pickFeaturedFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
|
|
75
|
+
const byName = new Map(ggufFiles(repo).map(file => [file.filename, file] as const))
|
|
76
|
+
return FEATURED_FILE_ORDER
|
|
77
|
+
.map(spec => {
|
|
78
|
+
const file = byName.get(spec.filename)
|
|
79
|
+
if (file) return file
|
|
80
|
+
return { filename: spec.filename, sizeBytes: spec.fallbackSizeBytes } satisfies HuggingFaceSibling
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isVisionEncoder(filename: string): boolean {
|
|
85
|
+
return filename.toLowerCase().startsWith('mmproj-')
|
|
86
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { getKey } from '../storage/secrets.js'
|
|
2
|
+
import type { Message, MessageContentBlock, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
|
|
3
|
+
import { ProviderError } from './contracts.js'
|
|
4
|
+
import { providerErrorFromResponse } from './errors.js'
|
|
5
|
+
import { fetchWithRetryStreamEvents } from './retry.js'
|
|
6
|
+
import { iterSseEvents } from './sse.js'
|
|
7
|
+
|
|
8
|
+
export type AnthropicToolDefinition = {
|
|
9
|
+
name: string
|
|
10
|
+
description: string
|
|
11
|
+
input_schema: {
|
|
12
|
+
type: 'object'
|
|
13
|
+
properties?: Record<string, unknown>
|
|
14
|
+
required?: string[]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type AnthropicStreamMessage = {
|
|
19
|
+
type?: string
|
|
20
|
+
message?: {
|
|
21
|
+
usage?: {
|
|
22
|
+
input_tokens?: number
|
|
23
|
+
output_tokens?: number
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
usage?: {
|
|
27
|
+
input_tokens?: number
|
|
28
|
+
output_tokens?: number
|
|
29
|
+
}
|
|
30
|
+
delta?: {
|
|
31
|
+
type?: string
|
|
32
|
+
text?: string
|
|
33
|
+
thinking?: string
|
|
34
|
+
stop_reason?: string
|
|
35
|
+
partial_json?: string
|
|
36
|
+
}
|
|
37
|
+
content_block?: {
|
|
38
|
+
type?: string
|
|
39
|
+
id?: string
|
|
40
|
+
name?: string
|
|
41
|
+
input?: Record<string, unknown>
|
|
42
|
+
}
|
|
43
|
+
index?: number
|
|
44
|
+
error?: {
|
|
45
|
+
type?: string
|
|
46
|
+
message?: string
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ANTHROPIC_VERSION = '2023-06-01'
|
|
51
|
+
const READ_TIMEOUT_MS = 45_000
|
|
52
|
+
const DEFAULT_MAX_TOKENS = 4096
|
|
53
|
+
|
|
54
|
+
export class AnthropicProvider implements Provider {
|
|
55
|
+
readonly id = 'anthropic' as const
|
|
56
|
+
readonly model: string
|
|
57
|
+
readonly supportsTools: boolean
|
|
58
|
+
private readonly tools: AnthropicToolDefinition[]
|
|
59
|
+
|
|
60
|
+
constructor(opts: { model: string; tools?: AnthropicToolDefinition[] }) {
|
|
61
|
+
this.model = opts.model
|
|
62
|
+
this.tools = opts.tools ?? []
|
|
63
|
+
this.supportsTools = this.tools.length > 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async *complete(
|
|
67
|
+
messages: Message[],
|
|
68
|
+
signal: AbortSignal,
|
|
69
|
+
options: ProviderCompleteOptions = {},
|
|
70
|
+
): AsyncIterable<StreamEvent> {
|
|
71
|
+
const apiKey = await getKey('anthropic')
|
|
72
|
+
if (!apiKey) {
|
|
73
|
+
const error = new ProviderError('missing API key for anthropic (/doctor to verify)')
|
|
74
|
+
yield { type: 'error', message: error.message }
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { system, conversation } = splitMessages(messages)
|
|
79
|
+
|
|
80
|
+
let response: Response
|
|
81
|
+
try {
|
|
82
|
+
response = yield* fetchWithRetryStreamEvents('https://api.anthropic.com/v1/messages', {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: {
|
|
85
|
+
'content-type': 'application/json',
|
|
86
|
+
accept: 'text/event-stream',
|
|
87
|
+
'anthropic-version': ANTHROPIC_VERSION,
|
|
88
|
+
'x-api-key': apiKey,
|
|
89
|
+
},
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
model: this.model,
|
|
92
|
+
max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
93
|
+
stream: true,
|
|
94
|
+
system,
|
|
95
|
+
messages: conversation,
|
|
96
|
+
tools: this.tools.length > 0 ? this.tools : undefined,
|
|
97
|
+
}),
|
|
98
|
+
}, { signal, rateLimitResetProvider: 'anthropic' })
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
if (signal.aborted) return
|
|
101
|
+
yield { type: 'error', message: (err as Error).message || 'network error' }
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const error = await providerErrorFromResponse(this.id, response)
|
|
107
|
+
yield { type: 'error', message: error.message }
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (!response.body) {
|
|
111
|
+
yield { type: 'error', message: 'empty response body' }
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let inputTokens: number | undefined
|
|
116
|
+
let outputTokens: number | undefined
|
|
117
|
+
let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' = 'unknown'
|
|
118
|
+
const toolBuffers = new Map<number, { id: string; name: string; json: string }>()
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
for await (const frame of iterSseEvents(response.body, signal, READ_TIMEOUT_MS)) {
|
|
122
|
+
const eventType = frame.event
|
|
123
|
+
let parsed: AnthropicStreamMessage
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(frame.data) as AnthropicStreamMessage
|
|
126
|
+
} catch {
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const type = parsed.type ?? eventType ?? ''
|
|
131
|
+
if (type === 'message_start') {
|
|
132
|
+
inputTokens = parsed.message?.usage?.input_tokens ?? inputTokens
|
|
133
|
+
outputTokens = parsed.message?.usage?.output_tokens ?? outputTokens
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (type === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
|
|
137
|
+
const id = parsed.content_block.id ?? `tool-${parsed.index ?? 0}`
|
|
138
|
+
const name = parsed.content_block.name ?? 'unknown'
|
|
139
|
+
const json = parsed.content_block.input ? JSON.stringify(parsed.content_block.input) : ''
|
|
140
|
+
toolBuffers.set(parsed.index ?? 0, { id, name, json })
|
|
141
|
+
yield { type: 'tool_use_start', id, name }
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (type === 'content_block_delta') {
|
|
145
|
+
if (parsed.delta?.type === 'text_delta' && parsed.delta.text) {
|
|
146
|
+
yield { type: 'text', delta: parsed.delta.text }
|
|
147
|
+
} else if (parsed.delta?.type === 'thinking_delta' && parsed.delta.thinking) {
|
|
148
|
+
yield { type: 'thinking', delta: parsed.delta.thinking }
|
|
149
|
+
} else if (parsed.delta?.type === 'input_json_delta' && typeof parsed.delta.partial_json === 'string') {
|
|
150
|
+
const buffer = toolBuffers.get(parsed.index ?? 0)
|
|
151
|
+
if (buffer) {
|
|
152
|
+
buffer.json += parsed.delta.partial_json
|
|
153
|
+
yield { type: 'tool_use_delta', id: buffer.id, delta: parsed.delta.partial_json }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (type === 'content_block_stop') {
|
|
159
|
+
const buffer = toolBuffers.get(parsed.index ?? 0)
|
|
160
|
+
if (buffer) {
|
|
161
|
+
let input: Record<string, unknown> = {}
|
|
162
|
+
try {
|
|
163
|
+
input = buffer.json.trim() ? JSON.parse(buffer.json) as Record<string, unknown> : {}
|
|
164
|
+
} catch {
|
|
165
|
+
input = {}
|
|
166
|
+
}
|
|
167
|
+
yield { type: 'tool_use_stop', id: buffer.id, name: buffer.name, input }
|
|
168
|
+
toolBuffers.delete(parsed.index ?? 0)
|
|
169
|
+
}
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
if (type === 'message_delta') {
|
|
173
|
+
inputTokens = parsed.usage?.input_tokens ?? inputTokens
|
|
174
|
+
outputTokens = parsed.usage?.output_tokens ?? outputTokens
|
|
175
|
+
stopReason = normalizeStopReason(parsed.delta?.stop_reason)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (type === 'error') {
|
|
179
|
+
const message = parsed.error?.message || 'anthropic stream error'
|
|
180
|
+
const transient = parsed.error?.type === 'overloaded_error' || parsed.error?.type === 'rate_limit_error'
|
|
181
|
+
throw new ProviderError(message, { transient })
|
|
182
|
+
}
|
|
183
|
+
if (type === 'message_stop') {
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} catch (err: unknown) {
|
|
188
|
+
if (signal.aborted) return
|
|
189
|
+
yield { type: 'error', message: (err as Error).message || 'stream error' }
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (signal.aborted) return
|
|
194
|
+
yield { type: 'done', inputTokens, outputTokens, stopReason }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function splitMessages(messages: Message[]): {
|
|
199
|
+
system?: string
|
|
200
|
+
conversation: Array<{
|
|
201
|
+
role: 'user' | 'assistant'
|
|
202
|
+
content: Array<
|
|
203
|
+
| { type: 'text'; text: string }
|
|
204
|
+
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
205
|
+
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
206
|
+
>
|
|
207
|
+
}>
|
|
208
|
+
} {
|
|
209
|
+
const systemParts: string[] = []
|
|
210
|
+
const conversation: Array<{
|
|
211
|
+
role: 'user' | 'assistant'
|
|
212
|
+
content: Array<
|
|
213
|
+
| { type: 'text'; text: string }
|
|
214
|
+
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
215
|
+
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
216
|
+
>
|
|
217
|
+
}> = []
|
|
218
|
+
|
|
219
|
+
for (const message of messages) {
|
|
220
|
+
const blocks = normalizeBlocks(message.content)
|
|
221
|
+
if (blocks.length === 0) continue
|
|
222
|
+
if (message.role === 'system') {
|
|
223
|
+
const systemText = blocks.filter(block => block.type === 'text').map(block => block.text).join('\n\n').trim()
|
|
224
|
+
if (systemText) systemParts.push(systemText)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
conversation.push({
|
|
228
|
+
role: message.role,
|
|
229
|
+
content: blocks.map(block => {
|
|
230
|
+
if (block.type === 'text') return { type: 'text', text: block.text }
|
|
231
|
+
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
232
|
+
return { type: 'tool_result', tool_use_id: block.toolUseId, content: block.content, is_error: block.isError }
|
|
233
|
+
}),
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
system: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
|
|
239
|
+
conversation,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
|
|
244
|
+
if (typeof content === 'string') {
|
|
245
|
+
const text = content.trim()
|
|
246
|
+
return text ? [{ type: 'text', text }] : []
|
|
247
|
+
}
|
|
248
|
+
return content.filter(block => {
|
|
249
|
+
if (block.type === 'text') return block.text.trim().length > 0
|
|
250
|
+
return true
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function normalizeStopReason(value?: string): 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' {
|
|
255
|
+
if (value === 'end_turn' || value === 'tool_use' || value === 'max_tokens' || value === 'stop_sequence') {
|
|
256
|
+
return value
|
|
257
|
+
}
|
|
258
|
+
return 'unknown'
|
|
259
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ProviderId } from '../storage/config.js'
|
|
2
|
+
import type { RetryEvent } from '../utils/withRetry.js'
|
|
3
|
+
|
|
4
|
+
export type Role = 'system' | 'user' | 'assistant'
|
|
5
|
+
|
|
6
|
+
export type TextBlock = {
|
|
7
|
+
type: 'text'
|
|
8
|
+
text: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type ToolUseBlock = {
|
|
12
|
+
type: 'tool_use'
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
input: Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ToolResultBlock = {
|
|
19
|
+
type: 'tool_result'
|
|
20
|
+
toolUseId: string
|
|
21
|
+
content: string
|
|
22
|
+
isError?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MessageContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
|
|
26
|
+
|
|
27
|
+
export type Message = {
|
|
28
|
+
role: Role
|
|
29
|
+
content: string | MessageContentBlock[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ProviderRetryStreamEvent = { type: 'retry' } & RetryEvent
|
|
33
|
+
|
|
34
|
+
export type StreamEvent =
|
|
35
|
+
| { type: 'text'; delta: string }
|
|
36
|
+
| { type: 'thinking'; delta: string }
|
|
37
|
+
| ProviderRetryStreamEvent
|
|
38
|
+
| { type: 'tool_use_start'; id: string; name: string }
|
|
39
|
+
| { type: 'tool_use_delta'; id: string; delta: string }
|
|
40
|
+
| { type: 'tool_use_stop'; id: string; name: string; input: Record<string, unknown> }
|
|
41
|
+
| { type: 'done'; inputTokens?: number; outputTokens?: number; stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' }
|
|
42
|
+
| { type: 'error'; message: string }
|
|
43
|
+
|
|
44
|
+
export type ProviderCompleteOptions = {
|
|
45
|
+
maxTokens?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Provider {
|
|
49
|
+
readonly id: ProviderId
|
|
50
|
+
readonly model: string
|
|
51
|
+
readonly supportsTools: boolean
|
|
52
|
+
complete(messages: Message[], signal: AbortSignal, options?: ProviderCompleteOptions): AsyncIterable<StreamEvent>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class ProviderError extends Error {
|
|
56
|
+
readonly transient: boolean
|
|
57
|
+
constructor(message: string, options: { transient?: boolean } = {}) {
|
|
58
|
+
super(message)
|
|
59
|
+
this.name = 'ProviderError'
|
|
60
|
+
this.transient = options.transient ?? false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ProviderId } from '../storage/config.js'
|
|
2
|
+
import { ProviderError } from './contracts.js'
|
|
3
|
+
|
|
4
|
+
type ErrorBody =
|
|
5
|
+
| string
|
|
6
|
+
| {
|
|
7
|
+
error?: {
|
|
8
|
+
message?: string
|
|
9
|
+
type?: string
|
|
10
|
+
}
|
|
11
|
+
message?: string
|
|
12
|
+
detail?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function providerErrorFromResponse(
|
|
16
|
+
provider: ProviderId,
|
|
17
|
+
response: Response,
|
|
18
|
+
): Promise<ProviderError> {
|
|
19
|
+
const detail = await readErrorDetail(response)
|
|
20
|
+
|
|
21
|
+
if (provider !== 'llamacpp') {
|
|
22
|
+
if (response.status === 401 || response.status === 403) {
|
|
23
|
+
return new ProviderError(`auth failed: check your ${provider} key (/doctor to verify)`)
|
|
24
|
+
}
|
|
25
|
+
if (response.status === 429) {
|
|
26
|
+
return new ProviderError(detail || `${provider} rate limit exceeded`, { transient: true })
|
|
27
|
+
}
|
|
28
|
+
if (response.status >= 500) {
|
|
29
|
+
return new ProviderError(detail || `${provider} server error (${response.status})`, { transient: true })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new ProviderError(
|
|
34
|
+
detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`,
|
|
35
|
+
{ transient: response.status === 429 || response.status >= 500 },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readErrorDetail(response: Response): Promise<string> {
|
|
40
|
+
let text = ''
|
|
41
|
+
try {
|
|
42
|
+
text = (await response.text()).trim()
|
|
43
|
+
} catch {
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
if (!text) return ''
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(text) as ErrorBody
|
|
50
|
+
const nestedMessage =
|
|
51
|
+
typeof parsed === 'object' && parsed !== null
|
|
52
|
+
? parsed.error?.message ?? parsed.message ?? parsed.detail ?? ''
|
|
53
|
+
: ''
|
|
54
|
+
return normalizeDetail(nestedMessage || text)
|
|
55
|
+
} catch {
|
|
56
|
+
return normalizeDetail(text)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeDetail(detail: string): string {
|
|
61
|
+
return detail.replace(/\s+/g, ' ').trim().slice(0, 400)
|
|
62
|
+
}
|