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,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
|
+
}
|
package/src/cli/reset.ts
ADDED
|
@@ -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
|
+
}
|