ethagent 1.1.0 → 1.1.2
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 +19 -80
- package/package.json +10 -8
- package/src/app/FirstRun.tsx +2 -0
- package/src/chat/ChatScreen.tsx +8 -3
- package/src/chat/ContinuityEditReviewView.tsx +6 -0
- package/src/chat/ConversationStack.tsx +3 -0
- package/src/chat/TranscriptView.tsx +6 -0
- package/src/chat/chatTurnOrchestrator.ts +1 -1
- package/src/cli/updateNotice.ts +1 -1
- package/src/identity/hub/IdentityHub.tsx +4 -2
- package/src/identity/hub/identityHubModel.ts +80 -0
- package/src/identity/hub/identityHubReducer.ts +2 -2
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +14 -2
- package/src/identity/hub/screens/EditProfileFlow.tsx +1 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +1 -1
- package/src/identity/hub/screens/IdentitySummary.tsx +36 -20
- package/src/identity/hub/screens/MenuScreen.tsx +2 -2
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +24 -16
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +3 -0
- package/src/identity/wallet/wallet-page/wallet.html +370 -379
- package/src/ui/Select.tsx +1 -1
- package/src/ui/Surface.tsx +1 -1
- package/src/ui/TextInput.tsx +142 -23
package/README.md
CHANGED
|
@@ -3,118 +3,61 @@
|
|
|
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 identity setup, then model 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,
|
|
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.
|
|
67
33
|
|
|
68
|
-
|
|
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.
|
|
34
|
+
Agents can also be looked up by token ID on [8004scan](https://8004scan.io/).
|
|
84
35
|
|
|
85
36
|
## Models
|
|
86
37
|
|
|
87
38
|
ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served through a llama.cpp-compatible endpoint.
|
|
88
39
|
|
|
89
|
-
The model picker
|
|
90
|
-
|
|
91
|
-
The featured local model is [Qwen3.5-9B-Uncensored](https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive). You can also add other Hugging Face GGUF models by repo ID or URL.
|
|
40
|
+
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.
|
|
92
41
|
|
|
93
|
-
Cloud API keys
|
|
42
|
+
Cloud API keys live in the OS keyring when available, with an encrypted local file under `~/.ethagent` as fallback.
|
|
94
43
|
|
|
95
|
-
## Tools
|
|
44
|
+
## Tools & Sessions
|
|
96
45
|
|
|
97
|
-
|
|
46
|
+
File ops, shell, clipboard, and MCP tools, all permissioned. Managed edits support `/rewind`. Sessions support `/resume`, `/compact`, and `/export`.
|
|
98
47
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Sessions are local. You can resume prior sessions, export a transcript, compact older context, and review saved project permissions.
|
|
48
|
+
`Shift+Tab` cycles Plan, Accept-Edits, and Chat modes. `Alt+T` toggles reasoning display.
|
|
102
49
|
|
|
103
50
|
## Privacy
|
|
104
51
|
|
|
105
52
|
Public information includes token ownership, tokenURI metadata, public discovery files, and IPFS CIDs.
|
|
106
53
|
|
|
107
|
-
Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for
|
|
54
|
+
Private information includes plaintext `SOUL.md`, plaintext `MEMORY.md`, sessions, prompt history, API keys, local permissions, and wallet signatures used for decryption.
|
|
108
55
|
|
|
109
56
|
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
57
|
|
|
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
|
|
114
|
-
|
|
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.
|
|
58
|
+
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.
|
|
116
59
|
|
|
117
|
-
|
|
60
|
+
`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
61
|
|
|
119
62
|
## Architecture
|
|
120
63
|
|
|
@@ -129,10 +72,6 @@ Before resetting, use **Publish Snapshot Now** if local continuity changes shoul
|
|
|
129
72
|
|
|
130
73
|
The ERC-8004 token is the durable handle. The machine, model, and local session can change around it.
|
|
131
74
|
|
|
132
|
-
## Links
|
|
133
|
-
|
|
134
75
|
[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
76
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
MIT
|
|
77
|
+
MIT License.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ethagent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "A privacy-first AI agent with a portable Ethereum identity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/ethagent.js",
|
|
@@ -23,14 +23,16 @@
|
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"ethereum",
|
|
26
|
-
"
|
|
27
|
-
"ai",
|
|
28
|
-
"
|
|
26
|
+
"erc-8004",
|
|
27
|
+
"ai-agent",
|
|
28
|
+
"cli",
|
|
29
|
+
"privacy",
|
|
29
30
|
"ipfs",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
31
|
+
"wallet",
|
|
32
|
+
"llm",
|
|
33
|
+
"gguf",
|
|
34
|
+
"mcp",
|
|
35
|
+
"identity"
|
|
34
36
|
],
|
|
35
37
|
"repository": {
|
|
36
38
|
"type": "git",
|
package/src/app/FirstRun.tsx
CHANGED
|
@@ -289,6 +289,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
|
|
|
289
289
|
<TextInput
|
|
290
290
|
isSecret
|
|
291
291
|
placeholder={provider === 'openai' ? 'sk-...' : 'paste key and press enter'}
|
|
292
|
+
chromeWidth={4}
|
|
292
293
|
validate={v => v.trim().length >= 8 ? null : 'key looks too short'}
|
|
293
294
|
onSubmit={async value => {
|
|
294
295
|
const trimmed = value.trim()
|
|
@@ -326,6 +327,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
|
|
|
326
327
|
<TextInput
|
|
327
328
|
initialValue={defaultModel}
|
|
328
329
|
placeholder={defaultModel}
|
|
330
|
+
chromeWidth={4}
|
|
329
331
|
onSubmit={model => goTo({
|
|
330
332
|
kind: 'saving',
|
|
331
333
|
config: withFirstRunIdentity({
|
package/src/chat/ChatScreen.tsx
CHANGED
|
@@ -143,6 +143,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
143
143
|
const [mode, setMode] = useState<SessionMode>('chat')
|
|
144
144
|
const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
|
|
145
145
|
const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
|
|
146
|
+
const [canScrollTranscript, setCanScrollTranscript] = useState(false)
|
|
146
147
|
const [sessionId, setSessionId] = useState<string>(() => newSessionId())
|
|
147
148
|
const [sessionKey, setSessionKey] = useState<number>(0)
|
|
148
149
|
const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
|
|
@@ -1094,6 +1095,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1094
1095
|
: `open failed: ${result.error}`,
|
|
1095
1096
|
result.ok ? 'dim' : 'error',
|
|
1096
1097
|
)
|
|
1098
|
+
if (result.ok) setContinuityEditReview(prev => prev ? { ...prev, editorOpened: true } : null)
|
|
1097
1099
|
return
|
|
1098
1100
|
}
|
|
1099
1101
|
setContinuityEditReview(null)
|
|
@@ -1398,9 +1400,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1398
1400
|
<Text color={theme.dim}> · </Text>
|
|
1399
1401
|
</>
|
|
1400
1402
|
)}
|
|
1401
|
-
<Text color={theme.dim}>
|
|
1402
|
-
{'pgup/pgdn scroll · alt+p model · alt+i identity'}
|
|
1403
|
-
</Text>
|
|
1403
|
+
<Text color={theme.dim}>{chatFooterShortcutText(canScrollTranscript)}</Text>
|
|
1404
1404
|
</Box>
|
|
1405
1405
|
)
|
|
1406
1406
|
const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
|
|
@@ -1466,10 +1466,15 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1466
1466
|
)}
|
|
1467
1467
|
sessionKey={sessionKey}
|
|
1468
1468
|
onVisibleReasoningIdsChange={updateVisibleReasoningIds}
|
|
1469
|
+
onTranscriptScrollabilityChange={setCanScrollTranscript}
|
|
1469
1470
|
/>
|
|
1470
1471
|
)
|
|
1471
1472
|
}
|
|
1472
1473
|
|
|
1474
|
+
export function chatFooterShortcutText(canScrollTranscript: boolean): string {
|
|
1475
|
+
return `${canScrollTranscript ? 'pgup/pgdn scroll · ' : ''}alt+p model · alt+i identity`
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1473
1478
|
function formatContextLabel(usage: ContextUsage): string {
|
|
1474
1479
|
if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
|
|
1475
1480
|
return `Estimated context: ${usage.percent}% used`
|
|
@@ -8,6 +8,7 @@ export type ContinuityEditReviewState = {
|
|
|
8
8
|
file: 'SOUL.md' | 'MEMORY.md'
|
|
9
9
|
filePath: string
|
|
10
10
|
summary: string
|
|
11
|
+
editorOpened?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export type ContinuityEditReviewAction = 'open' | 'save-publish' | 'later'
|
|
@@ -28,6 +29,11 @@ export const ContinuityEditReviewView: React.FC<{
|
|
|
28
29
|
<Text color={theme.textSubtle}>review file</Text>
|
|
29
30
|
<Text color={theme.text}>{review.filePath}</Text>
|
|
30
31
|
</Box>
|
|
32
|
+
{review.editorOpened && (
|
|
33
|
+
<Box marginTop={1}>
|
|
34
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
)}
|
|
31
37
|
<Box marginTop={1} flexDirection="column">
|
|
32
38
|
<Text color={theme.textSubtle}>saved locally</Text>
|
|
33
39
|
<Text color={theme.dim}>Previous version saved in identity history. /rewind does not restore identity continuity.</Text>
|
|
@@ -12,6 +12,7 @@ type ConversationStackProps = {
|
|
|
12
12
|
status?: React.ReactNode
|
|
13
13
|
sessionKey: number
|
|
14
14
|
onVisibleReasoningIdsChange?: (ids: string[]) => void
|
|
15
|
+
onTranscriptScrollabilityChange?: (canScroll: boolean) => void
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
@@ -23,6 +24,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
|
23
24
|
status,
|
|
24
25
|
sessionKey,
|
|
25
26
|
onVisibleReasoningIdsChange,
|
|
27
|
+
onTranscriptScrollabilityChange,
|
|
26
28
|
}) => {
|
|
27
29
|
return (
|
|
28
30
|
<Box flexDirection="column" padding={1}>
|
|
@@ -33,6 +35,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
|
33
35
|
active={transcriptActive}
|
|
34
36
|
bottomVariant={bottomVariant}
|
|
35
37
|
onVisibleReasoningIdsChange={onVisibleReasoningIdsChange}
|
|
38
|
+
onScrollabilityChange={onTranscriptScrollabilityChange}
|
|
36
39
|
/>
|
|
37
40
|
<Box marginTop={1} width="100%">
|
|
38
41
|
{bottom}
|
|
@@ -21,6 +21,7 @@ type TranscriptViewProps = {
|
|
|
21
21
|
active?: boolean
|
|
22
22
|
bottomVariant?: 'prompt' | 'overlay'
|
|
23
23
|
onVisibleReasoningIdsChange?: (ids: string[]) => void
|
|
24
|
+
onScrollabilityChange?: (canScroll: boolean) => void
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const PROMPT_RESERVED_LINES = 11
|
|
@@ -33,6 +34,7 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
|
|
|
33
34
|
active = true,
|
|
34
35
|
bottomVariant = 'prompt',
|
|
35
36
|
onVisibleReasoningIdsChange,
|
|
37
|
+
onScrollabilityChange,
|
|
36
38
|
}) => {
|
|
37
39
|
const { stdout } = useStdout()
|
|
38
40
|
const columns = stdout.columns ?? process.stdout.columns ?? 80
|
|
@@ -89,6 +91,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
|
|
|
89
91
|
onVisibleReasoningIdsChange?.(visibleReasoningIds)
|
|
90
92
|
}, [onVisibleReasoningIdsChange, visibleReasoningIds])
|
|
91
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
onScrollabilityChange?.(metrics.maxScrollTop > 0)
|
|
96
|
+
}, [metrics.maxScrollTop, onScrollabilityChange])
|
|
97
|
+
|
|
92
98
|
useAppInput((_input, key) => {
|
|
93
99
|
if (key.pageUp) {
|
|
94
100
|
const target = promptScrollTopForPageUp(
|
|
@@ -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/cli/updateNotice.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function isNewerVersion(candidate: string, current: string): boolean {
|
|
|
24
24
|
|
|
25
25
|
export function formatUpdateNotice(currentVersion: string, latestVersion: string): string | null {
|
|
26
26
|
if (!isNewerVersion(latestVersion, currentVersion)) return null
|
|
27
|
-
return
|
|
27
|
+
return `✨ update available: ethagent ${currentVersion} -> ${latestVersion} · run npm i -g ethagent@latest`
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function checkForUpdates(
|
|
@@ -381,8 +381,8 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
381
381
|
? `opened ${kind === 'soul' ? 'SOUL.md' : kind === 'memory' ? 'MEMORY.md' : 'skills.json'} with ${result.method}.`
|
|
382
382
|
: `open failed: ${result.error}`
|
|
383
383
|
setStep(kind === 'skills'
|
|
384
|
-
? { kind: 'continuity-public', notice: message }
|
|
385
|
-
: { kind: 'continuity-private', notice: message })
|
|
384
|
+
? { kind: 'continuity-public', notice: message, editorOpened: result.ok }
|
|
385
|
+
: { kind: 'continuity-private', notice: message, editorOpened: result.ok })
|
|
386
386
|
} catch (err: unknown) {
|
|
387
387
|
errorStep(err, kind === 'skills' ? { kind: 'continuity-public' } : { kind: 'continuity-private' })
|
|
388
388
|
}
|
|
@@ -609,6 +609,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
609
609
|
ready={continuityReady}
|
|
610
610
|
notice={step.notice}
|
|
611
611
|
footer={footer}
|
|
612
|
+
editorOpened={step.editorOpened}
|
|
612
613
|
onOpenSoul={() => { void openContinuityFile('soul') }}
|
|
613
614
|
onOpenMemory={() => { void openContinuityFile('memory') }}
|
|
614
615
|
onBack={back}
|
|
@@ -625,6 +626,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
625
626
|
ready={continuityReady}
|
|
626
627
|
notice={step.notice}
|
|
627
628
|
footer={footer}
|
|
629
|
+
editorOpened={step.editorOpened}
|
|
628
630
|
onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
|
|
629
631
|
onOpenSkills={() => { void openContinuityFile('skills') }}
|
|
630
632
|
onBack={back}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../registry/erc8004.js'
|
|
7
7
|
import { AgentStateOwnerMismatchError } from '../crypto/backupEnvelope.js'
|
|
8
8
|
import { ContinuitySnapshotOwnerMismatchError } from '../continuity/envelope.js'
|
|
9
|
+
import type { ContinuityWorkingTreeStatus } from '../continuity/storage.js'
|
|
9
10
|
import { resolveSelectedNetwork } from '../registry/registryConfig.js'
|
|
10
11
|
|
|
11
12
|
export const PREFLIGHT_AGENT_URI = 'ipfs://bafybeigdyrztma2dbfczw7q6ooozbxlqzyw5r7w4f3qw2axvvxqg3w6y7q'
|
|
@@ -214,6 +215,85 @@ export function identitySummaryRows(
|
|
|
214
215
|
]
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
export type LocalChangeStatusView = {
|
|
219
|
+
label: string
|
|
220
|
+
detail: string
|
|
221
|
+
tone: 'ok' | 'warn' | 'dim'
|
|
222
|
+
files: string[]
|
|
223
|
+
hasLocalChanges: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function changedContinuitySnapshotFiles(
|
|
227
|
+
workingStatus?: ContinuityWorkingTreeStatus | null,
|
|
228
|
+
): string[] {
|
|
229
|
+
if (!workingStatus?.localContentHashes || !workingStatus.publishedContentHashes) return []
|
|
230
|
+
const files: Array<keyof typeof workingStatus.localContentHashes> = ['SOUL.md', 'MEMORY.md', 'skills.json']
|
|
231
|
+
return files.filter(file => workingStatus.localContentHashes?.[file] !== workingStatus.publishedContentHashes?.[file])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function localChangeStatusView(
|
|
235
|
+
workingStatus?: ContinuityWorkingTreeStatus | null,
|
|
236
|
+
): LocalChangeStatusView {
|
|
237
|
+
if (!workingStatus) {
|
|
238
|
+
return {
|
|
239
|
+
label: 'Local Changes',
|
|
240
|
+
detail: 'Checking status...',
|
|
241
|
+
tone: 'dim',
|
|
242
|
+
files: [],
|
|
243
|
+
hasLocalChanges: false,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (workingStatus.publishState === 'published') {
|
|
248
|
+
return {
|
|
249
|
+
label: 'Local Changes',
|
|
250
|
+
detail: 'None detected',
|
|
251
|
+
tone: 'ok',
|
|
252
|
+
files: [],
|
|
253
|
+
hasLocalChanges: false,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (workingStatus.publishState === 'local-changes') {
|
|
258
|
+
const files = changedContinuitySnapshotFiles(workingStatus)
|
|
259
|
+
return {
|
|
260
|
+
label: 'Local Changes',
|
|
261
|
+
detail: files.length > 0 ? `Detected: ${files.join(', ')}` : 'Detected: local files differ from saved snapshot',
|
|
262
|
+
tone: 'warn',
|
|
263
|
+
files,
|
|
264
|
+
hasLocalChanges: true,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (workingStatus.publishState === 'not-published') {
|
|
269
|
+
return {
|
|
270
|
+
label: 'Local Changes',
|
|
271
|
+
detail: 'Snapshot not saved yet',
|
|
272
|
+
tone: 'warn',
|
|
273
|
+
files: [],
|
|
274
|
+
hasLocalChanges: false,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (workingStatus.publishState === 'verify-needed') {
|
|
279
|
+
return {
|
|
280
|
+
label: 'Local Changes',
|
|
281
|
+
detail: 'Unable to verify saved snapshot',
|
|
282
|
+
tone: 'warn',
|
|
283
|
+
files: [],
|
|
284
|
+
hasLocalChanges: false,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
label: 'Local Changes',
|
|
290
|
+
detail: 'Local files not restored',
|
|
291
|
+
tone: 'warn',
|
|
292
|
+
files: [],
|
|
293
|
+
hasLocalChanges: false,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
217
297
|
export type IdentityDetailSection = {
|
|
218
298
|
title: string
|
|
219
299
|
rows: Array<{
|
|
@@ -31,8 +31,8 @@ export type Step =
|
|
|
31
31
|
| { kind: 'restore-authorizing'; cid: string; apiUrl: string; envelope: RestorableBackupEnvelope; candidate: Erc8004AgentCandidate; purpose?: RestorePurpose }
|
|
32
32
|
| { kind: 'rebackup-signing'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
|
|
33
33
|
| { kind: 'rebackup-storage'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; error?: string; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
|
|
34
|
-
| { kind: 'continuity-private'; notice?: string }
|
|
35
|
-
| { kind: 'continuity-public'; notice?: string }
|
|
34
|
+
| { kind: 'continuity-private'; notice?: string; editorOpened?: boolean }
|
|
35
|
+
| { kind: 'continuity-public'; notice?: string; editorOpened?: boolean }
|
|
36
36
|
| { kind: 'rebackup-confirm'; back: Step }
|
|
37
37
|
| { kind: 'recovery-refetch-confirm'; back: Step }
|
|
38
38
|
| { kind: 'recovery-refetching'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; back: Step }
|
|
@@ -17,6 +17,7 @@ type CommonProps = {
|
|
|
17
17
|
workingStatus?: ContinuityWorkingTreeStatus | null
|
|
18
18
|
ready: boolean
|
|
19
19
|
notice?: string
|
|
20
|
+
editorOpened?: boolean
|
|
20
21
|
footer: React.ReactNode
|
|
21
22
|
onBack: () => void
|
|
22
23
|
}
|
|
@@ -42,6 +43,7 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
42
43
|
workingStatus,
|
|
43
44
|
ready,
|
|
44
45
|
notice,
|
|
46
|
+
editorOpened,
|
|
45
47
|
footer,
|
|
46
48
|
onOpenSoul,
|
|
47
49
|
onOpenMemory,
|
|
@@ -51,6 +53,11 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
51
53
|
<IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
|
|
52
54
|
<PrivateRows identity={identity} ready={ready} />
|
|
53
55
|
<SaveFromHubHint workingStatus={workingStatus} />
|
|
56
|
+
{editorOpened && (
|
|
57
|
+
<Box marginTop={1}>
|
|
58
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
)}
|
|
54
61
|
<Box marginTop={1}>
|
|
55
62
|
<Select<PrivateAction>
|
|
56
63
|
options={[
|
|
@@ -75,11 +82,16 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
75
82
|
export const PublicSkillsScreen: React.FC<CommonProps & {
|
|
76
83
|
onEditProfile: () => void
|
|
77
84
|
onOpenSkills: () => void
|
|
78
|
-
}> = ({ identity, config, workingStatus, notice, footer, onEditProfile, onOpenSkills, onBack }) => (
|
|
85
|
+
}> = ({ identity, config, workingStatus, notice, editorOpened, footer, onEditProfile, onOpenSkills, onBack }) => (
|
|
79
86
|
<Surface title="Public Profile" subtitle={notice ?? 'Manage public metadata, skills.json, and the agent card.'} footer={footer}>
|
|
80
87
|
<IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
|
|
81
88
|
<PublicProfileRows identity={identity} />
|
|
82
89
|
<SaveFromHubHint workingStatus={workingStatus} />
|
|
90
|
+
{editorOpened && (
|
|
91
|
+
<Box marginTop={1}>
|
|
92
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
)}
|
|
83
95
|
<Box marginTop={1}>
|
|
84
96
|
<Select<PublicAction>
|
|
85
97
|
options={[
|
|
@@ -135,7 +147,7 @@ const PublicProfileRows: React.FC<{ identity?: EthagentIdentity }> = ({ identity
|
|
|
135
147
|
function privateSubtitle(ready: boolean): string {
|
|
136
148
|
return ready
|
|
137
149
|
? 'SOUL.md and MEMORY.md are private local files on this machine.'
|
|
138
|
-
: 'Use "Refetch Latest Snapshot" from the
|
|
150
|
+
: 'Use "Refetch Latest Snapshot" from the Identity Hub menu to recover files.'
|
|
139
151
|
}
|
|
140
152
|
|
|
141
153
|
function readStateString(state: Record<string, unknown> | undefined, key: string): string {
|
|
@@ -22,7 +22,7 @@ export const ErrorScreen: React.FC<ErrorScreenProps> = ({ error, back, footer, o
|
|
|
22
22
|
{ value: 'back', role: 'section', prefix: '--', label: 'Recovery' },
|
|
23
23
|
{ value: 'back', label: 'Go Back', hint: 'Return to the previous identity step' },
|
|
24
24
|
{ value: 'close', role: 'section', prefix: '--', label: 'Exit' },
|
|
25
|
-
{ value: 'close', label: 'Close Hub', hint: 'Return to chat without retrying', role: 'utility' },
|
|
25
|
+
{ value: 'close', label: 'Close Identity Hub', hint: 'Return to chat without retrying', role: 'utility' },
|
|
26
26
|
]}
|
|
27
27
|
hintLayout="inline"
|
|
28
28
|
onSubmit={choice => {
|
|
@@ -2,10 +2,22 @@ import React from 'react'
|
|
|
2
2
|
import { Box, Text } from 'ink'
|
|
3
3
|
import { theme } from '../../../ui/theme.js'
|
|
4
4
|
import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
identitySummaryRows,
|
|
7
|
+
lastBackupLabel,
|
|
8
|
+
localChangeStatusView,
|
|
9
|
+
type LocalChangeStatusView,
|
|
10
|
+
} from '../identityHubModel.js'
|
|
6
11
|
|
|
7
12
|
import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
|
|
8
13
|
|
|
14
|
+
type SummaryRow = {
|
|
15
|
+
label: string
|
|
16
|
+
value: string
|
|
17
|
+
tone: 'ok' | 'dim' | 'warn'
|
|
18
|
+
highlight?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
export const IdentitySummary: React.FC<{
|
|
10
22
|
identity?: EthagentIdentity
|
|
11
23
|
config?: EthagentConfig
|
|
@@ -25,24 +37,10 @@ export const IdentitySummary: React.FC<{
|
|
|
25
37
|
: ''
|
|
26
38
|
|
|
27
39
|
const row = (label: string) => rows.find(item => item.label === label)
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
let changedFiles: string[] = []
|
|
31
|
-
if (needsBackup) {
|
|
32
|
-
if (workingStatus?.localContentHashes && workingStatus?.publishedContentHashes) {
|
|
33
|
-
if (workingStatus.localContentHashes['SOUL.md'] !== workingStatus.publishedContentHashes['SOUL.md']) changedFiles.push('SOUL.md')
|
|
34
|
-
if (workingStatus.localContentHashes['MEMORY.md'] !== workingStatus.publishedContentHashes['MEMORY.md']) changedFiles.push('MEMORY.md')
|
|
35
|
-
if (workingStatus.localContentHashes['skills.json'] !== workingStatus.publishedContentHashes['skills.json']) changedFiles.push('skills.json')
|
|
36
|
-
} else {
|
|
37
|
-
changedFiles = ['SOUL.md', 'MEMORY.md', 'skills.json']
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
+
const localChangeStatus = localChangeStatusView(workingStatus)
|
|
41
|
+
const lastSavedRow: SummaryRow = { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' : 'ok' }
|
|
40
42
|
|
|
41
|
-
const
|
|
42
|
-
? { label: 'Unsaved', value: changedFiles.length > 0 ? changedFiles.join(', ') : 'Markdown files', tone: 'warn' as const, highlight: true }
|
|
43
|
-
: { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' as const : 'ok' as const }
|
|
44
|
-
|
|
45
|
-
const summaryRows = [
|
|
43
|
+
const summaryRows: SummaryRow[] = [
|
|
46
44
|
{ label: 'Token', value: row('token')?.value ?? 'Not Created', tone: row('token')?.tone ?? 'dim', highlight: true },
|
|
47
45
|
{ label: 'Network', value: row('network')?.value ?? 'Unknown', tone: row('network')?.tone ?? 'dim' },
|
|
48
46
|
{ label: 'Owner', value: row('owner')?.value ?? 'Not Connected', tone: row('owner')?.tone ?? 'dim' },
|
|
@@ -57,18 +55,36 @@ export const IdentitySummary: React.FC<{
|
|
|
57
55
|
<Box flexDirection="column">
|
|
58
56
|
<Text color={theme.accentPrimary} bold>{stateName || 'Active Agent'}</Text>
|
|
59
57
|
{summaryRows.map(row => {
|
|
60
|
-
const valueColor = row.tone === 'warn' ? '
|
|
58
|
+
const valueColor = row.tone === 'warn' ? '#e87070' : (row.tone === 'ok' ? theme.text : theme.dim)
|
|
61
59
|
return (
|
|
62
60
|
<Text key={row.label}>
|
|
63
61
|
<Text color={theme.dim}>{row.label.padEnd(12)}</Text>
|
|
64
|
-
<Text color={valueColor} bold={row.highlight}>{displayValue(row.value)}</Text>
|
|
62
|
+
<Text color={valueColor} bold={row.highlight ?? false}>{displayValue(row.value)}</Text>
|
|
65
63
|
</Text>
|
|
66
64
|
)
|
|
67
65
|
})}
|
|
66
|
+
<Box marginTop={1}>
|
|
67
|
+
<LocalChangeStatusLine status={localChangeStatus} />
|
|
68
|
+
</Box>
|
|
68
69
|
</Box>
|
|
69
70
|
)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const LocalChangeStatusLine: React.FC<{ status: LocalChangeStatusView }> = ({ status }) => {
|
|
74
|
+
if (status.hasLocalChanges) {
|
|
75
|
+
return (
|
|
76
|
+
<Text color="#e87070" bold>
|
|
77
|
+
Local changes detected
|
|
78
|
+
{status.files.length > 0 ? `: ${status.files.join(', ')}` : ''}
|
|
79
|
+
</Text>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const color = status.tone === 'ok' ? theme.accentMint : status.tone === 'warn' ? theme.accentPeach : theme.dim
|
|
84
|
+
const label = status.detail === 'None detected' ? 'No local changes detected' : status.detail
|
|
85
|
+
return <Text color={color}>{label}</Text>
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
function displayValue(value: string): string {
|
|
73
89
|
const mapped = DISPLAY_VALUES[value]
|
|
74
90
|
return mapped ?? value
|