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,1146 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import type { Address, Hex } from 'viem'
5
+ import type { EthagentConfig, EthagentIdentity, SelectableNetwork } from '../../storage/config.js'
6
+ import { saveConfig } from '../../storage/config.js'
7
+ import {
8
+ assertAgentStateBackupOwner,
9
+ parseAgentStateBackupEnvelope,
10
+ restoreAgentStateBackupEnvelope,
11
+ } from '../crypto/backupEnvelope.js'
12
+ import {
13
+ CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
14
+ assertContinuitySnapshotOwner,
15
+ createContinuitySnapshotChallenge,
16
+ createContinuitySnapshotEnvelope,
17
+ parseContinuitySnapshotEnvelope,
18
+ restoreContinuitySnapshotEnvelope,
19
+ serializeContinuitySnapshotEnvelope,
20
+ type ContinuitySnapshotEnvelope,
21
+ } from '../continuity/envelope.js'
22
+ import {
23
+ continuityAgentSnapshot,
24
+ continuityVaultStatus,
25
+ defaultContinuityFiles,
26
+ ensurePublicSkillsFile,
27
+ ensureIdentityMarkdownScaffold,
28
+ ensureContinuityFiles,
29
+ continuitySnapshotContentHashes,
30
+ equalContinuitySnapshotHashes,
31
+ localContinuitySnapshotContentHashes,
32
+ prepareSyncedIdentityMarkdownScaffold,
33
+ prepareSyncedPublicSkillsJson,
34
+ readContinuityFiles,
35
+ readPublicSkillsFile,
36
+ writeContinuityFiles,
37
+ writeIdentityMarkdownScaffold,
38
+ writePublicSkillsFile,
39
+ type IdentityMarkdownScaffold,
40
+ } from '../continuity/storage.js'
41
+ import {
42
+ createAgentCard,
43
+ defaultPublicSkillsProfile,
44
+ renderPublicSkillsJson,
45
+ serializeAgentCard,
46
+ } from '../continuity/publicSkills.js'
47
+ import {
48
+ recordPublishedContinuitySnapshot,
49
+ updatePublishedContinuitySnapshotContentHashes,
50
+ } from '../continuity/snapshots.js'
51
+ import { addFileToIpfs, addToIpfs, catFromIpfs, DEFAULT_IPFS_API_URL, isPinataUploadUrl, type IpfsAddResult } from '../storage/ipfs.js'
52
+ import {
53
+ AgentTokenIdRequiredError,
54
+ chainIdForNetwork,
55
+ createErc8004PublicClient,
56
+ discoverOwnedAgentBackups,
57
+ discoverOwnedAgentBackupByTokenId,
58
+ encodeRegisterAgent,
59
+ encodeSetAgentUri,
60
+ erc8004ConfigForSupportedChain,
61
+ normalizeErc8004RegistryConfig,
62
+ preflightRegisterAgent,
63
+ preflightSetAgentUri,
64
+ registeredAgentFromReceipt,
65
+ withEthagentBackupPointer,
66
+ withEthagentPointers,
67
+ type Erc8004AgentCandidate,
68
+ type Erc8004RegistryConfig,
69
+ } from '../registry/erc8004.js'
70
+ import { getAddress } from 'viem'
71
+ import { registryConfigFromConfig, type RegistryResolution } from '../registry/registryConfig.js'
72
+ import { resolveValidatedPinataJwt, savePinataJwt } from '../storage/pinataJwt.js'
73
+ import {
74
+ requestBrowserWalletAccount,
75
+ requestBrowserWalletSignature,
76
+ requestBrowserWalletSignatureAndTransaction,
77
+ sendBrowserWalletTransaction,
78
+ type BrowserWalletReady,
79
+ } from '../wallet/browserWallet.js'
80
+ import { initialAgentState, PREFLIGHT_AGENT_URI } from './identityHubModel.js'
81
+ import type { Step, ProfileUpdates, RestorePurpose } from './identityHubReducer.js'
82
+
83
+ type BackupMetadata = NonNullable<EthagentIdentity['backup']>
84
+ type PublicSkillsMetadata = NonNullable<EthagentIdentity['publicSkills']>
85
+
86
+ type CreatePreparedTransaction = {
87
+ ownerAddress: Address
88
+ agentUri: string
89
+ metadataCid: string
90
+ backup: BackupMetadata
91
+ publicSkills: PublicSkillsMetadata
92
+ state: Record<string, unknown>
93
+ continuityFiles: ReturnType<typeof defaultContinuityFiles>
94
+ publicSkillsJson: string
95
+ }
96
+
97
+ type RebackupPreparedTransaction = {
98
+ ownerAddress: Address
99
+ agentUri: string
100
+ metadataCid: string
101
+ backup: BackupMetadata
102
+ publicSkills: PublicSkillsMetadata
103
+ identity: EthagentIdentity
104
+ markdownScaffold?: IdentityMarkdownScaffold
105
+ }
106
+
107
+ type PublicProfilePreparedTransaction = {
108
+ ownerAddress: Address
109
+ agentUri: string
110
+ metadataCid: string
111
+ publicSkills: PublicSkillsMetadata
112
+ identity: EthagentIdentity
113
+ publicSkillsJson: string
114
+ }
115
+
116
+ export type EffectCallbacks = {
117
+ onStep: (step: Step) => void
118
+ onWalletReady: (session: BrowserWalletReady | null) => void
119
+ onIdentityComplete: (identity: EthagentIdentity, message: string) => Promise<void>
120
+ onRestoreProgress?: (progress: RestoreProgress | null) => void
121
+ }
122
+
123
+ export type RestoreProgress = {
124
+ phase: 'decrypting' | 'writing' | 'finishing'
125
+ label: string
126
+ }
127
+
128
+ export async function runCreatePreflight(
129
+ step: Extract<Step, { kind: 'create-preflight' }>,
130
+ config: EthagentConfig | undefined,
131
+ callbacks: EffectCallbacks,
132
+ ): Promise<void> {
133
+ const resolution = step.network
134
+ ? registryResolutionForNetwork(step.network)
135
+ : registryConfigFromConfig(config)
136
+ if (!resolution.config) {
137
+ callbacks.onStep({ kind: 'create-registry', name: step.name, description: step.description, resolution })
138
+ return
139
+ }
140
+ const apiUrl = DEFAULT_IPFS_API_URL
141
+ let jwt: string | undefined
142
+ try {
143
+ jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined
144
+ } catch (err: unknown) {
145
+ callbacks.onStep({
146
+ kind: 'create-storage',
147
+ name: step.name,
148
+ description: step.description,
149
+ registry: resolution.config,
150
+ error: (err as Error).message,
151
+ })
152
+ return
153
+ }
154
+ if (isPinataUploadUrl(apiUrl) && !jwt) {
155
+ callbacks.onStep({ kind: 'create-storage', name: step.name, description: step.description, registry: resolution.config })
156
+ return
157
+ }
158
+ callbacks.onStep({ kind: 'create-signing', name: step.name, description: step.description, registry: resolution.config, pinataJwt: jwt })
159
+ }
160
+
161
+ function registryResolutionForNetwork(network: SelectableNetwork): RegistryResolution {
162
+ const chainId = chainIdForNetwork(network)
163
+ try {
164
+ const registry = erc8004ConfigForSupportedChain(chainId)
165
+ return {
166
+ config: registry,
167
+ network,
168
+ chainId,
169
+ needsRegistryAddress: false,
170
+ defaultRpcUrl: registry.rpcUrl,
171
+ }
172
+ } catch {
173
+ return {
174
+ config: null,
175
+ network,
176
+ chainId,
177
+ needsRegistryAddress: true,
178
+ defaultRpcUrl: '',
179
+ }
180
+ }
181
+ }
182
+
183
+ export async function runCreateSigning(
184
+ step: Extract<Step, { kind: 'create-signing' }>,
185
+ callbacks: EffectCallbacks,
186
+ ): Promise<void> {
187
+ const result = await requestBrowserWalletSignatureAndTransaction<CreatePreparedTransaction>({
188
+ chainId: step.registry.chainId,
189
+ messageForAccount: account => createContinuitySnapshotChallenge(account),
190
+ onReady: callbacks.onWalletReady,
191
+ prepareTransaction: async wallet => {
192
+ await preflightRegisterAgent({
193
+ ...step.registry,
194
+ ownerAddress: wallet.account,
195
+ agentURI: PREFLIGHT_AGENT_URI,
196
+ })
197
+ const state = initialAgentState(step.name, step.description, wallet.account)
198
+ const draftIdentity = identityDraftForBackup({
199
+ ownerAddress: wallet.account,
200
+ registry: step.registry,
201
+ state,
202
+ })
203
+ const continuityFiles = defaultContinuityFiles(draftIdentity)
204
+ const publicProfile = defaultPublicSkillsProfile(draftIdentity)
205
+ const publicSkillsJson = renderPublicSkillsJson(publicProfile)
206
+ const publicSkillsPin = await addToIpfs(DEFAULT_IPFS_API_URL, publicSkillsJson, fetch, { pinataJwt: step.pinataJwt })
207
+ assertVerifiedPin(publicSkillsPin)
208
+ const agentCardPin = await addToIpfs(DEFAULT_IPFS_API_URL, serializeAgentCard(createAgentCard(publicProfile)), fetch, { pinataJwt: step.pinataJwt })
209
+ assertVerifiedPin(agentCardPin)
210
+ const envelope = createContinuitySnapshotEnvelope({
211
+ ownerAddress: wallet.account,
212
+ walletSignature: wallet.signature,
213
+ payload: {
214
+ agent: continuityAgentSnapshot(draftIdentity),
215
+ files: continuityFiles,
216
+ transcript: [],
217
+ state,
218
+ },
219
+ })
220
+ const statePin = await addToIpfs(DEFAULT_IPFS_API_URL, serializeContinuitySnapshotEnvelope(envelope), fetch, { pinataJwt: step.pinataJwt })
221
+ assertVerifiedPin(statePin)
222
+ const cid = statePin.cid
223
+ const backup: BackupMetadata = {
224
+ cid,
225
+ createdAt: envelope.createdAt,
226
+ envelopeVersion: envelope.envelopeVersion,
227
+ ipfsApiUrl: DEFAULT_IPFS_API_URL,
228
+ status: 'pinned',
229
+ ownerAddress: wallet.account,
230
+ chainId: step.registry.chainId,
231
+ rpcUrl: step.registry.rpcUrl,
232
+ identityRegistryAddress: step.registry.identityRegistryAddress,
233
+ }
234
+ const publicSkills: PublicSkillsMetadata = {
235
+ cid: publicSkillsPin.cid,
236
+ agentCardCid: agentCardPin.cid,
237
+ updatedAt: envelope.createdAt,
238
+ status: 'pinned',
239
+ }
240
+ const registration = withEthagentBackupPointer({
241
+ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
242
+ name: step.name,
243
+ ...(step.description ? { description: step.description } : {}),
244
+ ...(typeof state.imageUrl === 'string' ? { image: state.imageUrl } : {}),
245
+ }, {
246
+ cid,
247
+ envelopeVersion: envelope.envelopeVersion,
248
+ createdAt: envelope.createdAt,
249
+ }, {
250
+ skillsCid: publicSkills.cid,
251
+ agentCardCid: publicSkills.agentCardCid,
252
+ updatedAt: publicSkills.updatedAt,
253
+ }, {
254
+ chainId: step.registry.chainId,
255
+ identityRegistryAddress: step.registry.identityRegistryAddress,
256
+ })
257
+ const metadataPin = await addToIpfs(DEFAULT_IPFS_API_URL, JSON.stringify(registration, null, 2), fetch, { pinataJwt: step.pinataJwt })
258
+ assertVerifiedPin(metadataPin)
259
+ const metadataCid = metadataPin.cid
260
+ const agentUri = `ipfs://${metadataCid}`
261
+ return {
262
+ to: step.registry.identityRegistryAddress,
263
+ data: encodeRegisterAgent({ agentURI: agentUri }),
264
+ prepared: {
265
+ ownerAddress: wallet.account,
266
+ agentUri,
267
+ metadataCid,
268
+ backup: { ...backup, metadataCid, agentUri },
269
+ publicSkills,
270
+ state,
271
+ continuityFiles,
272
+ publicSkillsJson,
273
+ },
274
+ }
275
+ },
276
+ })
277
+ const client = createErc8004PublicClient(step.registry)
278
+ const receipt = await client.waitForTransactionReceipt({ hash: result.txHash })
279
+ const registered = registeredAgentFromReceipt({
280
+ logs: receipt.logs.map(log => ({ address: log.address, topics: [...log.topics] as Hex[], data: log.data })),
281
+ identityRegistryAddress: step.registry.identityRegistryAddress,
282
+ ownerAddress: result.prepared.ownerAddress,
283
+ })
284
+ const backup: BackupMetadata = {
285
+ ...result.prepared.backup,
286
+ agentId: registered.agentId.toString(),
287
+ agentUri: registered.agentURI,
288
+ txHash: result.txHash,
289
+ }
290
+ const nextIdentity: EthagentIdentity = {
291
+ source: 'erc8004',
292
+ address: result.prepared.ownerAddress,
293
+ ownerAddress: result.prepared.ownerAddress,
294
+ createdAt: result.prepared.backup.createdAt,
295
+ chainId: step.registry.chainId,
296
+ rpcUrl: step.registry.rpcUrl,
297
+ identityRegistryAddress: step.registry.identityRegistryAddress,
298
+ agentId: registered.agentId.toString(),
299
+ agentUri: registered.agentURI,
300
+ metadataCid: result.prepared.metadataCid,
301
+ state: result.prepared.state,
302
+ backup,
303
+ publicSkills: result.prepared.publicSkills,
304
+ }
305
+ await writeIdentityMarkdownScaffold(nextIdentity, {
306
+ ...defaultContinuityFiles(nextIdentity),
307
+ 'skills.json': result.prepared.publicSkillsJson,
308
+ })
309
+ await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'initial published snapshot' }).catch(() => null)
310
+ await callbacks.onIdentityComplete(nextIdentity, `ERC-8004 agent registered · #${registered.agentId.toString()}`)
311
+ }
312
+
313
+ export async function runRestoreDiscover(
314
+ step: Extract<Step, { kind: 'restore-discovering' }>,
315
+ _config: EthagentConfig | undefined,
316
+ callbacks: EffectCallbacks,
317
+ ): Promise<void> {
318
+ const candidates = await discoverOwnedAgentBackups({
319
+ ...step.registry,
320
+ ownerHandle: step.ownerHandle,
321
+ ipfsApiUrl: DEFAULT_IPFS_API_URL,
322
+ })
323
+ callbacks.onStep(restoreTokenSelectionStep({
324
+ ownerHandle: step.ownerHandle,
325
+ registry: step.registry,
326
+ candidates,
327
+ purpose: step.purpose,
328
+ }))
329
+ }
330
+
331
+ export async function runRestoreConnectWallet(
332
+ step: Extract<Step, { kind: 'restore-wallet' }>,
333
+ callbacks: EffectCallbacks,
334
+ ): Promise<void> {
335
+ const wallet = await requestBrowserWalletAccount({
336
+ onReady: callbacks.onWalletReady,
337
+ })
338
+ callbacks.onStep({ kind: 'restore-network', ownerHandle: wallet.account, purpose: step.purpose })
339
+ }
340
+
341
+ export function restoreTokenSelectionStep(args: {
342
+ ownerHandle: string
343
+ registry: Erc8004RegistryConfig
344
+ candidates: Erc8004AgentCandidate[]
345
+ purpose?: RestorePurpose
346
+ }): Extract<Step, { kind: 'restore-select-token' }> {
347
+ const restorable = args.candidates.filter(candidate => candidate.backup?.cid)
348
+ if (restorable.length === 0) {
349
+ throw new Error(args.candidates.length === 0
350
+ ? 'no agent identities owned by that wallet on this network'
351
+ : 'no owned agent identity has recoverable ethagent state on this network')
352
+ }
353
+ return {
354
+ kind: 'restore-select-token',
355
+ ownerHandle: args.ownerHandle,
356
+ registry: args.registry,
357
+ candidates: restorable,
358
+ purpose: args.purpose,
359
+ }
360
+ }
361
+
362
+ export function isAgentTokenIdRequiredError(err: unknown): err is AgentTokenIdRequiredError {
363
+ return err instanceof AgentTokenIdRequiredError
364
+ }
365
+
366
+ export async function runRestoreTokenIdSubmit(
367
+ value: string,
368
+ step: Extract<Step, { kind: 'restore-token-id' }>,
369
+ callbacks: EffectCallbacks,
370
+ ): Promise<void> {
371
+ const tokenId = parseTokenId(value)
372
+ const candidate = await discoverOwnedAgentBackupByTokenId({
373
+ ...step.registry,
374
+ ownerHandle: step.ownerHandle,
375
+ tokenId,
376
+ ipfsApiUrl: DEFAULT_IPFS_API_URL,
377
+ })
378
+ if (!candidate.backup?.cid) {
379
+ throw new Error('that agent token does not have recoverable ethagent state')
380
+ }
381
+ callbacks.onStep({
382
+ kind: 'restore-fetching',
383
+ cid: candidate.backup.cid,
384
+ apiUrl: DEFAULT_IPFS_API_URL,
385
+ candidate,
386
+ purpose: step.purpose,
387
+ })
388
+ }
389
+
390
+ function parseTokenId(value: string): bigint {
391
+ const normalized = value.trim().replace(/^#/, '')
392
+ if (!/^\d+$/.test(normalized)) throw new Error('enter a token id')
393
+ return BigInt(normalized)
394
+ }
395
+
396
+ export async function runRestoreFetch(
397
+ step: Extract<Step, { kind: 'restore-fetching' }>,
398
+ callbacks: EffectCallbacks,
399
+ ): Promise<void> {
400
+ const raw = await catFromIpfs(step.apiUrl, step.cid)
401
+ const envelope = parseRestorableEnvelope(raw)
402
+ if (isContinuitySnapshotEnvelope(envelope)) {
403
+ assertContinuitySnapshotOwner(envelope, step.candidate.ownerAddress)
404
+ } else {
405
+ assertAgentStateBackupOwner(envelope, step.candidate.ownerAddress)
406
+ }
407
+ callbacks.onStep({ kind: 'restore-authorizing', cid: step.cid, apiUrl: step.apiUrl, envelope, candidate: step.candidate, purpose: step.purpose })
408
+ }
409
+
410
+ export async function runRestoreAuthorize(
411
+ step: Extract<Step, { kind: 'restore-authorizing' }>,
412
+ callbacks: EffectCallbacks,
413
+ ): Promise<void> {
414
+ const wallet = await requestBrowserWalletSignature({
415
+ chainId: step.candidate.chainId,
416
+ expectedAccount: step.candidate.ownerAddress,
417
+ message: step.envelope.challenge,
418
+ onReady: callbacks.onWalletReady,
419
+ })
420
+ callbacks.onWalletReady(null)
421
+ callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'signature received · decrypting encrypted snapshot...' })
422
+ let restored: ReturnType<typeof restoreAgentStateBackupEnvelope> | ReturnType<typeof restoreContinuitySnapshotEnvelope>
423
+ let continuityFiles: ReturnType<typeof restoreContinuitySnapshotEnvelope>['files'] | undefined
424
+ if (isContinuitySnapshotEnvelope(step.envelope)) {
425
+ const payload = restoreContinuitySnapshotEnvelope({
426
+ envelope: step.envelope,
427
+ walletSignature: wallet.signature,
428
+ })
429
+ restored = payload
430
+ continuityFiles = payload.files
431
+ } else {
432
+ restored = restoreAgentStateBackupEnvelope({
433
+ envelope: step.envelope,
434
+ walletSignature: wallet.signature,
435
+ })
436
+ }
437
+ callbacks.onRestoreProgress?.({ phase: 'writing', label: 'restoring local agent files...' })
438
+ const backup: BackupMetadata = {
439
+ cid: step.cid,
440
+ createdAt: step.envelope.createdAt,
441
+ envelopeVersion: step.envelope.envelopeVersion,
442
+ ipfsApiUrl: step.apiUrl,
443
+ status: 'restored',
444
+ ownerAddress: step.candidate.ownerAddress,
445
+ chainId: step.candidate.chainId,
446
+ rpcUrl: step.candidate.rpcUrl,
447
+ identityRegistryAddress: step.candidate.identityRegistryAddress,
448
+ agentId: step.candidate.agentId.toString(),
449
+ agentUri: step.candidate.agentUri,
450
+ metadataCid: step.candidate.metadataCid,
451
+ }
452
+ const nextIdentity: EthagentIdentity = {
453
+ source: 'erc8004',
454
+ address: step.candidate.ownerAddress,
455
+ ownerAddress: step.candidate.ownerAddress,
456
+ createdAt: restored.createdAt,
457
+ chainId: step.candidate.chainId,
458
+ rpcUrl: step.candidate.rpcUrl,
459
+ identityRegistryAddress: step.candidate.identityRegistryAddress,
460
+ agentId: step.candidate.agentId.toString(),
461
+ agentUri: step.candidate.agentUri,
462
+ metadataCid: step.candidate.metadataCid,
463
+ state: {
464
+ ...restored.state,
465
+ ...(step.candidate.name ? { name: step.candidate.name } : {}),
466
+ ...(step.candidate.description ? { description: step.candidate.description } : {}),
467
+ ...(step.candidate.imageUrl ? { imageUrl: step.candidate.imageUrl } : {}),
468
+ },
469
+ backup,
470
+ ...(step.candidate.publicDiscovery ? {
471
+ publicSkills: {
472
+ ...(step.candidate.publicDiscovery.skillsCid ? { cid: step.candidate.publicDiscovery.skillsCid } : {}),
473
+ ...(step.candidate.publicDiscovery.agentCardCid ? { agentCardCid: step.candidate.publicDiscovery.agentCardCid } : {}),
474
+ ...(step.candidate.publicDiscovery.updatedAt ? { updatedAt: step.candidate.publicDiscovery.updatedAt } : {}),
475
+ status: 'pinned',
476
+ },
477
+ } : {}),
478
+ }
479
+ if (continuityFiles) {
480
+ await writeContinuityFiles(nextIdentity, continuityFiles)
481
+ }
482
+ callbacks.onRestoreProgress?.({ phase: 'finishing', label: 'finalizing restored identity...' })
483
+ await restorePublishedPublicSkills(nextIdentity, step.apiUrl, step.candidate.publicDiscovery?.skillsCid)
484
+ await ensureIdentityMarkdownScaffold(nextIdentity)
485
+ await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'restored from agent backup' }).catch(() => null)
486
+ await callbacks.onIdentityComplete(nextIdentity, `ERC-8004 agent restored · #${step.candidate.agentId.toString()}`)
487
+ }
488
+
489
+ export async function runRegistrySubmit(
490
+ value: string,
491
+ step: Extract<Step, { kind: 'create-registry' }>,
492
+ config: EthagentConfig | undefined,
493
+ onConfigChange: ((config: EthagentConfig) => void) | undefined,
494
+ callbacks: EffectCallbacks,
495
+ ): Promise<void> {
496
+ const registry = normalizeErc8004RegistryConfig({
497
+ chainId: step.resolution.chainId,
498
+ rpcUrl: step.resolution.defaultRpcUrl,
499
+ identityRegistryAddress: value.trim(),
500
+ })
501
+ if (config && onConfigChange) {
502
+ const next: EthagentConfig = {
503
+ ...config,
504
+ erc8004: {
505
+ chainId: registry.chainId,
506
+ rpcUrl: registry.rpcUrl,
507
+ identityRegistryAddress: registry.identityRegistryAddress,
508
+ },
509
+ }
510
+ await saveConfig(next)
511
+ onConfigChange(next)
512
+ }
513
+ const apiUrl = DEFAULT_IPFS_API_URL
514
+ let jwt: string | undefined
515
+ try {
516
+ jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined
517
+ } catch (err: unknown) {
518
+ callbacks.onStep({ kind: 'create-storage', name: step.name, description: step.description, registry, error: (err as Error).message })
519
+ return
520
+ }
521
+ if (isPinataUploadUrl(apiUrl) && !jwt) {
522
+ callbacks.onStep({ kind: 'create-storage', name: step.name, description: step.description, registry })
523
+ return
524
+ }
525
+ callbacks.onStep({ kind: 'create-signing', name: step.name, description: step.description, registry, pinataJwt: jwt })
526
+ }
527
+
528
+ export async function runRestoreRegistrySubmit(
529
+ value: string,
530
+ step: Extract<Step, { kind: 'restore-registry' }>,
531
+ config: EthagentConfig | undefined,
532
+ onConfigChange: ((config: EthagentConfig) => void) | undefined,
533
+ callbacks: EffectCallbacks,
534
+ ): Promise<void> {
535
+ const resolution = registryConfigFromConfig(config)
536
+ const registry = normalizeErc8004RegistryConfig({
537
+ chainId: resolution.chainId,
538
+ rpcUrl: resolution.config?.rpcUrl ?? resolution.defaultRpcUrl,
539
+ identityRegistryAddress: value.trim(),
540
+ })
541
+ if (config && onConfigChange) {
542
+ const next: EthagentConfig = {
543
+ ...config,
544
+ erc8004: {
545
+ chainId: registry.chainId,
546
+ rpcUrl: registry.rpcUrl,
547
+ identityRegistryAddress: registry.identityRegistryAddress,
548
+ },
549
+ }
550
+ await saveConfig(next)
551
+ onConfigChange(next)
552
+ }
553
+ callbacks.onStep({ kind: 'restore-discovering', ownerHandle: step.ownerHandle, registry, purpose: step.purpose })
554
+ }
555
+
556
+ export async function runStorageSubmit(
557
+ input: string,
558
+ step: Extract<Step, { kind: 'create-storage' }>,
559
+ callbacks: EffectCallbacks,
560
+ ): Promise<void> {
561
+ const { jwt: pinataJwt } = await savePinataJwt(input)
562
+ callbacks.onStep({ kind: 'create-signing', name: step.name, description: step.description, registry: step.registry, pinataJwt })
563
+ }
564
+
565
+ export async function runRebackupPreflight(
566
+ identity: EthagentIdentity,
567
+ registry: Erc8004RegistryConfig,
568
+ callbacks: EffectCallbacks,
569
+ profileUpdates?: ProfileUpdates,
570
+ returnTo: Step = { kind: 'menu' },
571
+ ): Promise<void> {
572
+ const status = await continuityVaultStatus(identity)
573
+ if (!status.ready) {
574
+ throw new Error('restore local SOUL.md and MEMORY.md working files before saving an encrypted snapshot')
575
+ }
576
+ const apiUrl = DEFAULT_IPFS_API_URL
577
+ let jwt: string | undefined
578
+ try {
579
+ jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined
580
+ } catch (err: unknown) {
581
+ callbacks.onStep({ kind: 'rebackup-storage', identity, registry, error: (err as Error).message, profileUpdates, returnTo })
582
+ return
583
+ }
584
+ if (isPinataUploadUrl(apiUrl) && !jwt) {
585
+ callbacks.onStep({ kind: 'rebackup-storage', identity, registry, profileUpdates, returnTo })
586
+ return
587
+ }
588
+ callbacks.onStep({ kind: 'rebackup-signing', identity, registry, pinataJwt: jwt, profileUpdates, returnTo })
589
+ }
590
+
591
+ export async function runRebackupSigning(
592
+ step: Extract<Step, { kind: 'rebackup-signing' }>,
593
+ callbacks: EffectCallbacks,
594
+ ): Promise<void> {
595
+ const expectedOwner = step.identity.ownerAddress ?? step.identity.address
596
+ const result = await requestBrowserWalletSignatureAndTransaction<RebackupPreparedTransaction>({
597
+ chainId: step.registry.chainId,
598
+ messageForAccount: account => createContinuitySnapshotChallenge(account),
599
+ onReady: callbacks.onWalletReady,
600
+ ...(expectedOwner ? { expectedAccount: getAddress(expectedOwner) } : {}),
601
+ prepareTransaction: async wallet => {
602
+ if (!step.identity.agentId) throw new Error('cannot back up: identity is missing an agent token id')
603
+ if (expectedOwner && wallet.account.toLowerCase() !== expectedOwner.toLowerCase()) {
604
+ throw new Error(`connect the wallet that owns this agent (${expectedOwner}) and try again`)
605
+ }
606
+ const baseState = (step.identity.state ?? {}) as Record<string, unknown>
607
+ const profile = step.profileUpdates ?? {}
608
+ const nextName = typeof profile.name === 'string' && profile.name.trim() ? profile.name.trim() : (typeof baseState.name === 'string' ? baseState.name : undefined)
609
+ const nextDescription = profile.description !== undefined ? profile.description.trim() : (typeof baseState.description === 'string' ? baseState.description : '')
610
+ const uploadedImageUri = profile.imagePath === 'delete'
611
+ ? ''
612
+ : profile.imagePath
613
+ ? await uploadAgentImage(profile.imagePath, step.pinataJwt)
614
+ : typeof baseState.imageUrl === 'string' && baseState.imageUrl.trim()
615
+ ? baseState.imageUrl.trim()
616
+ : undefined
617
+ const state: Record<string, unknown> = {
618
+ ...baseState,
619
+ ...(nextName !== undefined ? { name: nextName } : {}),
620
+ description: nextDescription,
621
+ lastBackedUpAt: new Date().toISOString(),
622
+ }
623
+ if (uploadedImageUri === '') {
624
+ delete state.imageUrl
625
+ } else if (uploadedImageUri) {
626
+ state.imageUrl = uploadedImageUri
627
+ }
628
+ const nextIdentityForFiles: EthagentIdentity = { ...step.identity, state }
629
+ const markdownScaffold = step.profileUpdates
630
+ ? await prepareSyncedIdentityMarkdownScaffold(nextIdentityForFiles)
631
+ : undefined
632
+ const continuityFiles = markdownScaffold
633
+ ? { 'SOUL.md': markdownScaffold['SOUL.md'], 'MEMORY.md': markdownScaffold['MEMORY.md'] }
634
+ : await readContinuityFiles(nextIdentityForFiles)
635
+ const publicSkillsJson = markdownScaffold
636
+ ? markdownScaffold['skills.json']
637
+ : await readPublicSkillsFile(nextIdentityForFiles)
638
+ const publicSkillsPin = await addToIpfs(DEFAULT_IPFS_API_URL, publicSkillsJson, fetch, { pinataJwt: step.pinataJwt })
639
+ assertVerifiedPin(publicSkillsPin)
640
+ const agentCardPin = await addToIpfs(
641
+ DEFAULT_IPFS_API_URL,
642
+ serializeAgentCard(createAgentCard(defaultPublicSkillsProfile(nextIdentityForFiles))),
643
+ fetch,
644
+ { pinataJwt: step.pinataJwt },
645
+ )
646
+ assertVerifiedPin(agentCardPin)
647
+ const envelope = createContinuitySnapshotEnvelope({
648
+ ownerAddress: wallet.account,
649
+ walletSignature: wallet.signature,
650
+ payload: {
651
+ agent: continuityAgentSnapshot(nextIdentityForFiles),
652
+ files: continuityFiles,
653
+ transcript: [],
654
+ state,
655
+ },
656
+ })
657
+ const statePin = await addToIpfs(DEFAULT_IPFS_API_URL, serializeContinuitySnapshotEnvelope(envelope), fetch, { pinataJwt: step.pinataJwt })
658
+ assertVerifiedPin(statePin)
659
+ const cid = statePin.cid
660
+ const backup: BackupMetadata = {
661
+ cid,
662
+ createdAt: envelope.createdAt,
663
+ envelopeVersion: envelope.envelopeVersion,
664
+ ipfsApiUrl: DEFAULT_IPFS_API_URL,
665
+ status: 'pinned',
666
+ ownerAddress: wallet.account,
667
+ chainId: step.registry.chainId,
668
+ rpcUrl: step.registry.rpcUrl,
669
+ identityRegistryAddress: step.registry.identityRegistryAddress,
670
+ agentId: step.identity.agentId,
671
+ }
672
+ const publicSkills: PublicSkillsMetadata = {
673
+ cid: publicSkillsPin.cid,
674
+ agentCardCid: agentCardPin.cid,
675
+ updatedAt: envelope.createdAt,
676
+ status: 'pinned',
677
+ }
678
+ const registration = withEthagentBackupPointer({
679
+ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
680
+ name: nextName ?? deriveAgentName(step.identity),
681
+ ...(nextDescription ? { description: nextDescription } : {}),
682
+ ...(uploadedImageUri ? { image: uploadedImageUri } : {}),
683
+ }, {
684
+ cid,
685
+ envelopeVersion: envelope.envelopeVersion,
686
+ createdAt: envelope.createdAt,
687
+ }, {
688
+ skillsCid: publicSkills.cid,
689
+ agentCardCid: publicSkills.agentCardCid,
690
+ updatedAt: publicSkills.updatedAt,
691
+ }, {
692
+ chainId: step.registry.chainId,
693
+ identityRegistryAddress: step.registry.identityRegistryAddress,
694
+ agentId: step.identity.agentId,
695
+ })
696
+ const metadataPin = await addToIpfs(DEFAULT_IPFS_API_URL, JSON.stringify(registration, null, 2), fetch, { pinataJwt: step.pinataJwt })
697
+ assertVerifiedPin(metadataPin)
698
+ const metadataCid = metadataPin.cid
699
+ const agentUri = `ipfs://${metadataCid}`
700
+ const agentId = BigInt(step.identity.agentId)
701
+ await preflightSetAgentUri({
702
+ ...step.registry,
703
+ account: wallet.account,
704
+ agentId,
705
+ newUri: agentUri,
706
+ })
707
+ return {
708
+ to: step.registry.identityRegistryAddress,
709
+ data: encodeSetAgentUri({ agentId, newUri: agentUri }),
710
+ prepared: {
711
+ ownerAddress: wallet.account,
712
+ agentUri,
713
+ metadataCid,
714
+ backup: { ...backup, metadataCid, agentUri },
715
+ publicSkills,
716
+ identity: { ...step.identity, state },
717
+ ...(markdownScaffold ? { markdownScaffold } : {}),
718
+ },
719
+ }
720
+ },
721
+ })
722
+ const client = createErc8004PublicClient(step.registry)
723
+ await client.waitForTransactionReceipt({ hash: result.txHash })
724
+ const nextIdentity: EthagentIdentity = {
725
+ ...result.prepared.identity,
726
+ source: 'erc8004',
727
+ address: getAddress(result.prepared.ownerAddress),
728
+ ownerAddress: getAddress(result.prepared.ownerAddress),
729
+ chainId: step.registry.chainId,
730
+ rpcUrl: step.registry.rpcUrl,
731
+ identityRegistryAddress: step.registry.identityRegistryAddress,
732
+ agentUri: result.prepared.agentUri,
733
+ metadataCid: result.prepared.metadataCid,
734
+ backup: { ...result.prepared.backup, txHash: result.txHash },
735
+ publicSkills: result.prepared.publicSkills,
736
+ }
737
+ if (result.prepared.markdownScaffold) {
738
+ await writeIdentityMarkdownScaffold(nextIdentity, result.prepared.markdownScaffold)
739
+ }
740
+ await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'published encrypted snapshot' }).catch(() => null)
741
+ const completionMessage = step.profileUpdates ? 'profile updated and backup saved' : 'agent backup saved'
742
+ await callbacks.onIdentityComplete(nextIdentity, completionMessage)
743
+ }
744
+
745
+ export async function runRebackupStorageSubmit(
746
+ input: string,
747
+ step: Extract<Step, { kind: 'rebackup-storage' }>,
748
+ callbacks: EffectCallbacks,
749
+ ): Promise<void> {
750
+ const { jwt: pinataJwt } = await savePinataJwt(input)
751
+ callbacks.onStep({ kind: 'rebackup-signing', identity: step.identity, registry: step.registry, pinataJwt, profileUpdates: step.profileUpdates, returnTo: step.returnTo })
752
+ }
753
+
754
+ export async function runPublicProfilePreflight(
755
+ identity: EthagentIdentity,
756
+ registry: Erc8004RegistryConfig,
757
+ callbacks: EffectCallbacks,
758
+ profileUpdates?: ProfileUpdates,
759
+ returnTo: Step = { kind: 'continuity-public' },
760
+ ): Promise<void> {
761
+ const apiUrl = DEFAULT_IPFS_API_URL
762
+ let jwt: string | undefined
763
+ try {
764
+ jwt = isPinataUploadUrl(apiUrl) ? await resolveValidatedPinataJwt() : undefined
765
+ } catch (err: unknown) {
766
+ callbacks.onStep({ kind: 'public-profile-storage', identity, registry, error: (err as Error).message, profileUpdates, returnTo })
767
+ return
768
+ }
769
+ if (isPinataUploadUrl(apiUrl) && !jwt) {
770
+ callbacks.onStep({ kind: 'public-profile-storage', identity, registry, profileUpdates, returnTo })
771
+ return
772
+ }
773
+ callbacks.onStep({ kind: 'public-profile-signing', identity, registry, pinataJwt: jwt, profileUpdates, returnTo })
774
+ }
775
+
776
+ export async function runPublicProfileSigning(
777
+ step: Extract<Step, { kind: 'public-profile-signing' }>,
778
+ callbacks: EffectCallbacks,
779
+ ): Promise<void> {
780
+ const expectedOwner = getAddress(step.identity.ownerAddress ?? step.identity.address)
781
+ if (!step.identity.agentId) throw new Error('cannot publish public profile: identity is missing an agent token id')
782
+
783
+ const prepared = await preparePublicProfileTransaction(step, expectedOwner)
784
+ const agentId = BigInt(step.identity.agentId)
785
+ await preflightSetAgentUri({
786
+ ...step.registry,
787
+ account: expectedOwner,
788
+ agentId,
789
+ newUri: prepared.agentUri,
790
+ })
791
+
792
+ const tx = await sendBrowserWalletTransaction({
793
+ chainId: step.registry.chainId,
794
+ expectedAccount: expectedOwner,
795
+ to: step.registry.identityRegistryAddress,
796
+ data: encodeSetAgentUri({ agentId, newUri: prepared.agentUri }),
797
+ onReady: callbacks.onWalletReady,
798
+ })
799
+ const client = createErc8004PublicClient(step.registry)
800
+ await client.waitForTransactionReceipt({ hash: tx.txHash })
801
+
802
+ const nextIdentity: EthagentIdentity = {
803
+ ...prepared.identity,
804
+ source: 'erc8004',
805
+ address: getAddress(prepared.ownerAddress),
806
+ ownerAddress: getAddress(prepared.ownerAddress),
807
+ chainId: step.registry.chainId,
808
+ rpcUrl: step.registry.rpcUrl,
809
+ identityRegistryAddress: step.registry.identityRegistryAddress,
810
+ agentUri: prepared.agentUri,
811
+ metadataCid: prepared.metadataCid,
812
+ publicSkills: prepared.publicSkills,
813
+ }
814
+ await writePublicSkillsFile(nextIdentity, prepared.publicSkillsJson)
815
+ await callbacks.onIdentityComplete(nextIdentity, step.profileUpdates ? 'public profile updated' : 'public profile published')
816
+ }
817
+
818
+ export async function runPublicProfileStorageSubmit(
819
+ input: string,
820
+ step: Extract<Step, { kind: 'public-profile-storage' }>,
821
+ callbacks: EffectCallbacks,
822
+ ): Promise<void> {
823
+ const { jwt: pinataJwt } = await savePinataJwt(input)
824
+ callbacks.onStep({
825
+ kind: 'public-profile-signing',
826
+ identity: step.identity,
827
+ registry: step.registry,
828
+ pinataJwt,
829
+ profileUpdates: step.profileUpdates,
830
+ returnTo: step.returnTo,
831
+ })
832
+ }
833
+
834
+ export async function runContinuityUnlock(
835
+ step: Extract<Step, { kind: 'continuity-unlocking' }>,
836
+ callbacks: Pick<EffectCallbacks, 'onStep' | 'onWalletReady'>,
837
+ ): Promise<void> {
838
+ const identity = step.identity
839
+ const ownerAddress = getAddress(identity.ownerAddress ?? identity.address)
840
+ const chainId = identity.chainId ?? identity.backup?.chainId ?? 1
841
+ const snapshotCid = step.cid ?? identity.backup?.cid
842
+ if (snapshotCid) {
843
+ const raw = await catFromIpfs(identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL, snapshotCid)
844
+ const envelope = parseRestorableEnvelope(raw)
845
+ if (isContinuitySnapshotEnvelope(envelope)) {
846
+ assertContinuitySnapshotOwner(envelope, ownerAddress)
847
+ const wallet = await requestBrowserWalletSignature({
848
+ chainId,
849
+ expectedAccount: ownerAddress,
850
+ message: envelope.challenge,
851
+ onReady: callbacks.onWalletReady,
852
+ })
853
+ const payload = restoreContinuitySnapshotEnvelope({ envelope, walletSignature: wallet.signature })
854
+ await writeContinuityFiles({ ...identity, state: payload.state }, payload.files)
855
+ await restorePublishedPublicSkills(identity, identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL, step.publicSkillsCid)
856
+ callbacks.onStep({ kind: 'continuity-private', notice: 'published snapshot restored locally. review, then publish when ready.' })
857
+ return
858
+ }
859
+ assertAgentStateBackupOwner(envelope, ownerAddress)
860
+ const wallet = await requestBrowserWalletSignature({
861
+ chainId,
862
+ expectedAccount: ownerAddress,
863
+ message: envelope.challenge,
864
+ onReady: callbacks.onWalletReady,
865
+ })
866
+ restoreAgentStateBackupEnvelope({ envelope, walletSignature: wallet.signature })
867
+ } else {
868
+ const wallet = await requestBrowserWalletSignature({
869
+ chainId,
870
+ expectedAccount: ownerAddress,
871
+ message: createContinuitySnapshotChallenge(ownerAddress),
872
+ onReady: callbacks.onWalletReady,
873
+ })
874
+ void wallet.signature
875
+ }
876
+ await ensureContinuityFiles(identity)
877
+ callbacks.onStep({ kind: 'continuity-private', notice: 'local private working files are ready on this machine.' })
878
+ }
879
+
880
+
881
+ export async function runRecoveryRefetch(
882
+ identity: EthagentIdentity,
883
+ registry: Erc8004RegistryConfig,
884
+ callbacks: EffectCallbacks,
885
+ ): Promise<void> {
886
+ if (!identity.agentId) throw new Error('cannot refetch: identity is missing an agent token id')
887
+ const ownerAddress = getAddress(identity.ownerAddress ?? identity.address)
888
+ const candidate = await discoverOwnedAgentBackupByTokenId({
889
+ ...registry,
890
+ ownerHandle: ownerAddress,
891
+ tokenId: BigInt(identity.agentId),
892
+ ipfsApiUrl: identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL,
893
+ })
894
+ if (!candidate.backup?.cid) {
895
+ throw new Error('the published agent does not have a recoverable encrypted snapshot')
896
+ }
897
+ const apiUrl = identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL
898
+ const raw = await catFromIpfs(apiUrl, candidate.backup.cid)
899
+ const envelope = parseRestorableEnvelope(raw)
900
+ if (!isContinuitySnapshotEnvelope(envelope)) {
901
+ throw new Error('on-chain backup is in a legacy format and cannot be refetched here; use switch agent')
902
+ }
903
+ assertContinuitySnapshotOwner(envelope, ownerAddress)
904
+ const wallet = await requestBrowserWalletSignature({
905
+ chainId: candidate.chainId,
906
+ expectedAccount: ownerAddress,
907
+ message: envelope.challenge,
908
+ onReady: callbacks.onWalletReady,
909
+ })
910
+ callbacks.onWalletReady(null)
911
+ callbacks.onRestoreProgress?.({ phase: 'decrypting', label: 'signature received · decrypting on-chain snapshot...' })
912
+ const payload = restoreContinuitySnapshotEnvelope({ envelope, walletSignature: wallet.signature })
913
+ callbacks.onRestoreProgress?.({ phase: 'writing', label: 'overwriting local SOUL.md, MEMORY.md, skills.json...' })
914
+ const refreshedBackup: BackupMetadata = {
915
+ cid: candidate.backup.cid,
916
+ createdAt: envelope.createdAt,
917
+ envelopeVersion: envelope.envelopeVersion,
918
+ ipfsApiUrl: apiUrl,
919
+ status: 'restored',
920
+ ownerAddress,
921
+ chainId: candidate.chainId,
922
+ rpcUrl: candidate.rpcUrl,
923
+ identityRegistryAddress: candidate.identityRegistryAddress,
924
+ agentId: candidate.agentId.toString(),
925
+ agentUri: candidate.agentUri,
926
+ metadataCid: candidate.metadataCid,
927
+ }
928
+ const nextIdentity: EthagentIdentity = {
929
+ ...identity,
930
+ source: 'erc8004',
931
+ address: ownerAddress,
932
+ ownerAddress,
933
+ chainId: candidate.chainId,
934
+ rpcUrl: candidate.rpcUrl,
935
+ identityRegistryAddress: candidate.identityRegistryAddress,
936
+ agentId: candidate.agentId.toString(),
937
+ agentUri: candidate.agentUri,
938
+ metadataCid: candidate.metadataCid,
939
+ state: {
940
+ ...payload.state,
941
+ ...(candidate.name ? { name: candidate.name } : {}),
942
+ ...(candidate.description ? { description: candidate.description } : {}),
943
+ ...(candidate.imageUrl ? { imageUrl: candidate.imageUrl } : {}),
944
+ },
945
+ backup: refreshedBackup,
946
+ ...(candidate.publicDiscovery ? {
947
+ publicSkills: {
948
+ ...(candidate.publicDiscovery.skillsCid ? { cid: candidate.publicDiscovery.skillsCid } : {}),
949
+ ...(candidate.publicDiscovery.agentCardCid ? { agentCardCid: candidate.publicDiscovery.agentCardCid } : {}),
950
+ ...(candidate.publicDiscovery.updatedAt ? { updatedAt: candidate.publicDiscovery.updatedAt } : {}),
951
+ status: 'pinned',
952
+ },
953
+ } : {}),
954
+ }
955
+ await writeContinuityFiles(nextIdentity, payload.files)
956
+ callbacks.onRestoreProgress?.({ phase: 'finishing', label: 'finalizing refreshed identity...' })
957
+ await restorePublishedPublicSkills(nextIdentity, apiUrl, candidate.publicDiscovery?.skillsCid)
958
+ await ensureIdentityMarkdownScaffold(nextIdentity)
959
+ await recordPublishedContinuitySnapshot({ identity: nextIdentity, label: 'refetched latest snapshot from chain' }).catch(() => null)
960
+ await callbacks.onIdentityComplete(nextIdentity, 'latest published snapshot restored from chain')
961
+ }
962
+
963
+
964
+ async function preparePublicProfileTransaction(
965
+ step: Extract<Step, { kind: 'public-profile-signing' }>,
966
+ ownerAddress: Address,
967
+ ): Promise<PublicProfilePreparedTransaction> {
968
+ const baseState = (step.identity.state ?? {}) as Record<string, unknown>
969
+ const profile = step.profileUpdates ?? {}
970
+ const nextName = typeof profile.name === 'string' && profile.name.trim()
971
+ ? profile.name.trim()
972
+ : (typeof baseState.name === 'string' && baseState.name.trim() ? baseState.name.trim() : deriveAgentName(step.identity))
973
+ const nextDescription = profile.description !== undefined
974
+ ? profile.description.trim()
975
+ : (typeof baseState.description === 'string' ? baseState.description : '')
976
+ const uploadedImageUri = profile.imagePath === 'delete'
977
+ ? ''
978
+ : profile.imagePath
979
+ ? await uploadAgentImage(profile.imagePath, step.pinataJwt)
980
+ : typeof baseState.imageUrl === 'string' && baseState.imageUrl.trim()
981
+ ? baseState.imageUrl.trim()
982
+ : undefined
983
+ const updatedAt = new Date().toISOString()
984
+ const state: Record<string, unknown> = {
985
+ ...baseState,
986
+ name: nextName,
987
+ description: nextDescription,
988
+ publicProfileUpdatedAt: updatedAt,
989
+ }
990
+ if (uploadedImageUri === '') {
991
+ delete state.imageUrl
992
+ } else if (uploadedImageUri) {
993
+ state.imageUrl = uploadedImageUri
994
+ }
995
+ const nextIdentityForFiles: EthagentIdentity = { ...step.identity, state }
996
+ const publicSkillsJson = step.profileUpdates
997
+ ? await prepareSyncedPublicSkillsJson(nextIdentityForFiles)
998
+ : await ensurePublicSkillsFile(nextIdentityForFiles)
999
+ const publicSkillsPin = await addToIpfs(DEFAULT_IPFS_API_URL, publicSkillsJson, fetch, { pinataJwt: step.pinataJwt })
1000
+ assertVerifiedPin(publicSkillsPin)
1001
+ const agentCardPin = await addToIpfs(
1002
+ DEFAULT_IPFS_API_URL,
1003
+ serializeAgentCard(createAgentCard(defaultPublicSkillsProfile(nextIdentityForFiles))),
1004
+ fetch,
1005
+ { pinataJwt: step.pinataJwt },
1006
+ )
1007
+ assertVerifiedPin(agentCardPin)
1008
+ const publicSkills: PublicSkillsMetadata = {
1009
+ cid: publicSkillsPin.cid,
1010
+ agentCardCid: agentCardPin.cid,
1011
+ updatedAt,
1012
+ status: 'pinned',
1013
+ }
1014
+ const backup = step.identity.backup
1015
+ const registration = withEthagentPointers({
1016
+ type: 'https://eips.ethereum.org/EIPS/eip-8004#registration-v1',
1017
+ name: nextName,
1018
+ ...(nextDescription ? { description: nextDescription } : {}),
1019
+ ...(uploadedImageUri ? { image: uploadedImageUri } : {}),
1020
+ }, {
1021
+ ...(backup ? {
1022
+ backup: {
1023
+ cid: backup.cid,
1024
+ envelopeVersion: backup.envelopeVersion,
1025
+ createdAt: backup.createdAt,
1026
+ },
1027
+ } : {}),
1028
+ publicDiscovery: {
1029
+ skillsCid: publicSkills.cid,
1030
+ agentCardCid: publicSkills.agentCardCid,
1031
+ updatedAt,
1032
+ },
1033
+ registration: {
1034
+ chainId: step.registry.chainId,
1035
+ identityRegistryAddress: step.registry.identityRegistryAddress,
1036
+ agentId: step.identity.agentId,
1037
+ },
1038
+ })
1039
+ const metadataPin = await addToIpfs(DEFAULT_IPFS_API_URL, JSON.stringify(registration, null, 2), fetch, { pinataJwt: step.pinataJwt })
1040
+ assertVerifiedPin(metadataPin)
1041
+ return {
1042
+ ownerAddress,
1043
+ agentUri: `ipfs://${metadataPin.cid}`,
1044
+ metadataCid: metadataPin.cid,
1045
+ publicSkills,
1046
+ identity: { ...step.identity, state },
1047
+ publicSkillsJson,
1048
+ }
1049
+ }
1050
+
1051
+ function deriveAgentName(identity: EthagentIdentity): string {
1052
+ const state = (identity.state ?? {}) as Record<string, unknown>
1053
+ const name = typeof state.name === 'string' ? state.name.trim() : ''
1054
+ if (name) return name
1055
+ return identity.agentId ? `agent #${identity.agentId}` : 'unnamed agent'
1056
+ }
1057
+
1058
+ async function uploadAgentImage(imagePath: string, pinataJwt: string | undefined): Promise<string> {
1059
+ const file = resolveImagePath(imagePath)
1060
+ const data = await fs.readFile(file)
1061
+ const contentType = imageContentType(file)
1062
+ const pin = await addFileToIpfs(DEFAULT_IPFS_API_URL, data, path.basename(file), contentType, fetch, { pinataJwt })
1063
+ assertVerifiedPin(pin)
1064
+ return `ipfs://${pin.cid}`
1065
+ }
1066
+
1067
+ function resolveImagePath(input: string): string {
1068
+ const trimmed = input.trim()
1069
+ if (!trimmed) throw new Error('image file path is empty')
1070
+ if (/^https?:\/\//i.test(trimmed) || /^ipfs:\/\//i.test(trimmed)) {
1071
+ throw new Error('enter a local image file path; ethagent uploads it to IPFS')
1072
+ }
1073
+ if (!/\.(png|jpe?g|gif|webp|svg)$/i.test(trimmed)) {
1074
+ throw new Error('agent image must be png, jpg, gif, webp, or svg')
1075
+ }
1076
+ return path.resolve(trimmed.replace(/^~(?=$|[\\/])/, os.homedir()))
1077
+ }
1078
+
1079
+ function imageContentType(file: string): string {
1080
+ const ext = path.extname(file).toLowerCase()
1081
+ switch (ext) {
1082
+ case '.png':
1083
+ return 'image/png'
1084
+ case '.jpg':
1085
+ case '.jpeg':
1086
+ return 'image/jpeg'
1087
+ case '.gif':
1088
+ return 'image/gif'
1089
+ case '.webp':
1090
+ return 'image/webp'
1091
+ case '.svg':
1092
+ return 'image/svg+xml'
1093
+ default:
1094
+ throw new Error('agent image must be png, jpg, gif, webp, or svg')
1095
+ }
1096
+ }
1097
+
1098
+ function assertVerifiedPin(pin: IpfsAddResult, expectedCid?: string): void {
1099
+ if (expectedCid && pin.cid !== expectedCid) throw new Error('IPFS pin verification did not match the published CID')
1100
+ if (!pin.pinVerified) throw new Error(`IPFS pin was not verified for ${pin.cid}`)
1101
+ }
1102
+
1103
+ function parseRestorableEnvelope(raw: string | Uint8Array): ReturnType<typeof parseAgentStateBackupEnvelope> | ContinuitySnapshotEnvelope {
1104
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
1105
+ const parsed = JSON.parse(text) as { envelopeVersion?: unknown }
1106
+ if (parsed.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION) {
1107
+ return parseContinuitySnapshotEnvelope(text)
1108
+ }
1109
+ return parseAgentStateBackupEnvelope(text)
1110
+ }
1111
+
1112
+ function isContinuitySnapshotEnvelope(envelope: ReturnType<typeof parseRestorableEnvelope>): envelope is ContinuitySnapshotEnvelope {
1113
+ return envelope.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
1114
+ }
1115
+
1116
+ function identityDraftForBackup(args: {
1117
+ ownerAddress: Address
1118
+ registry: Erc8004RegistryConfig
1119
+ state: Record<string, unknown>
1120
+ }): EthagentIdentity {
1121
+ return {
1122
+ source: 'erc8004',
1123
+ address: args.ownerAddress,
1124
+ ownerAddress: args.ownerAddress,
1125
+ createdAt: typeof args.state.createdAt === 'string' ? args.state.createdAt : new Date().toISOString(),
1126
+ chainId: args.registry.chainId,
1127
+ rpcUrl: args.registry.rpcUrl,
1128
+ identityRegistryAddress: args.registry.identityRegistryAddress,
1129
+ agentUri: PREFLIGHT_AGENT_URI,
1130
+ state: args.state,
1131
+ }
1132
+ }
1133
+
1134
+ async function restorePublishedPublicSkills(
1135
+ identity: EthagentIdentity,
1136
+ apiUrl: string,
1137
+ cid: string | undefined,
1138
+ ): Promise<void> {
1139
+ if (!cid) return
1140
+ try {
1141
+ const raw = await catFromIpfs(apiUrl, cid)
1142
+ await writePublicSkillsFile(identity, new TextDecoder().decode(raw))
1143
+ } catch {
1144
+ // Public skills are recoverable from IPFS later and must not block private restore.
1145
+ }
1146
+ }