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,673 @@
|
|
|
1
|
+
import type { EthagentConfig, ProviderId } from '../storage/config.js'
|
|
2
|
+
import { getConfigPath, localProviderBaseUrlFor, saveConfig } from '../storage/config.js'
|
|
3
|
+
import { detectLlamaCpp } from '../models/llamacpp.js'
|
|
4
|
+
import { detectSpec } from '../models/runtimeDetection.js'
|
|
5
|
+
import { hasKey } from '../storage/secrets.js'
|
|
6
|
+
import {
|
|
7
|
+
clearIdentity,
|
|
8
|
+
getIdentityStatus,
|
|
9
|
+
} from '../storage/identity.js'
|
|
10
|
+
import { discoverProviderModels, type ModelCatalogResult } from '../models/catalog.js'
|
|
11
|
+
import { getLocalHfCacheDir, loadLocalHfModels } from '../models/huggingface.js'
|
|
12
|
+
import { copyToClipboard } from '../utils/clipboard.js'
|
|
13
|
+
import { parseSegments } from '../utils/markdownSegments.js'
|
|
14
|
+
import { exportSessionMarkdown } from '../storage/sessionExport.js'
|
|
15
|
+
import { rewindWorkspaceEdits } from '../storage/rewind.js'
|
|
16
|
+
import type { SessionMessage } from '../storage/sessions.js'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { setCwd } from '../runtime/cwd.js'
|
|
19
|
+
import type { SessionMode } from '../runtime/sessionMode.js'
|
|
20
|
+
import type { ContextUsage } from '../runtime/compaction.js'
|
|
21
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
22
|
+
import type { McpManager } from '../mcp/manager.js'
|
|
23
|
+
|
|
24
|
+
export type IdentityRequestAction =
|
|
25
|
+
| 'manage'
|
|
26
|
+
| 'create'
|
|
27
|
+
| 'load'
|
|
28
|
+
|
|
29
|
+
export type SlashContext = {
|
|
30
|
+
config: EthagentConfig
|
|
31
|
+
turns: number
|
|
32
|
+
approxTokens: number
|
|
33
|
+
contextUsage: ContextUsage
|
|
34
|
+
startedAt: number
|
|
35
|
+
sessionId: string
|
|
36
|
+
cwd: string
|
|
37
|
+
mode: SessionMode
|
|
38
|
+
sessionMessages: () => SessionMessage[]
|
|
39
|
+
assistantTurns: () => string[]
|
|
40
|
+
onReplaceConfig: (next: EthagentConfig) => void
|
|
41
|
+
onChangeCwd: (next: string) => void
|
|
42
|
+
onClear: () => void
|
|
43
|
+
onExit: () => void
|
|
44
|
+
onResumeRequest: () => void
|
|
45
|
+
onModelPickerRequest: () => void
|
|
46
|
+
onRewindRequest: () => void
|
|
47
|
+
onPermissionsRequest: () => void
|
|
48
|
+
onCompactRequest: () => void
|
|
49
|
+
onIdentityRequest: (action?: IdentityRequestAction) => void
|
|
50
|
+
onCopyPickerRequest: (turnText: string, turnLabel: string) => void
|
|
51
|
+
mcp?: McpManager
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SlashResult =
|
|
55
|
+
| { kind: 'note'; text: string; variant?: 'info' | 'error' | 'dim' }
|
|
56
|
+
| { kind: 'submit'; text: string }
|
|
57
|
+
| { kind: 'handled' }
|
|
58
|
+
|
|
59
|
+
type CommandSpec = {
|
|
60
|
+
name: string
|
|
61
|
+
aliases?: string[]
|
|
62
|
+
summary: string
|
|
63
|
+
hidden?: boolean
|
|
64
|
+
requiresArgs?: boolean
|
|
65
|
+
blockedInPlan?: boolean
|
|
66
|
+
enterBehavior?: 'execute' | 'fill'
|
|
67
|
+
run: (args: string, ctx: SlashContext) => Promise<SlashResult> | SlashResult
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type SlashSuggestion = {
|
|
71
|
+
name: string
|
|
72
|
+
summary: string
|
|
73
|
+
completion: string
|
|
74
|
+
executeOnEnter: boolean
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseSlash(input: string): { name: string; args: string } | null {
|
|
78
|
+
const trimmed = input.trim()
|
|
79
|
+
if (!trimmed.startsWith('/')) return null
|
|
80
|
+
const body = trimmed.slice(1)
|
|
81
|
+
const spaceIdx = body.search(/\s/)
|
|
82
|
+
if (spaceIdx === -1) return { name: body.toLowerCase(), args: '' }
|
|
83
|
+
return {
|
|
84
|
+
name: body.slice(0, spaceIdx).toLowerCase(),
|
|
85
|
+
args: body.slice(spaceIdx + 1).trim(),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const COMMANDS: CommandSpec[] = [
|
|
90
|
+
{
|
|
91
|
+
name: 'help',
|
|
92
|
+
summary: 'show this list',
|
|
93
|
+
run: () => ({ kind: 'note', text: renderHelp() }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'exit',
|
|
97
|
+
aliases: ['quit'],
|
|
98
|
+
summary: 'exit the agent',
|
|
99
|
+
run: (_args, ctx) => {
|
|
100
|
+
ctx.onExit()
|
|
101
|
+
return { kind: 'handled' }
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: 'new',
|
|
106
|
+
aliases: ['clear'],
|
|
107
|
+
summary: 'clear the transcript and start a new session',
|
|
108
|
+
run: (_args, ctx) => {
|
|
109
|
+
ctx.onClear()
|
|
110
|
+
return { kind: 'handled' }
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'status',
|
|
115
|
+
summary: 'provider, model, session id, turns, context, elapsed',
|
|
116
|
+
run: (_args, ctx) => ({ kind: 'note', text: renderStatus(ctx) }),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'context',
|
|
120
|
+
summary: 'show model context usage and compaction options',
|
|
121
|
+
run: (_args, ctx) => ({ kind: 'note', text: renderContext(ctx) }),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'cd',
|
|
125
|
+
requiresArgs: true,
|
|
126
|
+
enterBehavior: 'fill',
|
|
127
|
+
summary: 'change working directory · /cd <path>',
|
|
128
|
+
run: async (args, ctx) => {
|
|
129
|
+
const target = args.trim()
|
|
130
|
+
if (!target) return { kind: 'note', variant: 'error', text: 'usage: /cd <path>' }
|
|
131
|
+
try {
|
|
132
|
+
const next = setCwd(target, ctx.cwd)
|
|
133
|
+
ctx.onChangeCwd(next)
|
|
134
|
+
return { kind: 'note', text: `cwd: ${next}`, variant: 'dim' }
|
|
135
|
+
} catch (err: unknown) {
|
|
136
|
+
return { kind: 'note', variant: 'error', text: `cd failed: ${(err as Error).message}` }
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: 'config',
|
|
142
|
+
summary: 'show resolved config',
|
|
143
|
+
run: (_args, ctx) => ({
|
|
144
|
+
kind: 'note',
|
|
145
|
+
text: `${JSON.stringify(ctx.config, null, 2)}\npath: ${getConfigPath()}`,
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'models',
|
|
150
|
+
summary: 'list models for the current provider',
|
|
151
|
+
run: async (_args, ctx) => {
|
|
152
|
+
if (ctx.config.provider === 'llamacpp') {
|
|
153
|
+
const installed = await loadLocalHfModels()
|
|
154
|
+
if (installed.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
kind: 'note',
|
|
157
|
+
text: 'no local model files downloaded. open alt+p and choose "add local model file".',
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const lines = installed.map(m => {
|
|
161
|
+
const marker = m.id === ctx.config.model ? '*' : ' '
|
|
162
|
+
const q = m.quantization ? ` ${m.quantization}` : ''
|
|
163
|
+
const displayName = formatModelDisplayName('llamacpp', m.id, { displayName: m.displayName, maxLength: 64 })
|
|
164
|
+
return `${marker} ${displayName}${q} ${formatBytes(m.sizeBytes)} ${m.risk}`
|
|
165
|
+
})
|
|
166
|
+
return { kind: 'note', text: ['installed Hugging Face models:', ...lines].join('\n') }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const catalog = await discoverProviderModels(ctx.config)
|
|
170
|
+
return {
|
|
171
|
+
kind: 'note',
|
|
172
|
+
text: renderModelCatalog(catalog, ctx.config.model),
|
|
173
|
+
variant: catalog.status === 'fallback' ? 'dim' : 'info',
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
name: 'model',
|
|
179
|
+
enterBehavior: 'fill',
|
|
180
|
+
summary: 'open picker or switch model · /model [name]',
|
|
181
|
+
run: async (args, ctx) => {
|
|
182
|
+
const name = args.trim()
|
|
183
|
+
if (!name) {
|
|
184
|
+
ctx.onModelPickerRequest()
|
|
185
|
+
return { kind: 'handled' }
|
|
186
|
+
}
|
|
187
|
+
if (ctx.config.provider === 'llamacpp') {
|
|
188
|
+
const installed = await loadLocalHfModels()
|
|
189
|
+
if (!installed.some(m => m.id === name)) {
|
|
190
|
+
return {
|
|
191
|
+
kind: 'note',
|
|
192
|
+
variant: 'error',
|
|
193
|
+
text: `'${name}' is not downloaded. open alt+p and choose "view full catalog" or "add local model file".`,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
const catalog = await discoverProviderModels(ctx.config)
|
|
198
|
+
if (catalog.status === 'ok' && !catalog.entries.some(entry => entry.id === name)) {
|
|
199
|
+
return {
|
|
200
|
+
kind: 'note',
|
|
201
|
+
variant: 'error',
|
|
202
|
+
text: `'${name}' was not found for ${ctx.config.provider}. use /models to inspect available models.`,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const next: EthagentConfig = {
|
|
207
|
+
...ctx.config,
|
|
208
|
+
model: name,
|
|
209
|
+
baseUrl: baseUrlForModelSwitch(ctx.config),
|
|
210
|
+
}
|
|
211
|
+
await saveConfig(next)
|
|
212
|
+
ctx.onReplaceConfig(next)
|
|
213
|
+
return { kind: 'note', text: `now using ${next.provider} · ${formatModelDisplayName(next.provider, name, { maxLength: 64 })}.` }
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'hf',
|
|
218
|
+
enterBehavior: 'fill',
|
|
219
|
+
summary: 'local model files · /hf [installed|download <link>]',
|
|
220
|
+
run: async (args, ctx) => runHuggingFace(args, ctx),
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'resume',
|
|
224
|
+
summary: 'reopen a prior session',
|
|
225
|
+
run: (_args, ctx) => {
|
|
226
|
+
ctx.onResumeRequest()
|
|
227
|
+
return { kind: 'handled' }
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'rewind',
|
|
232
|
+
aliases: ['checkpoint'],
|
|
233
|
+
summary: 'restore recent managed file edits · /rewind [n]',
|
|
234
|
+
run: async (args, ctx) => {
|
|
235
|
+
const trimmed = args.trim()
|
|
236
|
+
if (!trimmed) {
|
|
237
|
+
ctx.onRewindRequest()
|
|
238
|
+
return { kind: 'handled' }
|
|
239
|
+
}
|
|
240
|
+
const steps = trimmed ? Number.parseInt(trimmed, 10) : 1
|
|
241
|
+
if (!Number.isFinite(steps) || steps < 1) {
|
|
242
|
+
return { kind: 'note', variant: 'error', text: 'usage: /rewind [n]' }
|
|
243
|
+
}
|
|
244
|
+
const result = await rewindWorkspaceEdits(ctx.cwd, steps)
|
|
245
|
+
if (result.reverted === 0) {
|
|
246
|
+
return { kind: 'note', variant: 'error', text: 'no managed edits available to rewind in this directory.' }
|
|
247
|
+
}
|
|
248
|
+
const files = result.files.map(file => path.relative(ctx.cwd, file) || path.basename(file))
|
|
249
|
+
return {
|
|
250
|
+
kind: 'note',
|
|
251
|
+
text: `rewound ${result.reverted} edit${result.reverted === 1 ? '' : 's'}.\n${files.join('\n')}`,
|
|
252
|
+
variant: 'dim',
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'compact',
|
|
258
|
+
summary: 'summarize older turns to free up context',
|
|
259
|
+
run: (_args, ctx) => {
|
|
260
|
+
ctx.onCompactRequest()
|
|
261
|
+
return { kind: 'handled' }
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
name: 'permissions',
|
|
266
|
+
summary: 'review or remove saved permission rules for this project',
|
|
267
|
+
run: (_args, ctx) => {
|
|
268
|
+
ctx.onPermissionsRequest()
|
|
269
|
+
return { kind: 'handled' }
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: 'copy',
|
|
274
|
+
summary: 'copy an assistant reply to the clipboard · /copy [n]',
|
|
275
|
+
run: async (args, ctx) => {
|
|
276
|
+
const assistant = ctx.assistantTurns()
|
|
277
|
+
if (assistant.length === 0) {
|
|
278
|
+
return { kind: 'note', variant: 'error', text: 'nothing to copy yet.' }
|
|
279
|
+
}
|
|
280
|
+
let offset = 1
|
|
281
|
+
const trimmed = args.trim()
|
|
282
|
+
if (trimmed) {
|
|
283
|
+
const parsed = Number.parseInt(trimmed, 10)
|
|
284
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
285
|
+
return { kind: 'note', variant: 'error', text: 'usage: /copy [n] (n counts back from the latest reply, 1 = most recent)' }
|
|
286
|
+
}
|
|
287
|
+
offset = parsed
|
|
288
|
+
}
|
|
289
|
+
const index = assistant.length - offset
|
|
290
|
+
if (index < 0) {
|
|
291
|
+
return { kind: 'note', variant: 'error', text: `only ${assistant.length} assistant reply on record.` }
|
|
292
|
+
}
|
|
293
|
+
const text = assistant[index] ?? ''
|
|
294
|
+
const label = offset === 1 ? 'latest reply' : `reply #${offset} back`
|
|
295
|
+
const segments = parseSegments(text)
|
|
296
|
+
if (segments.length <= 1) {
|
|
297
|
+
const result = await copyToClipboard(text)
|
|
298
|
+
if (!result.ok) {
|
|
299
|
+
return { kind: 'note', variant: 'error', text: `copy failed: ${result.error}` }
|
|
300
|
+
}
|
|
301
|
+
return { kind: 'note', text: `copied ${text.length} chars via ${result.method}.`, variant: 'dim' }
|
|
302
|
+
}
|
|
303
|
+
ctx.onCopyPickerRequest(text, label)
|
|
304
|
+
return { kind: 'handled' }
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'export',
|
|
309
|
+
blockedInPlan: true,
|
|
310
|
+
summary: 'write the transcript to a markdown file',
|
|
311
|
+
run: async (_args, ctx) => {
|
|
312
|
+
const messages = ctx.sessionMessages()
|
|
313
|
+
if (messages.length === 0) {
|
|
314
|
+
return { kind: 'note', variant: 'error', text: 'nothing to export yet.' }
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const file = await exportSessionMarkdown(ctx.sessionId, messages, {
|
|
318
|
+
model: ctx.config.model,
|
|
319
|
+
provider: ctx.config.provider,
|
|
320
|
+
})
|
|
321
|
+
return { kind: 'note', text: `exported to ${file}` }
|
|
322
|
+
} catch (err: unknown) {
|
|
323
|
+
return { kind: 'note', variant: 'error', text: `export failed: ${(err as Error).message}` }
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: 'mcp',
|
|
329
|
+
enterBehavior: 'fill',
|
|
330
|
+
summary: 'manage MCP servers · /mcp [status|approve|reject|reconnect|enable|disable|add-json]',
|
|
331
|
+
run: async (args, ctx) => runMcp(args, ctx),
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: 'identity',
|
|
335
|
+
enterBehavior: 'fill',
|
|
336
|
+
summary: 'Ethereum identity · /identity [status|create|load|remove]',
|
|
337
|
+
run: async (args, ctx) => runIdentity(args, ctx),
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'doctor',
|
|
341
|
+
summary: 'spec, config, local runtime, key presence',
|
|
342
|
+
run: async (_args, ctx) => {
|
|
343
|
+
const [spec, keys, identity, llamaCpp, hfModels] = await Promise.all([
|
|
344
|
+
detectSpec(),
|
|
345
|
+
Promise.all(
|
|
346
|
+
(['openai', 'anthropic', 'gemini'] as ProviderId[]).map(async p => [p, await hasKey(p)] as const),
|
|
347
|
+
),
|
|
348
|
+
getIdentityStatus(ctx.config),
|
|
349
|
+
detectLlamaCpp(),
|
|
350
|
+
loadLocalHfModels(),
|
|
351
|
+
])
|
|
352
|
+
return { kind: 'note', text: renderDoctor(spec, keys, identity, ctx, llamaCpp, hfModels.length) }
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
358
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
359
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
360
|
+
|
|
361
|
+
if (!sub || sub === 'installed') {
|
|
362
|
+
const installed = await loadLocalHfModels()
|
|
363
|
+
if (installed.length === 0) {
|
|
364
|
+
return {
|
|
365
|
+
kind: 'note',
|
|
366
|
+
variant: 'dim',
|
|
367
|
+
text: 'no local model files downloaded. press alt+p and choose "add local model file".',
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const lines = installed.map(model => {
|
|
371
|
+
const marker = model.id === ctx.config.model && ctx.config.provider === 'llamacpp' ? '*' : ' '
|
|
372
|
+
const displayName = formatModelDisplayName('llamacpp', model.id, { displayName: model.displayName, maxLength: 64 })
|
|
373
|
+
return `${marker} ${displayName} ${formatBytes(model.sizeBytes)} ${model.risk}`
|
|
374
|
+
})
|
|
375
|
+
return { kind: 'note', text: ['installed Hugging Face models:', ...lines].join('\n') }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (sub === 'download' || sub === 'model') {
|
|
379
|
+
const link = tokens.slice(1).join(' ')
|
|
380
|
+
ctx.onModelPickerRequest()
|
|
381
|
+
return {
|
|
382
|
+
kind: 'note',
|
|
383
|
+
variant: 'dim',
|
|
384
|
+
text: link
|
|
385
|
+
? `alt+p opened. choose "add local model file" and paste: ${link}`
|
|
386
|
+
: 'alt+p opened. choose "add local model file" and paste the model URL or repo id.',
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
kind: 'note',
|
|
392
|
+
variant: 'error',
|
|
393
|
+
text: 'usage: /hf [installed|download <huggingface.co link or repo id>]',
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function runMcp(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
398
|
+
if (!ctx.mcp) {
|
|
399
|
+
return { kind: 'note', variant: 'error', text: 'MCP runtime is not available in this session.' }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
403
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
404
|
+
if (!sub || sub === 'status' || sub === 'list') {
|
|
405
|
+
return { kind: 'note', text: ctx.mcp.renderStatus(), variant: 'info' }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
if (sub === 'approve') {
|
|
410
|
+
const name = tokens.slice(1).join(' ')
|
|
411
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp approve <server>' }
|
|
412
|
+
return { kind: 'note', text: await ctx.mcp.approveServer(name), variant: 'dim' }
|
|
413
|
+
}
|
|
414
|
+
if (sub === 'reject') {
|
|
415
|
+
const name = tokens.slice(1).join(' ')
|
|
416
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp reject <server>' }
|
|
417
|
+
return { kind: 'note', text: await ctx.mcp.rejectServer(name), variant: 'dim' }
|
|
418
|
+
}
|
|
419
|
+
if (sub === 'reconnect') {
|
|
420
|
+
const name = tokens.slice(1).join(' ') || undefined
|
|
421
|
+
return { kind: 'note', text: await ctx.mcp.reconnect(name), variant: 'dim' }
|
|
422
|
+
}
|
|
423
|
+
if (sub === 'enable' || sub === 'disable') {
|
|
424
|
+
const name = tokens.slice(1).join(' ')
|
|
425
|
+
if (!name) return { kind: 'note', variant: 'error', text: `usage: /mcp ${sub} <server>` }
|
|
426
|
+
return { kind: 'note', text: await ctx.mcp.setEnabled(name, sub === 'enable'), variant: 'dim' }
|
|
427
|
+
}
|
|
428
|
+
if (sub === 'add-json') {
|
|
429
|
+
const project = tokens[1] === '--project'
|
|
430
|
+
const nameIndex = project ? 2 : 1
|
|
431
|
+
const name = tokens[nameIndex]
|
|
432
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
433
|
+
const jsonStart = nthTokenStart(args, nameIndex + 1)
|
|
434
|
+
const json = jsonStart >= 0 ? args.slice(jsonStart).trim() : ''
|
|
435
|
+
if (!json) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
436
|
+
return { kind: 'note', text: await ctx.mcp.addJson(name, json, project ? 'project' : 'user'), variant: 'dim' }
|
|
437
|
+
}
|
|
438
|
+
} catch (err: unknown) {
|
|
439
|
+
return { kind: 'note', variant: 'error', text: `mcp failed: ${(err as Error).message}` }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
kind: 'note',
|
|
444
|
+
variant: 'error',
|
|
445
|
+
text: 'usage: /mcp [status|approve <server>|reject <server>|reconnect [server]|enable <server>|disable <server>|add-json [--project] <name> <json>]',
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
450
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
451
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
452
|
+
const rest = tokens.slice(1)
|
|
453
|
+
|
|
454
|
+
if (!sub) {
|
|
455
|
+
ctx.onIdentityRequest('manage')
|
|
456
|
+
return { kind: 'handled' }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (sub === 'status') {
|
|
460
|
+
const status = await getIdentityStatus(ctx.config)
|
|
461
|
+
if (!status) {
|
|
462
|
+
return {
|
|
463
|
+
kind: 'note',
|
|
464
|
+
variant: 'dim',
|
|
465
|
+
text: 'no Ethereum identity set. run /identity create to make one.',
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const lines = [
|
|
469
|
+
`address ${status.address}`,
|
|
470
|
+
`created ${status.createdAt}`,
|
|
471
|
+
`backend ${status.backend}`,
|
|
472
|
+
]
|
|
473
|
+
if (status.source) lines.push(`source ${status.source}`)
|
|
474
|
+
if (status.agentId) lines.push(`token #${status.agentId}`)
|
|
475
|
+
return { kind: 'note', text: lines.join('\n') }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (sub === 'create') {
|
|
479
|
+
ctx.onIdentityRequest('create')
|
|
480
|
+
return { kind: 'handled' }
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (sub === 'load') {
|
|
484
|
+
ctx.onIdentityRequest('load')
|
|
485
|
+
return { kind: 'handled' }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (sub === 'remove') {
|
|
489
|
+
if (rest[0] !== 'confirm') {
|
|
490
|
+
return {
|
|
491
|
+
kind: 'note',
|
|
492
|
+
variant: 'error',
|
|
493
|
+
text: 'remove deletes local identity metadata and any legacy stored key. re-run with: /identity remove confirm',
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const status = await getIdentityStatus(ctx.config)
|
|
497
|
+
if (!status) {
|
|
498
|
+
return { kind: 'note', variant: 'dim', text: 'no Ethereum identity to remove.' }
|
|
499
|
+
}
|
|
500
|
+
const next = await clearIdentity(ctx.config)
|
|
501
|
+
ctx.onReplaceConfig(next)
|
|
502
|
+
return { kind: 'note', text: `removed identity ${status.address}.`, variant: 'dim' }
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
kind: 'note',
|
|
507
|
+
variant: 'error',
|
|
508
|
+
text: 'usage: /identity [status|create|load|remove confirm]',
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderHelp(): string {
|
|
513
|
+
const visibleCommands = COMMANDS.filter(c => !c.hidden)
|
|
514
|
+
const maxName = Math.max(...visibleCommands.map(c => commandLabel(c).length))
|
|
515
|
+
const lines = visibleCommands.map(c => {
|
|
516
|
+
const label = commandLabel(c)
|
|
517
|
+
return ` ${label.padEnd(maxName)} ${c.summary}`
|
|
518
|
+
})
|
|
519
|
+
return [
|
|
520
|
+
'slash commands:',
|
|
521
|
+
...lines,
|
|
522
|
+
'',
|
|
523
|
+
'shortcuts: esc cancels · ctrl+c twice exits · alt+p model · alt+i identity · shift+tab mode.',
|
|
524
|
+
].join('\n')
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function commandLabel(cmd: CommandSpec): string {
|
|
528
|
+
if (!cmd.aliases || cmd.aliases.length === 0) return `/${cmd.name}`
|
|
529
|
+
return `/${cmd.name} (${cmd.aliases.map(a => `/${a}`).join(', ')})`
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function renderStatus(ctx: SlashContext): string {
|
|
533
|
+
const elapsedMs = Date.now() - ctx.startedAt
|
|
534
|
+
const minutes = Math.floor(elapsedMs / 60000)
|
|
535
|
+
const seconds = Math.floor((elapsedMs % 60000) / 1000)
|
|
536
|
+
const elapsed = minutes > 0 ? `${minutes}m${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
|
|
537
|
+
const displayModel = formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })
|
|
538
|
+
return [
|
|
539
|
+
`provider ${ctx.config.provider}`,
|
|
540
|
+
`model ${displayModel}`,
|
|
541
|
+
`cwd ${ctx.cwd}`,
|
|
542
|
+
`session ${ctx.sessionId.slice(0, 8)}`,
|
|
543
|
+
'state active',
|
|
544
|
+
`turns ${ctx.turns}`,
|
|
545
|
+
`tokens ~${ctx.approxTokens}`,
|
|
546
|
+
`context ${ctx.contextUsage.percent}% (~${ctx.contextUsage.usedTokens}/${ctx.contextUsage.windowTokens}, ${ctx.contextUsage.source})`,
|
|
547
|
+
`elapsed ${elapsed}`,
|
|
548
|
+
].join('\n')
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function renderContext(ctx: SlashContext): string {
|
|
552
|
+
const usage = ctx.contextUsage
|
|
553
|
+
const free = Math.max(0, usage.windowTokens - usage.usedTokens)
|
|
554
|
+
const action =
|
|
555
|
+
usage.percent >= 90
|
|
556
|
+
? 'Context is near the model limit. New requests will ask you to summarize into a new conversation, switch models, ignore and send, or cancel.'
|
|
557
|
+
: usage.percent >= 75
|
|
558
|
+
? 'Context is getting full. Consider /compact before a new task boundary.'
|
|
559
|
+
: 'Context has comfortable room.'
|
|
560
|
+
return [
|
|
561
|
+
'context usage:',
|
|
562
|
+
` model ${ctx.config.provider} · ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`,
|
|
563
|
+
` used ~${usage.usedTokens} / ${usage.windowTokens} tokens (${usage.percent}%)`,
|
|
564
|
+
` free ~${free} tokens`,
|
|
565
|
+
` estimate ${usage.confidence} (${usage.source})`,
|
|
566
|
+
' session active',
|
|
567
|
+
'',
|
|
568
|
+
action,
|
|
569
|
+
].join('\n')
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function renderDoctor(
|
|
573
|
+
spec: Awaited<ReturnType<typeof detectSpec>>,
|
|
574
|
+
keys: ReadonlyArray<readonly [ProviderId, boolean]>,
|
|
575
|
+
identity: Awaited<ReturnType<typeof getIdentityStatus>>,
|
|
576
|
+
ctx: SlashContext,
|
|
577
|
+
llamaCpp: Awaited<ReturnType<typeof detectLlamaCpp>>,
|
|
578
|
+
hfModelCount: number,
|
|
579
|
+
): string {
|
|
580
|
+
const lines: string[] = ['diagnostics:']
|
|
581
|
+
lines.push(` platform ${spec.platform}/${spec.arch}${spec.isAppleSilicon ? ' (apple silicon)' : ''}`)
|
|
582
|
+
lines.push(` ram ${formatGB(spec.effectiveRamBytes)}${spec.gpuVramBytes ? ` · vram ${formatGB(spec.gpuVramBytes)}` : ''}`)
|
|
583
|
+
lines.push(` local run ${llamaCpp.binaryPresent ? 'installed' : 'not installed'} · server ${llamaCpp.serverUp ? 'up' : 'down'}`)
|
|
584
|
+
lines.push(` hf models ${hfModelCount} downloaded`)
|
|
585
|
+
lines.push('')
|
|
586
|
+
lines.push('config:')
|
|
587
|
+
lines.push(` provider ${ctx.config.provider}`)
|
|
588
|
+
lines.push(` model ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`)
|
|
589
|
+
if (ctx.config.baseUrl) lines.push(` baseUrl ${ctx.config.baseUrl}`)
|
|
590
|
+
if (ctx.config.provider === 'llamacpp') lines.push(` hf cache ${getLocalHfCacheDir()}`)
|
|
591
|
+
lines.push(` path ${getConfigPath()}`)
|
|
592
|
+
lines.push('')
|
|
593
|
+
lines.push('keys:')
|
|
594
|
+
for (const [provider, present] of keys) {
|
|
595
|
+
lines.push(` ${provider.padEnd(9)} ${present ? 'set' : 'not set'}`)
|
|
596
|
+
}
|
|
597
|
+
lines.push('')
|
|
598
|
+
lines.push('identity:')
|
|
599
|
+
if (identity) {
|
|
600
|
+
lines.push(` address ${identity.address}`)
|
|
601
|
+
lines.push(` backend ${identity.backend}`)
|
|
602
|
+
if (identity.source) lines.push(` source ${identity.source}`)
|
|
603
|
+
if (identity.agentId) lines.push(` token #${identity.agentId}`)
|
|
604
|
+
} else {
|
|
605
|
+
lines.push(' address not set')
|
|
606
|
+
}
|
|
607
|
+
return lines.join('\n')
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function renderModelCatalog(catalog: ModelCatalogResult, currentModel: string): string {
|
|
611
|
+
const title = catalog.status === 'fallback'
|
|
612
|
+
? `${catalog.provider} models (fallback${catalog.error ? `: ${catalog.error}` : ''}):`
|
|
613
|
+
: `${catalog.provider} models:`
|
|
614
|
+
const lines = catalog.entries.map(entry => {
|
|
615
|
+
const marker = entry.id === currentModel ? '*' : ' '
|
|
616
|
+
const suffix = entry.source === 'fallback' ? ' fallback' : ''
|
|
617
|
+
return `${marker} ${formatModelDisplayName(catalog.provider, entry.id, { maxLength: 72 })}${suffix}`
|
|
618
|
+
})
|
|
619
|
+
return [title, ...lines].join('\n')
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function baseUrlForModelSwitch(config: EthagentConfig): string | undefined {
|
|
623
|
+
if (config.provider === 'llamacpp') return localProviderBaseUrlFor('llamacpp', config.baseUrl)
|
|
624
|
+
if (config.provider === 'openai') return config.baseUrl
|
|
625
|
+
return undefined
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function formatBytes(bytes: number): string {
|
|
629
|
+
if (bytes <= 0) return '—'
|
|
630
|
+
const gb = bytes / (1024 * 1024 * 1024)
|
|
631
|
+
if (gb >= 1) return `${gb.toFixed(1)}GB`
|
|
632
|
+
const mb = bytes / (1024 * 1024)
|
|
633
|
+
return `${mb.toFixed(0)}MB`
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function formatGB(bytes: number): string {
|
|
637
|
+
const gb = bytes / (1024 * 1024 * 1024)
|
|
638
|
+
if (gb >= 10) return `${Math.round(gb)}GB`
|
|
639
|
+
return `${gb.toFixed(1)}GB`
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export async function dispatchSlash(input: string, ctx: SlashContext): Promise<SlashResult | null> {
|
|
643
|
+
const parsed = parseSlash(input)
|
|
644
|
+
if (!parsed) return null
|
|
645
|
+
const cmd = COMMANDS.find(c => c.name === parsed.name || c.aliases?.includes(parsed.name))
|
|
646
|
+
if (!cmd) {
|
|
647
|
+
try {
|
|
648
|
+
const promptText = await ctx.mcp?.runPromptSlash(parsed.name, parsed.args)
|
|
649
|
+
if (promptText !== null && promptText !== undefined) return { kind: 'submit', text: promptText }
|
|
650
|
+
} catch (err: unknown) {
|
|
651
|
+
return { kind: 'note', variant: 'error', text: `mcp prompt failed: ${(err as Error).message}` }
|
|
652
|
+
}
|
|
653
|
+
return { kind: 'note', variant: 'error', text: `unknown command: /${parsed.name}. try /help` }
|
|
654
|
+
}
|
|
655
|
+
if (ctx.mode === 'plan' && cmd.blockedInPlan) {
|
|
656
|
+
return { kind: 'note', variant: 'error', text: `/${cmd.name} is blocked in plan mode.` }
|
|
657
|
+
}
|
|
658
|
+
return cmd.run(parsed.args, ctx)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export function getSlashSuggestions(extra: SlashSuggestion[] = []): SlashSuggestion[] {
|
|
662
|
+
return [...COMMANDS.filter(c => !c.hidden).map(c => ({
|
|
663
|
+
name: c.name,
|
|
664
|
+
summary: c.summary,
|
|
665
|
+
completion: c.requiresArgs || c.enterBehavior === 'fill' ? `/${c.name} ` : `/${c.name}`,
|
|
666
|
+
executeOnEnter: !c.requiresArgs && c.enterBehavior !== 'fill',
|
|
667
|
+
})), ...extra]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function nthTokenStart(value: string, tokenIndex: number): number {
|
|
671
|
+
const matches = [...value.matchAll(/\S+/g)]
|
|
672
|
+
return matches[tokenIndex]?.index ?? -1
|
|
673
|
+
}
|