ethagent 1.0.9 → 1.1.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/README.md +18 -81
- package/package.json +1 -2
- package/src/chat/ChatScreen.tsx +5 -4
- package/src/chat/ContinuityEditReviewView.tsx +3 -3
- package/src/chat/chatTurnOrchestrator.ts +1 -1
- package/src/chat/commands.ts +5 -5
- package/src/cli/ResetConfirmView.tsx +12 -13
- package/src/cli/main.tsx +27 -30
- package/src/cli/reset.ts +7 -8
- package/src/cli/updateNotice.ts +52 -0
- package/src/identity/continuity/envelope.ts +11 -5
- package/src/identity/continuity/storage.ts +1 -1
- package/src/identity/hub/IdentityHub.tsx +6 -7
- package/src/identity/hub/identityHubModel.ts +12 -12
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +39 -34
- package/src/identity/hub/screens/CreateFlow.tsx +4 -4
- package/src/identity/hub/screens/DetailsScreen.tsx +2 -2
- package/src/identity/hub/screens/EditProfileFlow.tsx +5 -5
- package/src/identity/hub/screens/ErrorScreen.tsx +2 -2
- package/src/identity/hub/screens/IdentitySummary.tsx +32 -12
- package/src/identity/hub/screens/MenuScreen.tsx +17 -17
- package/src/identity/hub/screens/NetworkScreen.tsx +7 -3
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +9 -7
- package/src/identity/hub/screens/RestoreFlow.tsx +2 -2
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +5 -5
- package/src/identity/wallet/wallet-page/wallet.html +1202 -1082
- package/src/models/ModelPicker.tsx +71 -71
- package/src/models/llamacppPreflight.ts +1 -1
- package/src/models/modelPickerOptions.ts +22 -22
- package/src/storage/factoryReset.ts +17 -20
- package/src/tools/privateContinuityEditTool.ts +1 -1
- package/src/ui/BrandSplash.tsx +7 -4
package/README.md
CHANGED
|
@@ -3,118 +3,59 @@
|
|
|
3
3
|
|
|
4
4
|
A privacy-first AI agent with a portable Ethereum identity.
|
|
5
5
|
|
|
6
|
-
ethagent
|
|
7
|
-
|
|
8
|
-
The identity stays portable. The model can change. The private memory stays under wallet-gated encryption.
|
|
6
|
+
ethagent binds an AI agent to a wallet-owned ERC-8004 token. Persona and memory stay encrypted under your wallet signature and pinned to IPFS. Public skills publish as machine-readable JSON so other agents can discover what it does. Swap models, switch machines, restore the same agent from one onchain pointer.
|
|
9
7
|
|
|
10
8
|
## Install
|
|
11
9
|
|
|
12
10
|
ethagent requires Node.js 20 or newer. Install it from npm with `npm install -g ethagent`, then start it with `ethagent`.
|
|
13
11
|
|
|
14
|
-
On first run, ethagent guides you through model setup and identity setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
|
|
15
|
-
|
|
16
|
-
## What It Does
|
|
17
|
-
|
|
18
|
-
* Runs an AI coding agent in your terminal.
|
|
19
|
-
* Switches between cloud models and local GGUF models.
|
|
20
|
-
* Creates or loads a wallet-owned ERC-8004 agent identity.
|
|
21
|
-
* Encrypts private continuity before IPFS pinning.
|
|
22
|
-
* Publishes public `skills.json` and Agent Card metadata for discovery.
|
|
23
|
-
* Restores the same agent on another machine from the onchain record.
|
|
24
|
-
* Supports workspace tools, managed edit rewind, session resume, context compaction, and MCP servers.
|
|
25
|
-
|
|
26
12
|
## First Run
|
|
27
13
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
You can create a new ERC-8004 agent with a browser wallet, load an agent token you already own, or skip identity setup and add it later from the Identity Hub.
|
|
31
|
-
|
|
32
|
-
Use `Alt+P` to switch models and `Alt+I` to open the Identity Hub. Inside the agent, `/help` shows the live command list for the version you are running.
|
|
14
|
+
First run walks model setup, then identity setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
|
|
33
15
|
|
|
34
|
-
|
|
16
|
+
Create a new ERC-8004 agent with a browser wallet, load an agent token you already own, or skip identity setup and add it later from the Identity Hub.
|
|
35
17
|
|
|
36
|
-
|
|
18
|
+
Use `Alt+P` to swap models and `Alt+I` to open the Identity Hub. Inside the agent, `/help` shows the live command list for the version you are running.
|
|
37
19
|
|
|
38
|
-
|
|
39
|
-
| --- | --- |
|
|
40
|
-
| Public Metadata | Profile name, description, image, `skills.json`, and Agent Card. |
|
|
41
|
-
| Private Local Files | `SOUL.md`, `MEMORY.md`, and the local copy of `skills.json`. |
|
|
42
|
-
| Recovery | Publishing the current encrypted snapshot or refetching the latest one from chain. |
|
|
43
|
-
| Storage | The Pinata JWT used to pin continuity and metadata to IPFS. |
|
|
44
|
-
| Agent Token | Registry, owner, token, URI, CID, and copyable identity values. |
|
|
45
|
-
|
|
46
|
-
The hub is a recovery panel, not a history archive. The current tokenURI is the source of truth for the latest published state.
|
|
47
|
-
|
|
48
|
-
## Continuity
|
|
20
|
+
## Identity & Continuity
|
|
49
21
|
|
|
50
|
-
Each identity gets a local continuity vault under `~/.ethagent/continuity`.
|
|
22
|
+
The Identity Hub manages the portable identity. Each identity gets a local continuity vault under `~/.ethagent/continuity`.
|
|
51
23
|
|
|
52
24
|
| File | Visibility | Purpose |
|
|
53
25
|
| --- | --- | --- |
|
|
54
26
|
| `SOUL.md` | Private | Persona, boundaries, standing instructions, and identity framing. |
|
|
55
27
|
| `MEMORY.md` | Private | Durable preferences, project context, decisions, and operating notes. |
|
|
56
|
-
| `skills.json` | Public | Machine-readable capabilities
|
|
57
|
-
|
|
58
|
-
`SOUL.md` and `MEMORY.md` are encrypted before they are pinned to IPFS. They are not published as plaintext in token metadata.
|
|
59
|
-
|
|
60
|
-
`skills.json` is public by design. It uses schema `ethagent.public-skills.v1` and is meant to be easy for other agents, apps, and scanners to parse.
|
|
61
|
-
|
|
62
|
-
## Recovery
|
|
28
|
+
| `skills.json` | Public | Machine-readable capabilities under schema `ethagent.public-skills.v1`, easy for other agents and apps to parse. |
|
|
63
29
|
|
|
64
|
-
|
|
30
|
+
`SOUL.md` and `MEMORY.md` are encrypted before they reach IPFS. They are never published as plaintext in token metadata.
|
|
65
31
|
|
|
66
|
-
**Refetch Latest Snapshot** reads the current tokenURI from chain,
|
|
67
|
-
|
|
68
|
-
Publishing replaces the current onchain pointer. Registration metadata contains the current CIDs only.
|
|
69
|
-
|
|
70
|
-
## Public Discovery
|
|
71
|
-
|
|
72
|
-
The public registration metadata is intentionally compact. It describes the current state of the agent, not its history.
|
|
73
|
-
|
|
74
|
-
It can include:
|
|
75
|
-
|
|
76
|
-
* Agent name, description, and image.
|
|
77
|
-
* Current encrypted continuity CID.
|
|
78
|
-
* Current public `skills.json` CID.
|
|
79
|
-
* Current Agent Card CID.
|
|
80
|
-
* Service entries with canonical `endpoint` values.
|
|
81
|
-
* Registry linkage through `registrations[]`.
|
|
82
|
-
|
|
83
|
-
This is enough for baseline agent-to-agent discovery and delegation without exposing private memory or installing executable code.
|
|
32
|
+
**Save Snapshot Now** encrypts the current private continuity, pins the public discovery files, writes registration metadata, and updates the ERC-8004 tokenURI. **Refetch Latest Snapshot** reads the current tokenURI from chain, asks the owner wallet to sign the decrypt challenge, and restores local files from the published state. The current tokenURI is the source of truth.
|
|
84
33
|
|
|
85
34
|
## Models
|
|
86
35
|
|
|
87
36
|
ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served through a llama.cpp-compatible endpoint.
|
|
88
37
|
|
|
89
|
-
The model picker
|
|
38
|
+
The model picker discovers provider models, manages API keys, recommends GGUF files for your machine, and starts or attaches to a local runner. The featured local model is [Qwen3.5-9B-Uncensored](https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive); other Hugging Face GGUF models work by repo ID or URL.
|
|
90
39
|
|
|
91
|
-
|
|
40
|
+
Cloud API keys live in the OS keyring when available, with an encrypted local file under `~/.ethagent` as fallback.
|
|
92
41
|
|
|
93
|
-
|
|
42
|
+
## Tools & Sessions
|
|
94
43
|
|
|
95
|
-
|
|
44
|
+
File ops, shell, clipboard, and MCP tools, all permissioned. Managed edits support `/rewind`. Sessions support `/resume`, `/compact`, and `/export`.
|
|
96
45
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
Tool use is permissioned. Managed edits are tracked so recent workspace changes can be rewound from inside the agent.
|
|
100
|
-
|
|
101
|
-
Sessions are local. You can resume prior sessions, export a transcript, compact older context, and review saved project permissions.
|
|
46
|
+
`Shift+Tab` cycles Plan, Accept-Edits, and Chat modes. `Alt+T` toggles reasoning display.
|
|
102
47
|
|
|
103
48
|
## Privacy
|
|
104
49
|
|
|
105
50
|
Public information includes token ownership, tokenURI metadata, public discovery files, and IPFS CIDs.
|
|
106
51
|
|
|
107
|
-
Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for
|
|
52
|
+
Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decryption.
|
|
108
53
|
|
|
109
54
|
Continuity snapshots use an EIP-191 wallet signature as unlock material and encrypt with ML-KEM-1024, HKDF-SHA256, and AES-256-GCM. The unlock signature does not submit a transaction, spend funds, or grant token approval.
|
|
110
55
|
|
|
111
|
-
If an ERC-8004 token is transferred, the new holder can see public metadata and encrypted backup CIDs. They cannot decrypt private continuity that was
|
|
112
|
-
|
|
113
|
-
## Local Reset
|
|
56
|
+
If an ERC-8004 token is transferred, the new holder can see public metadata and encrypted backup CIDs. They cannot decrypt private continuity that was sealed for the previous owner wallet.
|
|
114
57
|
|
|
115
|
-
`ethagent reset` deletes local ethagent data from this machine while preserving installed local model assets. It does not burn or transfer ERC-8004 tokens, remove public IPFS content, or mutate onchain metadata.
|
|
116
|
-
|
|
117
|
-
Before resetting, use **Publish Snapshot Now** if local continuity changes should become the current recoverable state.
|
|
58
|
+
`ethagent reset` deletes local ethagent data from this machine while preserving installed local model assets. It does not burn or transfer ERC-8004 tokens, remove public IPFS content, or mutate onchain metadata. Run **Save Snapshot Now** before resetting if local changes should become the recoverable state.
|
|
118
59
|
|
|
119
60
|
## Architecture
|
|
120
61
|
|
|
@@ -129,10 +70,6 @@ Before resetting, use **Publish Snapshot Now** if local continuity changes shoul
|
|
|
129
70
|
|
|
130
71
|
The ERC-8004 token is the durable handle. The machine, model, and local session can change around it.
|
|
131
72
|
|
|
132
|
-
## Links
|
|
133
|
-
|
|
134
73
|
[npm](https://www.npmjs.com/package/ethagent) · [GitHub](https://github.com/baairon/ethagent) · [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) · [soul.md](https://soul.md/)
|
|
135
74
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
MIT
|
|
75
|
+
MIT License.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ethagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "A privacy-first AI agent with a portable Ethereum identity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/ethagent.js",
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
"ink": "^6.8.0",
|
|
48
48
|
"react": "^19.2.4",
|
|
49
49
|
"tsx": "^4.21.0",
|
|
50
|
-
"update-notifier": "^7.3.1",
|
|
51
50
|
"viem": "^2.48.4",
|
|
52
51
|
"zod": "^3.25.76"
|
|
53
52
|
},
|
package/src/chat/ChatScreen.tsx
CHANGED
|
@@ -79,6 +79,7 @@ import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager
|
|
|
79
79
|
type ChatScreenProps = {
|
|
80
80
|
config: EthagentConfig
|
|
81
81
|
onReplaceConfig?: (next: EthagentConfig) => void
|
|
82
|
+
updateNotice?: string | null
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
type PendingPlan = {
|
|
@@ -122,7 +123,7 @@ async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: t
|
|
|
122
123
|
return { ok: true }
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig }) => {
|
|
126
|
+
export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig, updateNotice }) => {
|
|
126
127
|
useRegisterKeybindingContext('Chat')
|
|
127
128
|
const { exit } = useApp()
|
|
128
129
|
const [config, setConfig] = useState<EthagentConfig>(initialConfig)
|
|
@@ -1109,7 +1110,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1109
1110
|
}
|
|
1110
1111
|
overlayRef.current = 'none'
|
|
1111
1112
|
setOverlay('none')
|
|
1112
|
-
pushNote('snapshot not
|
|
1113
|
+
pushNote('snapshot not saved yet.', 'dim')
|
|
1113
1114
|
},
|
|
1114
1115
|
[continuityEditReview, pushNote],
|
|
1115
1116
|
)
|
|
@@ -1118,7 +1119,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1118
1119
|
setContinuityEditReview(null)
|
|
1119
1120
|
overlayRef.current = 'none'
|
|
1120
1121
|
setOverlay('none')
|
|
1121
|
-
pushNote('snapshot not
|
|
1122
|
+
pushNote('snapshot not saved yet.', 'dim')
|
|
1122
1123
|
}, [pushNote])
|
|
1123
1124
|
|
|
1124
1125
|
const handleCopyDone = useCallback(
|
|
@@ -1402,7 +1403,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1402
1403
|
</Text>
|
|
1403
1404
|
</Box>
|
|
1404
1405
|
)
|
|
1405
|
-
const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} />
|
|
1406
|
+
const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
|
|
1406
1407
|
return (
|
|
1407
1408
|
<ConversationStack
|
|
1408
1409
|
header={header}
|
|
@@ -19,7 +19,7 @@ export const ContinuityEditReviewView: React.FC<{
|
|
|
19
19
|
}> = ({ review, onSelect, onCancel }) => (
|
|
20
20
|
<Surface
|
|
21
21
|
title="Private Continuity Updated"
|
|
22
|
-
subtitle="Review the file, then
|
|
22
|
+
subtitle="Review the file, then save an encrypted snapshot."
|
|
23
23
|
footer="enter select · esc later"
|
|
24
24
|
>
|
|
25
25
|
<Box flexDirection="column">
|
|
@@ -37,8 +37,8 @@ export const ContinuityEditReviewView: React.FC<{
|
|
|
37
37
|
<Select<ContinuityEditReviewAction>
|
|
38
38
|
options={[
|
|
39
39
|
{ value: 'open', label: `open ${review.file}`, hint: 'review the edited private file now' },
|
|
40
|
-
{ value: 'save-publish', label: '
|
|
41
|
-
{ value: 'later', label: 'later', hint: 'keep the local draft
|
|
40
|
+
{ value: 'save-publish', label: 'save snapshot now', hint: 'go directly to wallet approval' },
|
|
41
|
+
{ value: 'later', label: 'later', hint: 'keep the local draft unsaved' },
|
|
42
42
|
]}
|
|
43
43
|
onSubmit={onSelect}
|
|
44
44
|
onCancel={onCancel}
|
|
@@ -665,7 +665,7 @@ export async function buildIdentityContinuityContextMessages(
|
|
|
665
665
|
content: [
|
|
666
666
|
'<identity_continuity_files>',
|
|
667
667
|
`Automatic identity continuity load failed: ${(err as Error).message}`,
|
|
668
|
-
'If the user asks about continuity, surface this failure and route them to the
|
|
668
|
+
'If the user asks about continuity, surface this failure and route them to the Identity Hub.',
|
|
669
669
|
'</identity_continuity_files>',
|
|
670
670
|
].join('\n'),
|
|
671
671
|
}]
|
package/src/chat/commands.ts
CHANGED
|
@@ -154,7 +154,7 @@ const COMMANDS: CommandSpec[] = [
|
|
|
154
154
|
if (installed.length === 0) {
|
|
155
155
|
return {
|
|
156
156
|
kind: 'note',
|
|
157
|
-
text: '
|
|
157
|
+
text: 'No local model files downloaded. Open Alt+P and choose "Add Local Model File".',
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
const lines = installed.map(m => {
|
|
@@ -190,7 +190,7 @@ const COMMANDS: CommandSpec[] = [
|
|
|
190
190
|
return {
|
|
191
191
|
kind: 'note',
|
|
192
192
|
variant: 'error',
|
|
193
|
-
text: `'${name}' is not downloaded.
|
|
193
|
+
text: `'${name}' is not downloaded. Open Alt+P and choose "View Full Catalog" or "Add Local Model File".`,
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
} else {
|
|
@@ -364,7 +364,7 @@ async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashRes
|
|
|
364
364
|
return {
|
|
365
365
|
kind: 'note',
|
|
366
366
|
variant: 'dim',
|
|
367
|
-
text: '
|
|
367
|
+
text: 'No local model files downloaded. Press Alt+P and choose "Add Local Model File".',
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
const lines = installed.map(model => {
|
|
@@ -382,8 +382,8 @@ async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashRes
|
|
|
382
382
|
kind: 'note',
|
|
383
383
|
variant: 'dim',
|
|
384
384
|
text: link
|
|
385
|
-
? `
|
|
386
|
-
: '
|
|
385
|
+
? `Alt+P opened. Choose "Add Local Model File" and paste: ${link}`
|
|
386
|
+
: 'Alt+P opened. Choose "Add Local Model File" and paste the model URL or repo ID.',
|
|
387
387
|
}
|
|
388
388
|
}
|
|
389
389
|
|
|
@@ -16,27 +16,26 @@ export const ResetConfirmView: React.FC<{
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<Surface title="
|
|
19
|
+
<Surface title="Reset Local Data?" subtitle="Deletes this machine's ethagent data. Models and onchain records stay." footer="enter select · esc cancel">
|
|
20
20
|
<Box flexDirection="column">
|
|
21
|
-
<Section title="
|
|
21
|
+
<Section title="Deletes" lines={[
|
|
22
|
+
'Identity files, sessions, history, credentials',
|
|
22
23
|
localDataLine(plan.deletePaths.length),
|
|
23
|
-
'identity metadata, markdown vaults, sessions, prompt history',
|
|
24
|
-
'rewind history, permissions, credentials',
|
|
25
24
|
]} />
|
|
26
|
-
<Section title="
|
|
27
|
-
'
|
|
28
|
-
...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['
|
|
25
|
+
<Section title="Keeps" lines={[
|
|
26
|
+
'Local GGUF models and llama.cpp runners',
|
|
27
|
+
...(plan.preservedPaths.length > 0 ? [`${plan.preservedPaths.length} local model path${plan.preservedPaths.length === 1 ? '' : 's'}`] : ['No local model assets found']),
|
|
29
28
|
]} />
|
|
30
|
-
<Section title="
|
|
31
|
-
'onchain
|
|
32
|
-
'IPFS
|
|
29
|
+
<Section title="Not Touched" lines={[
|
|
30
|
+
'ERC-8004 tokens and onchain records',
|
|
31
|
+
'IPFS snapshots and public metadata',
|
|
33
32
|
]} />
|
|
34
33
|
</Box>
|
|
35
34
|
<Box marginTop={1}>
|
|
36
35
|
<Select<'confirm' | 'cancel'>
|
|
37
36
|
options={[
|
|
38
|
-
{ value: 'confirm', label: '
|
|
39
|
-
{ value: 'cancel', label: '
|
|
37
|
+
{ value: 'confirm', label: 'Reset Local Data', hint: 'Delete local ethagent data now' },
|
|
38
|
+
{ value: 'cancel', label: 'Cancel', hint: 'Leave local data unchanged' },
|
|
40
39
|
]}
|
|
41
40
|
onSubmit={choice => finish(choice === 'confirm')}
|
|
42
41
|
onCancel={() => finish(false)}
|
|
@@ -56,6 +55,6 @@ const Section: React.FC<{ title: string; lines: string[] }> = ({ title, lines })
|
|
|
56
55
|
)
|
|
57
56
|
|
|
58
57
|
function localDataLine(count: number): string {
|
|
59
|
-
if (count === 0) return '
|
|
58
|
+
if (count === 0) return 'No local ethagent data found'
|
|
60
59
|
return `${count} local path${count === 1 ? '' : 's'} under ~/.ethagent`
|
|
61
60
|
}
|
package/src/cli/main.tsx
CHANGED
|
@@ -12,29 +12,10 @@ import { AppInputProvider, useAppInput } from '../app/input/AppInputProvider.js'
|
|
|
12
12
|
import { loadConfig, type EthagentConfig } from '../storage/config.js'
|
|
13
13
|
import { runResetCommand } from './reset.js'
|
|
14
14
|
import { runPreviewCommand } from './preview.js'
|
|
15
|
+
import { checkForUpdates } from './updateNotice.js'
|
|
16
|
+
import { Spinner } from '../ui/Spinner.js'
|
|
15
17
|
|
|
16
18
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
-
const pkgPath = path.resolve(__dirname, '..', '..', 'package.json')
|
|
18
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
19
|
-
|
|
20
|
-
async function checkForUpdates() {
|
|
21
|
-
try {
|
|
22
|
-
const res = await fetch('https://registry.npmjs.org/ethagent/latest', { signal: AbortSignal.timeout(1200) })
|
|
23
|
-
const { version: latest } = await res.json() as { version: string }
|
|
24
|
-
if (latest && latest !== pkg.version) {
|
|
25
|
-
const line1 = ` Update available: ${theme.accentPrimary}${pkg.version}${theme.text} -> ${theme.accentMint}${latest}${theme.text} `
|
|
26
|
-
const line2 = ` Run ${theme.accentPeach}npm install -g ethagent${theme.text} to update `
|
|
27
|
-
const border = theme.dim + '─'.repeat(Math.max(line1.length - 20, line2.length - 20) + 10) + theme.text
|
|
28
|
-
|
|
29
|
-
process.stdout.write(`\n${theme.dim}┌${border}┐${theme.text}\n`)
|
|
30
|
-
process.stdout.write(`${theme.dim}│${theme.text} ${line1} ${theme.dim}│${theme.text}\n`)
|
|
31
|
-
process.stdout.write(`${theme.dim}│${theme.text} ${line2} ${theme.dim}│${theme.text}\n`)
|
|
32
|
-
process.stdout.write(`${theme.dim}└${border}┘${theme.text}\n\n`)
|
|
33
|
-
}
|
|
34
|
-
} catch {
|
|
35
|
-
// Silent fail for offline/timeout
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
19
|
|
|
39
20
|
function readVersion(): string {
|
|
40
21
|
try {
|
|
@@ -70,17 +51,24 @@ type AppPhase =
|
|
|
70
51
|
| { kind: 'cancelled' }
|
|
71
52
|
| { kind: 'error'; message: string }
|
|
72
53
|
|
|
73
|
-
const
|
|
54
|
+
const MIN_STARTUP_SPINNER_MS = 480
|
|
55
|
+
|
|
56
|
+
function delay(ms: number): Promise<void> {
|
|
57
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const AppRoot: React.FC<{ setExitCode: (code: number) => void; currentVersion: string }> = ({ setExitCode, currentVersion }) => {
|
|
74
61
|
const [phase, setPhase] = useState<AppPhase>({ kind: 'loading' })
|
|
62
|
+
const [updateNotice, setUpdateNotice] = useState<string | null>(null)
|
|
75
63
|
const { exit } = useApp()
|
|
76
64
|
|
|
77
65
|
useEffect(() => {
|
|
78
66
|
if (phase.kind !== 'loading') return
|
|
79
67
|
let cancelled = false
|
|
80
|
-
loadConfig()
|
|
68
|
+
Promise.all([loadConfig(), delay(MIN_STARTUP_SPINNER_MS)])
|
|
81
69
|
.then(config => {
|
|
82
70
|
if (cancelled) return
|
|
83
|
-
setPhase(config ? { kind: 'ready', config } : { kind: 'setup' })
|
|
71
|
+
setPhase(config[0] ? { kind: 'ready', config: config[0] } : { kind: 'setup' })
|
|
84
72
|
})
|
|
85
73
|
.catch((err: unknown) => {
|
|
86
74
|
if (cancelled) return
|
|
@@ -89,6 +77,16 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
|
|
|
89
77
|
return () => { cancelled = true }
|
|
90
78
|
}, [phase])
|
|
91
79
|
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
let cancelled = false
|
|
82
|
+
void checkForUpdates(currentVersion)
|
|
83
|
+
.then(notice => {
|
|
84
|
+
if (!cancelled) setUpdateNotice(notice)
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {})
|
|
87
|
+
return () => { cancelled = true }
|
|
88
|
+
}, [currentVersion])
|
|
89
|
+
|
|
92
90
|
useEffect(() => {
|
|
93
91
|
if (phase.kind === 'cancelled') {
|
|
94
92
|
setExitCode(1)
|
|
@@ -111,7 +109,7 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
|
|
|
111
109
|
if (phase.kind === 'loading') {
|
|
112
110
|
return (
|
|
113
111
|
<Box padding={1}>
|
|
114
|
-
<
|
|
112
|
+
<Spinner label="Starting ethagent..." showElapsed={false} />
|
|
115
113
|
</Box>
|
|
116
114
|
)
|
|
117
115
|
}
|
|
@@ -141,16 +139,17 @@ const AppRoot: React.FC<{ setExitCode: (code: number) => void }> = ({ setExitCod
|
|
|
141
139
|
<ChatScreen
|
|
142
140
|
config={phase.config}
|
|
143
141
|
onReplaceConfig={next => setPhase({ kind: 'ready', config: next })}
|
|
142
|
+
updateNotice={updateNotice}
|
|
144
143
|
/>
|
|
145
144
|
)
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
async function runDefault(): Promise<number> {
|
|
147
|
+
async function runDefault(currentVersion: string): Promise<number> {
|
|
149
148
|
let exitCode = 0
|
|
150
149
|
const instance = render(
|
|
151
150
|
<AppInputProvider>
|
|
152
151
|
<KeybindingProvider>
|
|
153
|
-
<AppRoot setExitCode={code => { exitCode = code }} />
|
|
152
|
+
<AppRoot setExitCode={code => { exitCode = code }} currentVersion={currentVersion} />
|
|
154
153
|
</KeybindingProvider>
|
|
155
154
|
</AppInputProvider>,
|
|
156
155
|
{
|
|
@@ -169,9 +168,7 @@ async function main(): Promise<number> {
|
|
|
169
168
|
const argv = process.argv.slice(2)
|
|
170
169
|
const [cmd, ...rest] = argv
|
|
171
170
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (!cmd) return runDefault()
|
|
171
|
+
if (!cmd) return runDefault(readVersion())
|
|
175
172
|
if (cmd === '--version' || cmd === '-v') {
|
|
176
173
|
process.stdout.write(`ethagent ${readVersion()}\n`)
|
|
177
174
|
return 0
|
package/src/cli/reset.ts
CHANGED
|
@@ -37,7 +37,7 @@ export async function runResetCommand(args: string[] = [], io: ResetCommandIO =
|
|
|
37
37
|
? await readTextConfirmation(plan, io)
|
|
38
38
|
: await readInkConfirmation(plan, io)
|
|
39
39
|
if (!confirmed) {
|
|
40
|
-
write('
|
|
40
|
+
write('Reset Cancelled.\n')
|
|
41
41
|
return 1
|
|
42
42
|
}
|
|
43
43
|
} else {
|
|
@@ -46,12 +46,12 @@ export async function runResetCommand(args: string[] = [], io: ResetCommandIO =
|
|
|
46
46
|
|
|
47
47
|
const result = await runFactoryReset({ clearSecrets: io.clearSecrets })
|
|
48
48
|
write([
|
|
49
|
-
'
|
|
50
|
-
`
|
|
51
|
-
`
|
|
49
|
+
'Reset Complete.',
|
|
50
|
+
`Deleted ${result.deletedPaths.length} local path${result.deletedPaths.length === 1 ? '' : 's'}.`,
|
|
51
|
+
`Cleared ${result.clearedSecretAccounts.length} secret account${result.clearedSecretAccounts.length === 1 ? '' : 's'}.`,
|
|
52
52
|
result.preservedPaths.length > 0
|
|
53
|
-
? `
|
|
54
|
-
: 'no local model assets
|
|
53
|
+
? `Kept ${result.preservedPaths.length} local model path${result.preservedPaths.length === 1 ? '' : 's'}.`
|
|
54
|
+
: 'Kept no local model assets.',
|
|
55
55
|
'',
|
|
56
56
|
].join('\n'))
|
|
57
57
|
return 0
|
|
@@ -90,7 +90,6 @@ async function readInkConfirmation(plan: FactoryResetPlan, io: ResetCommandIO):
|
|
|
90
90
|
|
|
91
91
|
async function readConfirmation(io: ResetCommandIO): Promise<string> {
|
|
92
92
|
if (io.readConfirmation) {
|
|
93
|
-
;(io.write ?? (text => { processStdout.write(text) }))('type confirm to wipe local ethagent data: ')
|
|
94
93
|
return io.readConfirmation()
|
|
95
94
|
}
|
|
96
95
|
|
|
@@ -99,7 +98,7 @@ async function readConfirmation(io: ResetCommandIO): Promise<string> {
|
|
|
99
98
|
output: io.output ?? processStdout,
|
|
100
99
|
})
|
|
101
100
|
try {
|
|
102
|
-
return await rl.question('
|
|
101
|
+
return await rl.question('Confirm reset: ')
|
|
103
102
|
} finally {
|
|
104
103
|
rl.close()
|
|
105
104
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
type RegistryResponse = {
|
|
2
|
+
ok?: boolean
|
|
3
|
+
json: () => Promise<unknown>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type RegistryFetch = (url: string, init: { signal: AbortSignal }) => Promise<RegistryResponse>
|
|
7
|
+
|
|
8
|
+
const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/ethagent/latest'
|
|
9
|
+
|
|
10
|
+
export function compareVersions(left: string, right: string): number {
|
|
11
|
+
const a = parseVersion(left)
|
|
12
|
+
const b = parseVersion(right)
|
|
13
|
+
if (!a || !b) return 0
|
|
14
|
+
for (let i = 0; i < 3; i++) {
|
|
15
|
+
if (a[i] > b[i]) return 1
|
|
16
|
+
if (a[i] < b[i]) return -1
|
|
17
|
+
}
|
|
18
|
+
return 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isNewerVersion(candidate: string, current: string): boolean {
|
|
22
|
+
return compareVersions(candidate, current) > 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatUpdateNotice(currentVersion: string, latestVersion: string): string | null {
|
|
26
|
+
if (!isNewerVersion(latestVersion, currentVersion)) return null
|
|
27
|
+
return `update available: ethagent ${currentVersion} -> ${latestVersion}; run npm i -g ethagent`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function checkForUpdates(
|
|
31
|
+
currentVersion: string,
|
|
32
|
+
options: { fetchImpl?: RegistryFetch; timeoutMs?: number } = {},
|
|
33
|
+
): Promise<string | null> {
|
|
34
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
35
|
+
const timeoutMs = options.timeoutMs ?? 1200
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetchImpl(REGISTRY_LATEST_URL, { signal: AbortSignal.timeout(timeoutMs) })
|
|
38
|
+
if (res.ok === false) return null
|
|
39
|
+
const body = await res.json()
|
|
40
|
+
if (!body || typeof body !== 'object') return null
|
|
41
|
+
const latest = (body as { version?: unknown }).version
|
|
42
|
+
return typeof latest === 'string' ? formatUpdateNotice(currentVersion, latest) : null
|
|
43
|
+
} catch {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseVersion(value: string): [number, number, number] | null {
|
|
49
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value.trim())
|
|
50
|
+
if (!match) return null
|
|
51
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
52
|
+
}
|
|
@@ -79,15 +79,21 @@ export class ContinuitySnapshotOwnerMismatchError extends Error {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export const CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES = [
|
|
83
|
+
'ethagent: save or restore identity files',
|
|
84
|
+
'Action: encrypt or decrypt local identity files',
|
|
85
|
+
'Private: SOUL.md, MEMORY.md',
|
|
86
|
+
'Public: skills.json, public profile',
|
|
87
|
+
'Safety: no transaction, spending, or approvals',
|
|
88
|
+
'Version: 1',
|
|
89
|
+
] as const
|
|
90
|
+
|
|
82
91
|
export function createContinuitySnapshotChallenge(ownerAddress: string): string {
|
|
83
92
|
const checksum = toChecksumAddress(ownerAddress)
|
|
84
93
|
return [
|
|
85
|
-
|
|
94
|
+
CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES[0],
|
|
86
95
|
`Owner: ${checksum}`,
|
|
87
|
-
|
|
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',
|
|
96
|
+
...CONTINUITY_SNAPSHOT_CHALLENGE_MESSAGES.slice(1),
|
|
91
97
|
].join('\n')
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -287,7 +287,7 @@ export function defaultContinuityFiles(identity: EthagentIdentity, now = new Dat
|
|
|
287
287
|
'## Private Instructions',
|
|
288
288
|
'',
|
|
289
289
|
'- Keep owner-specific standing instructions in this file.',
|
|
290
|
-
'- Do not
|
|
290
|
+
'- Do not share this file directly; save it via the Identity Hub encrypted snapshot.',
|
|
291
291
|
'- Public capabilities belong in skills.json.',
|
|
292
292
|
'',
|
|
293
293
|
'## Boundaries',
|
|
@@ -196,7 +196,8 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
196
196
|
|
|
197
197
|
useEffect(() => {
|
|
198
198
|
let cancelled = false
|
|
199
|
-
if (!identity
|
|
199
|
+
if (!identity) return
|
|
200
|
+
if (step.kind !== 'menu' && step.kind !== 'continuity-private' && step.kind !== 'continuity-public') return
|
|
200
201
|
|
|
201
202
|
const checkStatus = async () => {
|
|
202
203
|
try {
|
|
@@ -591,7 +592,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
591
592
|
return (
|
|
592
593
|
<WalletApprovalScreen
|
|
593
594
|
title="Refetch Latest Snapshot"
|
|
594
|
-
subtitle="Wallet approval decrypts the latest
|
|
595
|
+
subtitle="Wallet approval decrypts the latest saved snapshot and overwrites local SOUL.md, MEMORY.md, and skills.json."
|
|
595
596
|
walletSession={walletSession}
|
|
596
597
|
label={restoreProgress?.label ?? 'fetching latest snapshot from chain...'}
|
|
597
598
|
onCancel={() => setStep(step.back)}
|
|
@@ -604,13 +605,12 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
604
605
|
<PrivateContinuityScreen
|
|
605
606
|
identity={identity}
|
|
606
607
|
config={config}
|
|
608
|
+
workingStatus={workingStatus}
|
|
607
609
|
ready={continuityReady}
|
|
608
610
|
notice={step.notice}
|
|
609
|
-
canBackup={canRebackup}
|
|
610
611
|
footer={footer}
|
|
611
612
|
onOpenSoul={() => { void openContinuityFile('soul') }}
|
|
612
613
|
onOpenMemory={() => { void openContinuityFile('memory') }}
|
|
613
|
-
onBackup={() => setStep({ kind: 'rebackup-confirm', back: { kind: 'continuity-private' } })}
|
|
614
614
|
onBack={back}
|
|
615
615
|
/>
|
|
616
616
|
)
|
|
@@ -621,13 +621,12 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
621
621
|
<PublicSkillsScreen
|
|
622
622
|
identity={identity}
|
|
623
623
|
config={config}
|
|
624
|
+
workingStatus={workingStatus}
|
|
624
625
|
ready={continuityReady}
|
|
625
626
|
notice={step.notice}
|
|
626
|
-
canPublish={canRebackup}
|
|
627
627
|
footer={footer}
|
|
628
628
|
onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
|
|
629
629
|
onOpenSkills={() => { void openContinuityFile('skills') }}
|
|
630
|
-
onPublish={() => setStep({ kind: 'rebackup-confirm', back: { kind: 'continuity-public' } })}
|
|
631
630
|
onBack={back}
|
|
632
631
|
/>
|
|
633
632
|
)
|
|
@@ -784,7 +783,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
784
783
|
|
|
785
784
|
async function readPublishedPublicSkills(identity: EthagentIdentity): Promise<string> {
|
|
786
785
|
const cid = identity.publicSkills?.cid
|
|
787
|
-
if (!cid) throw new Error('no
|
|
786
|
+
if (!cid) throw new Error('no saved public skills CID')
|
|
788
787
|
return new TextDecoder().decode(await catFromIpfs(
|
|
789
788
|
identity.backup?.ipfsApiUrl ?? DEFAULT_IPFS_API_URL,
|
|
790
789
|
cid,
|