ethagent 0.2.0 → 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 +30 -8
  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,19 @@
1
+ import React from 'react'
2
+ import { Box, render } from 'ink'
3
+ import { BrandSplash } from '../ui/BrandSplash.js'
4
+
5
+ /**
6
+ * `ethagent preview` — renders the brand splash with only the tagline
7
+ * in the top border, no technical details at the bottom, and exits.
8
+ */
9
+ export async function runPreviewCommand(): Promise<number> {
10
+ const instance = render(
11
+ <Box flexDirection="column" marginY={1}>
12
+ <BrandSplash />
13
+ </Box>,
14
+ )
15
+ // Give Ink one tick to paint, then unmount cleanly.
16
+ await new Promise<void>(resolve => setTimeout(resolve, 50))
17
+ instance.unmount()
18
+ return 0
19
+ }
@@ -0,0 +1,106 @@
1
+ import { createInterface } from 'node:readline/promises'
2
+ import { stdin as processStdin, stdout as processStdout, stderr as processStderr } from 'node:process'
3
+ import React from 'react'
4
+ import { render } from 'ink'
5
+ import {
6
+ createFactoryResetPlan,
7
+ formatFactoryResetPlan,
8
+ runFactoryReset,
9
+ type FactoryResetPlan,
10
+ } from '../storage/factoryReset.js'
11
+ import { AppInputProvider } from '../app/input/AppInputProvider.js'
12
+ import { ResetConfirmView } from './ResetConfirmView.js'
13
+
14
+ export type ResetCommandIO = {
15
+ write?: (text: string) => void
16
+ writeError?: (text: string) => void
17
+ readConfirmation?: () => Promise<string>
18
+ clearSecrets?: boolean
19
+ input?: NodeJS.ReadableStream
20
+ output?: NodeJS.WritableStream
21
+ }
22
+
23
+ export async function runResetCommand(args: string[] = [], io: ResetCommandIO = {}): Promise<number> {
24
+ const write = io.write ?? (text => { processStdout.write(text) })
25
+ const writeError = io.writeError ?? (text => { processStderr.write(text) })
26
+ const yes = args.includes('--yes') || args.includes('-y')
27
+ const unknown = args.filter(arg => arg !== '--yes' && arg !== '-y')
28
+ if (unknown.length > 0) {
29
+ writeError(`unknown reset option: ${unknown[0]}\nusage: ethagent reset [--yes]\n`)
30
+ return 2
31
+ }
32
+
33
+ const plan = await createFactoryResetPlan()
34
+
35
+ if (!yes) {
36
+ const confirmed = io.readConfirmation
37
+ ? await readTextConfirmation(plan, io)
38
+ : await readInkConfirmation(plan, io)
39
+ if (!confirmed) {
40
+ write('factory reset cancelled.\n')
41
+ return 1
42
+ }
43
+ } else {
44
+ write(`${formatFactoryResetPlan(plan)}\n`)
45
+ }
46
+
47
+ const result = await runFactoryReset({ clearSecrets: io.clearSecrets })
48
+ write([
49
+ 'factory reset complete.',
50
+ `deleted ${result.deletedPaths.length} local path${result.deletedPaths.length === 1 ? '' : 's'}.`,
51
+ `cleared ${result.clearedSecretAccounts.length} known secret account${result.clearedSecretAccounts.length === 1 ? '' : 's'}.`,
52
+ result.preservedPaths.length > 0
53
+ ? `preserved local LLM assets: ${result.preservedPaths.length} path${result.preservedPaths.length === 1 ? '' : 's'}.`
54
+ : 'no local model assets were present.',
55
+ '',
56
+ ].join('\n'))
57
+ return 0
58
+ }
59
+
60
+ async function readTextConfirmation(plan: FactoryResetPlan, io: ResetCommandIO): Promise<boolean> {
61
+ ;(io.write ?? (text => { processStdout.write(text) }))(`${formatFactoryResetPlan(plan)}\n`)
62
+ const answer = await readConfirmation(io)
63
+ return answer.trim().toLowerCase() === 'confirm'
64
+ }
65
+
66
+ async function readInkConfirmation(plan: FactoryResetPlan, io: ResetCommandIO): Promise<boolean> {
67
+ let confirmed = false
68
+ const instance = render(
69
+ React.createElement(
70
+ AppInputProvider,
71
+ null,
72
+ React.createElement(ResetConfirmView, {
73
+ plan,
74
+ onDone: (value: boolean) => { confirmed = value },
75
+ }),
76
+ ),
77
+ {
78
+ exitOnCtrlC: false,
79
+ stdin: (io.input ?? processStdin) as typeof processStdin,
80
+ stdout: (io.output ?? processStdout) as typeof processStdout,
81
+ },
82
+ )
83
+ try {
84
+ await instance.waitUntilExit()
85
+ } catch {
86
+ return false
87
+ }
88
+ return confirmed
89
+ }
90
+
91
+ async function readConfirmation(io: ResetCommandIO): Promise<string> {
92
+ if (io.readConfirmation) {
93
+ ;(io.write ?? (text => { processStdout.write(text) }))('type confirm to wipe local ethagent data: ')
94
+ return io.readConfirmation()
95
+ }
96
+
97
+ const rl = createInterface({
98
+ input: io.input ?? processStdin,
99
+ output: io.output ?? processStdout,
100
+ })
101
+ try {
102
+ return await rl.question('type confirm to wipe local ethagent data: ')
103
+ } finally {
104
+ rl.close()
105
+ }
106
+ }
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+
5
+ export type EditorOpenResult =
6
+ | { ok: true; method: string; waited: boolean }
7
+ | { ok: false; error: string }
8
+
9
+ export type EditorCommand = {
10
+ cmd: string
11
+ args: string[]
12
+ method: string
13
+ waited: boolean
14
+ shell?: boolean
15
+ }
16
+
17
+ export type EditorResolutionOptions = {
18
+ platform?: NodeJS.Platform
19
+ commandExists?: (command: string) => string | null
20
+ }
21
+
22
+ const IDE_CANDIDATES = ['code', 'cursor', 'windsurf'] as const
23
+
24
+ export function openFileInEditor(file: string, env: NodeJS.ProcessEnv = process.env): Promise<EditorOpenResult> {
25
+ const command = resolveEditorCommand(file, env)
26
+ if (command) return openEditorCommand(command)
27
+ return openDefaultEditor(file)
28
+ }
29
+
30
+ export function resolveEditorCommand(
31
+ file: string,
32
+ env: NodeJS.ProcessEnv = process.env,
33
+ options: EditorResolutionOptions = {},
34
+ ): EditorCommand | null {
35
+ const platform = options.platform ?? process.platform
36
+ const commandExists = options.commandExists ?? (command => findExecutable(command, env, platform))
37
+
38
+ const ethagentEditor = env.ETHAGENT_EDITOR?.trim()
39
+ if (ethagentEditor) return configuredCommand(ethagentEditor, file, true, platform)
40
+
41
+ for (const candidate of IDE_CANDIDATES) {
42
+ const executable = commandExists(candidate)
43
+ if (executable) {
44
+ return {
45
+ cmd: executable,
46
+ args: [file],
47
+ method: candidate,
48
+ waited: false,
49
+ shell: platform === 'win32' && /\.(?:cmd|bat)$/i.test(executable),
50
+ }
51
+ }
52
+ }
53
+
54
+ const configured = env.VISUAL?.trim() || env.EDITOR?.trim()
55
+ if (configured) return configuredCommand(configured, file, true, platform)
56
+
57
+ return defaultEditorCommand(file, platform)
58
+ }
59
+
60
+ function configuredCommand(commandLine: string, file: string, waited: boolean, platform: NodeJS.Platform): EditorCommand | null {
61
+ const [cmd, ...args] = splitCommand(commandLine)
62
+ if (!cmd) return null
63
+ return {
64
+ cmd,
65
+ args: [...args, file],
66
+ method: path.basename(cmd),
67
+ waited,
68
+ shell: platform === 'win32' && /\.(?:cmd|bat)$/i.test(cmd),
69
+ }
70
+ }
71
+
72
+ function openEditorCommand(command: EditorCommand): Promise<EditorOpenResult> {
73
+ return new Promise(resolve => {
74
+ let child
75
+ try {
76
+ child = spawn(command.cmd, command.args, command.waited
77
+ ? { stdio: 'inherit', shell: command.shell }
78
+ : { detached: true, stdio: 'ignore', shell: command.shell })
79
+ } catch (err: unknown) {
80
+ resolve({ ok: false, error: (err as Error).message })
81
+ return
82
+ }
83
+ child.on('error', err => resolve({ ok: false, error: err.message }))
84
+ if (!command.waited) {
85
+ child.unref()
86
+ resolve({ ok: true, method: command.method, waited: false })
87
+ return
88
+ }
89
+ child.on('close', code => {
90
+ if (code === 0) resolve({ ok: true, method: command.method, waited: true })
91
+ else resolve({ ok: false, error: `${command.method} exited ${code}` })
92
+ })
93
+ })
94
+ }
95
+
96
+ function openDefaultEditor(file: string): Promise<EditorOpenResult> {
97
+ const command = defaultEditorCommand(file)
98
+ if (!command) return Promise.resolve({ ok: false, error: 'no default editor command for this platform' })
99
+ return openEditorCommand(command)
100
+ }
101
+
102
+ function defaultEditorCommand(file: string, platform: NodeJS.Platform = process.platform): EditorCommand | null {
103
+ if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '', file], method: 'cmd', waited: false }
104
+ if (platform === 'darwin') return { cmd: 'open', args: [file], method: 'open', waited: false }
105
+ return { cmd: 'xdg-open', args: [file], method: 'xdg-open', waited: false }
106
+ }
107
+
108
+ function splitCommand(commandLine: string): string[] {
109
+ return commandLine.match(/"[^"]+"|'[^']+'|\S+/g)?.map(part => {
110
+ if ((part.startsWith('"') && part.endsWith('"')) || (part.startsWith("'") && part.endsWith("'"))) {
111
+ return part.slice(1, -1)
112
+ }
113
+ return part
114
+ }) ?? []
115
+ }
116
+
117
+ function findExecutable(command: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null {
118
+ const hasPathSeparator = command.includes('/') || command.includes('\\')
119
+ if (hasPathSeparator || path.isAbsolute(command)) {
120
+ return canAccessExecutable(command) ? command : null
121
+ }
122
+
123
+ const pathValue = env.PATH ?? ''
124
+ const pathParts = pathValue.split(path.delimiter).filter(Boolean)
125
+ const extensions = platform === 'win32'
126
+ ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
127
+ : ['']
128
+
129
+ for (const dir of pathParts) {
130
+ for (const ext of extensions) {
131
+ const candidate = path.join(dir, platform === 'win32' && path.extname(command) === '' ? `${command}${ext.toLowerCase()}` : command)
132
+ if (canAccessExecutable(candidate)) return candidate
133
+ if (platform === 'win32') {
134
+ const upperCandidate = path.join(dir, path.extname(command) === '' ? `${command}${ext.toUpperCase()}` : command)
135
+ if (canAccessExecutable(upperCandidate)) return upperCandidate
136
+ }
137
+ }
138
+ }
139
+ return null
140
+ }
141
+
142
+ function canAccessExecutable(file: string): boolean {
143
+ try {
144
+ fs.accessSync(file, fs.constants.X_OK)
145
+ return true
146
+ } catch {
147
+ return false
148
+ }
149
+ }
@@ -0,0 +1,345 @@
1
+ import crypto from 'node:crypto'
2
+ import { ml_kem1024 } from '@noble/post-quantum/ml-kem.js'
3
+ import { recoverAddressFromSignature, toChecksumAddress } from '../crypto/eth.js'
4
+
5
+ export const CONTINUITY_SNAPSHOT_ENVELOPE_VERSION = 'ethagent-continuity-snapshot-v1'
6
+
7
+ export type ContinuityFiles = {
8
+ 'SOUL.md': string
9
+ 'MEMORY.md': string
10
+ }
11
+
12
+ export type ContinuityTranscriptSummary = {
13
+ sessionId?: string
14
+ createdAt?: string
15
+ summary: string
16
+ }
17
+
18
+ export type ContinuityAgentSnapshot = {
19
+ chainId?: number
20
+ identityRegistryAddress?: string
21
+ agentId?: string
22
+ agentUri?: string
23
+ metadataCid?: string
24
+ name?: string
25
+ description?: string
26
+ }
27
+
28
+ export type ContinuitySnapshotPayload = {
29
+ version: 1
30
+ ownerAddress: string
31
+ createdAt: string
32
+ sequence?: number
33
+ agent: ContinuityAgentSnapshot
34
+ files: ContinuityFiles
35
+ transcript: ContinuityTranscriptSummary[]
36
+ state: Record<string, unknown>
37
+ }
38
+
39
+ export type ContinuitySnapshotEnvelope = {
40
+ version: 1
41
+ envelopeVersion: typeof CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
42
+ ownerAddress: string
43
+ createdAt: string
44
+ challenge: string
45
+ crypto: {
46
+ kem: 'ML-KEM-1024'
47
+ aead: 'AES-256-GCM'
48
+ kdf: 'HKDF-SHA256'
49
+ signature: 'EIP-191'
50
+ }
51
+ salt: string
52
+ kemPublicKey: string
53
+ kemCiphertext: string
54
+ nonce: string
55
+ ciphertext: string
56
+ tag: string
57
+ }
58
+
59
+ export type CreateContinuitySnapshotEnvelopeArgs = {
60
+ ownerAddress: string
61
+ walletSignature: string
62
+ payload: Omit<ContinuitySnapshotPayload, 'version' | 'ownerAddress' | 'createdAt'> & {
63
+ createdAt?: string
64
+ }
65
+ }
66
+
67
+ export type RestoreContinuitySnapshotEnvelopeArgs = {
68
+ envelope: ContinuitySnapshotEnvelope
69
+ walletSignature: string
70
+ }
71
+
72
+ export class ContinuitySnapshotOwnerMismatchError extends Error {
73
+ constructor(
74
+ readonly snapshotOwner: string,
75
+ readonly currentOwner: string,
76
+ ) {
77
+ super('continuity snapshot is encrypted for another wallet')
78
+ this.name = 'ContinuitySnapshotOwnerMismatchError'
79
+ }
80
+ }
81
+
82
+ export function createContinuitySnapshotChallenge(ownerAddress: string): string {
83
+ const checksum = toChecksumAddress(ownerAddress)
84
+ return [
85
+ 'ethagent private continuity',
86
+ `Owner: ${checksum}`,
87
+ 'Purpose: unlock the encrypted SOUL.md and MEMORY.md snapshot for this device',
88
+ 'Scope: read and restore private agent continuity only',
89
+ 'Safety: this signature does not send a transaction, spend funds, or grant token approval',
90
+ 'Version: 1',
91
+ ].join('\n')
92
+ }
93
+
94
+ export function createContinuitySnapshotEnvelope(args: CreateContinuitySnapshotEnvelopeArgs): ContinuitySnapshotEnvelope {
95
+ const ownerAddress = toChecksumAddress(args.ownerAddress)
96
+ const challenge = createContinuitySnapshotChallenge(ownerAddress)
97
+ assertSignatureForAddress(challenge, args.walletSignature, ownerAddress)
98
+
99
+ const createdAt = args.payload.createdAt ?? new Date().toISOString()
100
+ const payload: ContinuitySnapshotPayload = {
101
+ version: 1,
102
+ ownerAddress,
103
+ createdAt,
104
+ ...(args.payload.sequence !== undefined ? { sequence: args.payload.sequence } : {}),
105
+ agent: normalizeAgentSnapshot(args.payload.agent),
106
+ files: normalizeContinuityFiles(args.payload.files),
107
+ transcript: normalizeTranscript(args.payload.transcript),
108
+ state: normalizeState(args.payload.state),
109
+ }
110
+
111
+ const salt = crypto.randomBytes(32)
112
+ const kemSeed = deriveContinuityKemSeed(args.walletSignature, salt, ownerAddress)
113
+ const kemKeys = ml_kem1024.keygen(kemSeed)
114
+ const kem = ml_kem1024.encapsulate(kemKeys.publicKey)
115
+ const key = deriveContinuityAesKey(args.walletSignature, kem.sharedSecret, salt, ownerAddress)
116
+ const nonce = crypto.randomBytes(12)
117
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce)
118
+ cipher.setAAD(continuityAadFor(ownerAddress, createdAt))
119
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf8')
120
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()])
121
+ const tag = cipher.getAuthTag()
122
+
123
+ return {
124
+ version: 1,
125
+ envelopeVersion: CONTINUITY_SNAPSHOT_ENVELOPE_VERSION,
126
+ ownerAddress,
127
+ createdAt,
128
+ challenge,
129
+ crypto: {
130
+ kem: 'ML-KEM-1024',
131
+ aead: 'AES-256-GCM',
132
+ kdf: 'HKDF-SHA256',
133
+ signature: 'EIP-191',
134
+ },
135
+ salt: toBase64(salt),
136
+ kemPublicKey: toBase64(kemKeys.publicKey),
137
+ kemCiphertext: toBase64(kem.cipherText),
138
+ nonce: toBase64(nonce),
139
+ ciphertext: toBase64(encrypted),
140
+ tag: toBase64(tag),
141
+ }
142
+ }
143
+
144
+ export function restoreContinuitySnapshotEnvelope(args: RestoreContinuitySnapshotEnvelopeArgs): ContinuitySnapshotPayload {
145
+ const envelope = normalizeContinuitySnapshotEnvelope(args.envelope)
146
+ assertSignatureForAddress(envelope.challenge, args.walletSignature, envelope.ownerAddress)
147
+
148
+ const salt = fromBase64(envelope.salt)
149
+ const kemSeed = deriveContinuityKemSeed(args.walletSignature, salt, envelope.ownerAddress)
150
+ const kemKeys = ml_kem1024.keygen(kemSeed)
151
+ const expectedPublicKey = toBase64(kemKeys.publicKey)
152
+ if (expectedPublicKey !== envelope.kemPublicKey) {
153
+ throw new Error('wallet signature does not match this continuity snapshot')
154
+ }
155
+
156
+ const sharedSecret = ml_kem1024.decapsulate(fromBase64(envelope.kemCiphertext), kemKeys.secretKey)
157
+ const key = deriveContinuityAesKey(args.walletSignature, sharedSecret, salt, envelope.ownerAddress)
158
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, fromBase64(envelope.nonce))
159
+ decipher.setAAD(continuityAadFor(envelope.ownerAddress, envelope.createdAt))
160
+ decipher.setAuthTag(fromBase64(envelope.tag))
161
+
162
+ let decoded: unknown
163
+ try {
164
+ const plaintext = Buffer.concat([
165
+ decipher.update(fromBase64(envelope.ciphertext)),
166
+ decipher.final(),
167
+ ]).toString('utf8')
168
+ decoded = JSON.parse(plaintext)
169
+ } catch {
170
+ throw new Error('could not decrypt continuity snapshot with the supplied wallet signature')
171
+ }
172
+
173
+ const payload = normalizeContinuityPayload(decoded)
174
+ if (payload.ownerAddress.toLowerCase() !== envelope.ownerAddress.toLowerCase()) {
175
+ throw new Error('continuity snapshot owner mismatch')
176
+ }
177
+ if (payload.createdAt !== envelope.createdAt) {
178
+ throw new Error('continuity snapshot timestamp mismatch')
179
+ }
180
+ return payload
181
+ }
182
+
183
+ export function assertContinuitySnapshotOwner(envelope: ContinuitySnapshotEnvelope, currentOwner: string): void {
184
+ const snapshotOwner = toChecksumAddress(envelope.ownerAddress)
185
+ const owner = toChecksumAddress(currentOwner)
186
+ if (snapshotOwner.toLowerCase() !== owner.toLowerCase()) {
187
+ throw new ContinuitySnapshotOwnerMismatchError(snapshotOwner, owner)
188
+ }
189
+ }
190
+
191
+ export function serializeContinuitySnapshotEnvelope(envelope: ContinuitySnapshotEnvelope): string {
192
+ return JSON.stringify(normalizeContinuitySnapshotEnvelope(envelope), null, 2)
193
+ }
194
+
195
+ export function parseContinuitySnapshotEnvelope(raw: string | Uint8Array): ContinuitySnapshotEnvelope {
196
+ const text = typeof raw === 'string' ? raw : new TextDecoder().decode(raw)
197
+ const parsed = JSON.parse(text) as unknown
198
+ return normalizeContinuitySnapshotEnvelope(parsed)
199
+ }
200
+
201
+ function normalizeContinuitySnapshotEnvelope(input: unknown): ContinuitySnapshotEnvelope {
202
+ if (!isContinuitySnapshotEnvelope(input)) throw new Error('invalid continuity snapshot envelope')
203
+ if (input.envelopeVersion !== CONTINUITY_SNAPSHOT_ENVELOPE_VERSION) {
204
+ throw new Error('unsupported continuity snapshot envelope version')
205
+ }
206
+ if (input.crypto.kem !== 'ML-KEM-1024' || input.crypto.aead !== 'AES-256-GCM') {
207
+ throw new Error('unsupported continuity snapshot crypto suite')
208
+ }
209
+ return {
210
+ ...input,
211
+ ownerAddress: toChecksumAddress(input.ownerAddress),
212
+ }
213
+ }
214
+
215
+ function isContinuitySnapshotEnvelope(input: unknown): input is ContinuitySnapshotEnvelope {
216
+ if (!input || typeof input !== 'object') return false
217
+ const obj = input as Partial<ContinuitySnapshotEnvelope> & { walletSignature?: unknown }
218
+ return obj.version === 1
219
+ && obj.envelopeVersion === CONTINUITY_SNAPSHOT_ENVELOPE_VERSION
220
+ && typeof obj.ownerAddress === 'string'
221
+ && typeof obj.createdAt === 'string'
222
+ && typeof obj.challenge === 'string'
223
+ && obj.walletSignature === undefined
224
+ && typeof obj.salt === 'string'
225
+ && typeof obj.kemPublicKey === 'string'
226
+ && typeof obj.kemCiphertext === 'string'
227
+ && typeof obj.nonce === 'string'
228
+ && typeof obj.ciphertext === 'string'
229
+ && typeof obj.tag === 'string'
230
+ && !!obj.crypto
231
+ }
232
+
233
+ function normalizeContinuityPayload(input: unknown): ContinuitySnapshotPayload {
234
+ if (!input || typeof input !== 'object') throw new Error('continuity snapshot payload is invalid')
235
+ const obj = input as Partial<ContinuitySnapshotPayload>
236
+ if (obj.version !== 1) throw new Error('continuity snapshot payload version is invalid')
237
+ if (typeof obj.ownerAddress !== 'string') throw new Error('continuity snapshot owner is invalid')
238
+ if (typeof obj.createdAt !== 'string') throw new Error('continuity snapshot timestamp is invalid')
239
+ return {
240
+ version: 1,
241
+ ownerAddress: toChecksumAddress(obj.ownerAddress),
242
+ createdAt: obj.createdAt,
243
+ ...(typeof obj.sequence === 'number' && Number.isSafeInteger(obj.sequence) ? { sequence: obj.sequence } : {}),
244
+ agent: normalizeAgentSnapshot(obj.agent),
245
+ files: normalizeContinuityFiles(obj.files),
246
+ transcript: normalizeTranscript(obj.transcript),
247
+ state: normalizeState(obj.state),
248
+ }
249
+ }
250
+
251
+ function normalizeAgentSnapshot(input: unknown): ContinuityAgentSnapshot {
252
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
253
+ const obj = input as Record<string, unknown>
254
+ return {
255
+ ...(typeof obj.chainId === 'number' && Number.isSafeInteger(obj.chainId) && obj.chainId > 0 ? { chainId: obj.chainId } : {}),
256
+ ...(typeof obj.identityRegistryAddress === 'string' ? { identityRegistryAddress: obj.identityRegistryAddress } : {}),
257
+ ...(typeof obj.agentId === 'string' ? { agentId: obj.agentId } : {}),
258
+ ...(typeof obj.agentUri === 'string' ? { agentUri: obj.agentUri } : {}),
259
+ ...(typeof obj.metadataCid === 'string' ? { metadataCid: obj.metadataCid } : {}),
260
+ ...(typeof obj.name === 'string' ? { name: obj.name } : {}),
261
+ ...(typeof obj.description === 'string' ? { description: obj.description } : {}),
262
+ }
263
+ }
264
+
265
+ function normalizeContinuityFiles(input: unknown): ContinuityFiles {
266
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
267
+ throw new Error('continuity snapshot files are invalid')
268
+ }
269
+ const obj = input as Partial<ContinuityFiles>
270
+ if (typeof obj['SOUL.md'] !== 'string') throw new Error('SOUL.md is missing from continuity snapshot')
271
+ if (typeof obj['MEMORY.md'] !== 'string') throw new Error('MEMORY.md is missing from continuity snapshot')
272
+ return {
273
+ 'SOUL.md': obj['SOUL.md'],
274
+ 'MEMORY.md': obj['MEMORY.md'],
275
+ }
276
+ }
277
+
278
+ function normalizeTranscript(input: unknown): ContinuityTranscriptSummary[] {
279
+ if (!Array.isArray(input)) return []
280
+ return input.flatMap(item => {
281
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return []
282
+ const obj = item as Partial<ContinuityTranscriptSummary>
283
+ if (typeof obj.summary !== 'string' || !obj.summary.trim()) return []
284
+ return [{
285
+ ...(typeof obj.sessionId === 'string' ? { sessionId: obj.sessionId } : {}),
286
+ ...(typeof obj.createdAt === 'string' ? { createdAt: obj.createdAt } : {}),
287
+ summary: obj.summary,
288
+ }]
289
+ })
290
+ }
291
+
292
+ function normalizeState(input: unknown): Record<string, unknown> {
293
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return {}
294
+ return input as Record<string, unknown>
295
+ }
296
+
297
+ function assertSignatureForAddress(challenge: string, signature: string, address: string): void {
298
+ const recovered = recoverAddressFromSignature(challenge, signature)
299
+ if (recovered.toLowerCase() !== address.toLowerCase()) {
300
+ throw new Error('wallet signature does not match continuity snapshot owner')
301
+ }
302
+ }
303
+
304
+ function deriveContinuityKemSeed(walletSignature: string, salt: Uint8Array, ownerAddress: string): Uint8Array {
305
+ return hkdf(
306
+ Buffer.from(walletSignature, 'utf8'),
307
+ salt,
308
+ `ethagent:${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}:ml-kem1024:${ownerAddress.toLowerCase()}`,
309
+ 64,
310
+ )
311
+ }
312
+
313
+ function deriveContinuityAesKey(
314
+ walletSignature: string,
315
+ sharedSecret: Uint8Array,
316
+ salt: Uint8Array,
317
+ ownerAddress: string,
318
+ ): Buffer {
319
+ return Buffer.from(hkdf(
320
+ Buffer.concat([
321
+ Buffer.from(walletSignature, 'utf8'),
322
+ Buffer.from('\n', 'utf8'),
323
+ Buffer.from(sharedSecret),
324
+ ]),
325
+ salt,
326
+ `ethagent:${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}:aes-256-gcm:${ownerAddress.toLowerCase()}`,
327
+ 32,
328
+ ))
329
+ }
330
+
331
+ function continuityAadFor(ownerAddress: string, createdAt: string): Buffer {
332
+ return Buffer.from(`${CONTINUITY_SNAPSHOT_ENVELOPE_VERSION}\n${ownerAddress.toLowerCase()}\n${createdAt}`, 'utf8')
333
+ }
334
+
335
+ function hkdf(ikm: Uint8Array, salt: Uint8Array, info: string, length: number): Uint8Array {
336
+ return new Uint8Array(crypto.hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), length))
337
+ }
338
+
339
+ function toBase64(bytes: Uint8Array): string {
340
+ return Buffer.from(bytes).toString('base64')
341
+ }
342
+
343
+ function fromBase64(value: string): Uint8Array {
344
+ return new Uint8Array(Buffer.from(value, 'base64'))
345
+ }