ethagent 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { EthagentIdentity } from '../../storage/config.js'
4
+ import { atomicWriteText } from '../../storage/atomicWrite.js'
5
+ import {
6
+ continuityVaultRef,
7
+ ensureContinuityVault,
8
+ localContinuitySnapshotContentHashes,
9
+ type ContinuitySnapshotContentHashes,
10
+ } from './storage.js'
11
+
12
+ export type PublishedContinuitySnapshot = {
13
+ version: 1
14
+ id: string
15
+ createdAt: string
16
+ cid: string
17
+ metadataCid?: string
18
+ agentUri?: string
19
+ txHash?: string
20
+ publicSkillsCid?: string
21
+ agentCardCid?: string
22
+ contentHashes?: ContinuitySnapshotContentHashes
23
+ label: string
24
+ identity: {
25
+ address: string
26
+ ownerAddress?: string
27
+ chainId?: number
28
+ identityRegistryAddress?: string
29
+ agentId?: string
30
+ }
31
+ }
32
+
33
+ export type RecordPublishedContinuitySnapshotInput = {
34
+ identity: EthagentIdentity
35
+ label?: string
36
+ }
37
+
38
+ export function publishedContinuitySnapshotsPath(identity: EthagentIdentity): string {
39
+ return path.join(continuityVaultRef(identity).dir, '.published-snapshots.jsonl')
40
+ }
41
+
42
+ export async function recordPublishedContinuitySnapshot(
43
+ input: RecordPublishedContinuitySnapshotInput,
44
+ ): Promise<PublishedContinuitySnapshot | null> {
45
+ const backup = input.identity.backup
46
+ if (!backup?.cid) return null
47
+ await ensureContinuityVault(input.identity)
48
+ const createdAt = backup.createdAt ?? new Date().toISOString()
49
+ const contentHashes = await localContinuitySnapshotContentHashes(input.identity).catch(() => undefined)
50
+ const snapshot: PublishedContinuitySnapshot = {
51
+ version: 1,
52
+ id: `${createdAt}:${backup.cid}`.replaceAll('\\', '/'),
53
+ createdAt,
54
+ cid: backup.cid,
55
+ ...(backup.metadataCid ? { metadataCid: backup.metadataCid } : {}),
56
+ ...(backup.agentUri ? { agentUri: backup.agentUri } : {}),
57
+ ...(backup.txHash ? { txHash: backup.txHash } : {}),
58
+ ...(input.identity.publicSkills?.cid ? { publicSkillsCid: input.identity.publicSkills.cid } : {}),
59
+ ...(input.identity.publicSkills?.agentCardCid ? { agentCardCid: input.identity.publicSkills.agentCardCid } : {}),
60
+ ...(contentHashes ? { contentHashes } : {}),
61
+ label: input.label ?? 'published encrypted snapshot',
62
+ identity: {
63
+ address: input.identity.address,
64
+ ...(input.identity.ownerAddress ? { ownerAddress: input.identity.ownerAddress } : {}),
65
+ ...(input.identity.chainId ? { chainId: input.identity.chainId } : {}),
66
+ ...(input.identity.identityRegistryAddress ? { identityRegistryAddress: input.identity.identityRegistryAddress } : {}),
67
+ ...(input.identity.agentId ? { agentId: input.identity.agentId } : {}),
68
+ },
69
+ }
70
+
71
+ const existing = await readPublishedContinuitySnapshotFile(input.identity)
72
+ if (existing.some(item => item.cid === snapshot.cid)) return snapshot
73
+ await fs.appendFile(publishedContinuitySnapshotsPath(input.identity), `${JSON.stringify(snapshot)}\n`, {
74
+ encoding: 'utf8',
75
+ mode: 0o600,
76
+ })
77
+ return snapshot
78
+ }
79
+
80
+ export async function updatePublishedContinuitySnapshotContentHashes(
81
+ identity: EthagentIdentity,
82
+ cid: string,
83
+ contentHashes: ContinuitySnapshotContentHashes,
84
+ ): Promise<void> {
85
+ await ensureContinuityVault(identity)
86
+ const current = currentPublishedSnapshot(identity)
87
+ const snapshots = await readPublishedContinuitySnapshotFile(identity)
88
+ const index = snapshots.findIndex(item => item.cid === cid)
89
+ if (index === -1) {
90
+ const base = current.find(item => item.cid === cid)
91
+ if (!base) throw new Error('published snapshot was not found')
92
+ snapshots.push({ ...base, contentHashes })
93
+ } else {
94
+ snapshots[index] = { ...snapshots[index]!, contentHashes }
95
+ }
96
+ await atomicWriteText(
97
+ publishedContinuitySnapshotsPath(identity),
98
+ snapshots.map(snapshot => JSON.stringify(snapshot)).join('\n') + '\n',
99
+ { mode: 0o600 },
100
+ )
101
+ }
102
+
103
+ export async function listPublishedContinuitySnapshots(
104
+ identity: EthagentIdentity,
105
+ limit = 30,
106
+ ): Promise<PublishedContinuitySnapshot[]> {
107
+ const snapshots = await readPublishedContinuitySnapshotFile(identity)
108
+
109
+ for (const current of currentPublishedSnapshot(identity)) {
110
+ const index = snapshots.findIndex(item => item.cid === current.cid)
111
+ if (index === -1) {
112
+ snapshots.push(current)
113
+ } else {
114
+ snapshots[index] = enrichPublishedSnapshot(snapshots[index]!, current)
115
+ }
116
+ }
117
+
118
+ return snapshots
119
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
120
+ .slice(0, limit)
121
+ }
122
+
123
+ function enrichPublishedSnapshot(
124
+ snapshot: PublishedContinuitySnapshot,
125
+ current: PublishedContinuitySnapshot,
126
+ ): PublishedContinuitySnapshot {
127
+ return {
128
+ ...snapshot,
129
+ ...(snapshot.metadataCid ? {} : current.metadataCid ? { metadataCid: current.metadataCid } : {}),
130
+ ...(snapshot.agentUri ? {} : current.agentUri ? { agentUri: current.agentUri } : {}),
131
+ ...(snapshot.txHash ? {} : current.txHash ? { txHash: current.txHash } : {}),
132
+ ...(snapshot.publicSkillsCid ? {} : current.publicSkillsCid ? { publicSkillsCid: current.publicSkillsCid } : {}),
133
+ ...(snapshot.agentCardCid ? {} : current.agentCardCid ? { agentCardCid: current.agentCardCid } : {}),
134
+ ...(snapshot.contentHashes ? {} : current.contentHashes ? { contentHashes: current.contentHashes } : {}),
135
+ }
136
+ }
137
+
138
+ async function readPublishedContinuitySnapshotFile(identity: EthagentIdentity): Promise<PublishedContinuitySnapshot[]> {
139
+ let raw: string
140
+ try {
141
+ raw = await fs.readFile(publishedContinuitySnapshotsPath(identity), 'utf8')
142
+ } catch (error: unknown) {
143
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
144
+ throw error
145
+ }
146
+
147
+ const snapshots: PublishedContinuitySnapshot[] = []
148
+ for (const line of raw.split('\n')) {
149
+ const trimmed = line.trim()
150
+ if (!trimmed) continue
151
+ try {
152
+ snapshots.push(JSON.parse(trimmed) as PublishedContinuitySnapshot)
153
+ } catch {
154
+ continue
155
+ }
156
+ }
157
+ return snapshots
158
+ }
159
+
160
+ function currentPublishedSnapshot(identity: EthagentIdentity): PublishedContinuitySnapshot[] {
161
+ const backup = identity.backup
162
+ if (!backup?.cid) return []
163
+ const createdAt = backup.createdAt ?? identity.createdAt ?? new Date(0).toISOString()
164
+ return [{
165
+ version: 1,
166
+ id: `${createdAt}:${backup.cid}`.replaceAll('\\', '/'),
167
+ createdAt,
168
+ cid: backup.cid,
169
+ ...(backup.metadataCid ? { metadataCid: backup.metadataCid } : {}),
170
+ ...(backup.agentUri ? { agentUri: backup.agentUri } : {}),
171
+ ...(backup.txHash ? { txHash: backup.txHash } : {}),
172
+ ...(identity.publicSkills?.cid ? { publicSkillsCid: identity.publicSkills.cid } : {}),
173
+ ...(identity.publicSkills?.agentCardCid ? { agentCardCid: identity.publicSkills.agentCardCid } : {}),
174
+ label: 'current published snapshot',
175
+ identity: {
176
+ address: identity.address,
177
+ ...(identity.ownerAddress ? { ownerAddress: identity.ownerAddress } : {}),
178
+ ...(identity.chainId ? { chainId: identity.chainId } : {}),
179
+ ...(identity.identityRegistryAddress ? { identityRegistryAddress: identity.identityRegistryAddress } : {}),
180
+ ...(identity.agentId ? { agentId: identity.agentId } : {}),
181
+ },
182
+ }]
183
+ }
@@ -0,0 +1,507 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+ import { getConfigDir, type EthagentIdentity } from '../../storage/config.js'
5
+ import { atomicWriteText } from '../../storage/atomicWrite.js'
6
+ import type { ContinuityAgentSnapshot, ContinuityFiles } from './envelope.js'
7
+ import { defaultPublicSkillsProfile, renderPublicSkillsJson } from './publicSkills.js'
8
+
9
+ export const PRIVATE_CONTINUITY_FILES = ['SOUL.md', 'MEMORY.md'] as const
10
+ export type PrivateContinuityFile = (typeof PRIVATE_CONTINUITY_FILES)[number]
11
+
12
+ export type ContinuityVaultRef = {
13
+ dir: string
14
+ soulPath: string
15
+ memoryPath: string
16
+ publicSkillsPath: string
17
+ }
18
+
19
+ export type IdentityMarkdownScaffold = ContinuityFiles & {
20
+ 'skills.json': string
21
+ }
22
+
23
+ export type ContinuitySnapshotFile = PrivateContinuityFile | 'skills.json'
24
+ export type ContinuitySnapshotContentHashes = Record<ContinuitySnapshotFile, string>
25
+ export type ContinuityPublishState = 'not-restored' | 'not-published' | 'verify-needed' | 'local-changes' | 'published'
26
+ export type ContinuityWorkingTreeStatus = {
27
+ ready: boolean
28
+ newestLocalChangeAt?: string
29
+ localChangedAfterBackup: boolean
30
+ publishState: ContinuityPublishState
31
+ localContentHashes?: ContinuitySnapshotContentHashes
32
+ publishedContentHashes?: ContinuitySnapshotContentHashes
33
+ }
34
+
35
+ export function continuityVaultRef(identity: Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'agentId' | 'address'>): ContinuityVaultRef {
36
+ const dir = path.join(getConfigDir(), 'continuity', continuityVaultId(identity))
37
+ return {
38
+ dir,
39
+ soulPath: path.join(dir, 'SOUL.md'),
40
+ memoryPath: path.join(dir, 'MEMORY.md'),
41
+ publicSkillsPath: path.join(dir, 'skills.json'),
42
+ }
43
+ }
44
+
45
+ export async function ensureContinuityVault(identity: EthagentIdentity): Promise<ContinuityVaultRef> {
46
+ const ref = continuityVaultRef(identity)
47
+ await fs.mkdir(ref.dir, { recursive: true, mode: 0o700 })
48
+ return ref
49
+ }
50
+
51
+ export async function ensureContinuityFiles(identity: EthagentIdentity): Promise<ContinuityFiles> {
52
+ const ref = await ensureContinuityVault(identity)
53
+ const defaults = defaultContinuityFiles(identity)
54
+ await writeMissingPrivateFile(ref.soulPath, defaults['SOUL.md'])
55
+ await writeMissingPrivateFile(ref.memoryPath, defaults['MEMORY.md'])
56
+ return readContinuityFiles(identity)
57
+ }
58
+
59
+ export async function readContinuityFiles(identity: EthagentIdentity): Promise<ContinuityFiles> {
60
+ const ref = await ensureContinuityVault(identity)
61
+ const defaults = defaultContinuityFiles(identity)
62
+ return {
63
+ 'SOUL.md': await readOrDefault(ref.soulPath, defaults['SOUL.md']),
64
+ 'MEMORY.md': await readOrDefault(ref.memoryPath, defaults['MEMORY.md']),
65
+ }
66
+ }
67
+
68
+ export async function writeContinuityFiles(identity: EthagentIdentity, files: ContinuityFiles): Promise<ContinuityVaultRef> {
69
+ const ref = await ensureContinuityVault(identity)
70
+ await atomicWriteText(ref.soulPath, ensureTrailingNewline(files['SOUL.md']), { mode: 0o600 })
71
+ await atomicWriteText(ref.memoryPath, ensureTrailingNewline(files['MEMORY.md']), { mode: 0o600 })
72
+ return ref
73
+ }
74
+
75
+ export async function ensureIdentityMarkdownScaffold(
76
+ identity: EthagentIdentity,
77
+ options: { publicSkillsFallback?: string | (() => Promise<string>) } = {},
78
+ ): Promise<IdentityMarkdownScaffold> {
79
+ const privateFiles = await ensureContinuityFiles(identity)
80
+ const publicSkills = await ensurePublicSkillsFile(identity, { fallback: options.publicSkillsFallback })
81
+ return {
82
+ ...privateFiles,
83
+ 'skills.json': publicSkills,
84
+ }
85
+ }
86
+
87
+ export async function writeIdentityMarkdownScaffold(
88
+ identity: EthagentIdentity,
89
+ files: IdentityMarkdownScaffold,
90
+ ): Promise<ContinuityVaultRef> {
91
+ const ref = await writeContinuityFiles(identity, {
92
+ 'SOUL.md': files['SOUL.md'],
93
+ 'MEMORY.md': files['MEMORY.md'],
94
+ })
95
+ await writePublicSkillsFile(identity, files['skills.json'])
96
+ return ref
97
+ }
98
+
99
+ export async function syncIdentityMarkdownScaffold(identity: EthagentIdentity): Promise<IdentityMarkdownScaffold> {
100
+ const next = await prepareSyncedIdentityMarkdownScaffold(identity)
101
+ await writeIdentityMarkdownScaffold(identity, next)
102
+ return next
103
+ }
104
+
105
+ export async function prepareSyncedIdentityMarkdownScaffold(identity: EthagentIdentity): Promise<IdentityMarkdownScaffold> {
106
+ await ensureIdentityMarkdownScaffold(identity)
107
+ const privateFiles = await readContinuityFiles(identity)
108
+ const publicSkills = await readPublicSkillsFile(identity)
109
+ const privateDefaults = defaultContinuityFiles(identity)
110
+ const publicDefault = defaultPublicSkillsJson(identity)
111
+ return {
112
+ 'SOUL.md': syncGeneratedMarkdown(privateFiles['SOUL.md'], privateDefaults['SOUL.md'], [
113
+ { marker: 'identity', legacyHeading: 'Identity' },
114
+ ]),
115
+ 'MEMORY.md': syncGeneratedMarkdown(privateFiles['MEMORY.md'], privateDefaults['MEMORY.md'], [
116
+ { marker: 'identity' },
117
+ ]),
118
+ 'skills.json': syncSkillsJson(publicSkills, publicDefault),
119
+ }
120
+ }
121
+
122
+ export async function prepareSyncedPublicSkillsJson(identity: EthagentIdentity): Promise<string> {
123
+ await ensurePublicSkillsFile(identity)
124
+ const publicSkills = await readPublicSkillsFile(identity)
125
+ return syncSkillsJson(publicSkills, defaultPublicSkillsJson(identity))
126
+ }
127
+
128
+ function syncSkillsJson(existing: string, fresh: string): string {
129
+ try {
130
+ const existingParsed = JSON.parse(existing)
131
+ const freshParsed = JSON.parse(fresh)
132
+ const merged = {
133
+ ...existingParsed,
134
+ ...freshParsed,
135
+ skills: existingParsed.skills || freshParsed.skills,
136
+ inputModes: existingParsed.inputModes || freshParsed.inputModes,
137
+ outputModes: existingParsed.outputModes || freshParsed.outputModes,
138
+ }
139
+ return `${JSON.stringify(merged, null, 2)}\n`
140
+ } catch {
141
+ return fresh
142
+ }
143
+ }
144
+
145
+ export async function ensurePublicSkillsFile(
146
+ identity: EthagentIdentity,
147
+ options: { fallback?: string | (() => Promise<string>) } = {},
148
+ ): Promise<string> {
149
+ const ref = await ensureContinuityVault(identity)
150
+ if (await exists(ref.publicSkillsPath)) return readPublicSkillsFile(identity)
151
+
152
+
153
+ const fallback = await resolvePublicSkillsFallback(identity, options.fallback)
154
+ await atomicWriteText(ref.publicSkillsPath, ensureTrailingNewline(fallback), { mode: 0o644 })
155
+ return readPublicSkillsFile(identity)
156
+ }
157
+
158
+ export async function readPublicSkillsFile(identity: EthagentIdentity): Promise<string> {
159
+ const ref = await ensureContinuityVault(identity)
160
+ return readOrDefault(ref.publicSkillsPath, defaultPublicSkillsJson(identity))
161
+ }
162
+
163
+ export async function writePublicSkillsFile(identity: EthagentIdentity, content: string): Promise<ContinuityVaultRef> {
164
+ const ref = await ensureContinuityVault(identity)
165
+ await atomicWriteText(ref.publicSkillsPath, ensureTrailingNewline(content), { mode: 0o644 })
166
+ return ref
167
+ }
168
+
169
+ export async function continuityVaultStatus(identity: EthagentIdentity): Promise<{ ready: boolean; files: ContinuityVaultRef }> {
170
+ const ref = continuityVaultRef(identity)
171
+ const [soul, memory] = await Promise.all([exists(ref.soulPath), exists(ref.memoryPath)])
172
+ return { ready: soul && memory, files: ref }
173
+ }
174
+
175
+ export async function continuityWorkingTreeStatus(
176
+ identity: EthagentIdentity,
177
+ publishedSnapshot?: { contentHashes?: ContinuitySnapshotContentHashes },
178
+ ): Promise<ContinuityWorkingTreeStatus> {
179
+ const ref = continuityVaultRef(identity)
180
+ const stats = await Promise.all([
181
+ statIfExists(ref.soulPath),
182
+ statIfExists(ref.memoryPath),
183
+ statIfExists(ref.publicSkillsPath),
184
+ ])
185
+ const newestMs = Math.max(0, ...stats.flatMap(stat => stat ? [stat.mtimeMs] : []))
186
+ const ready = Boolean(stats[0] && stats[1])
187
+ const localContentHashes = ready
188
+ ? await localContinuitySnapshotContentHashes(identity).catch(() => undefined)
189
+ : undefined
190
+ const publishedContentHashes = publishedSnapshot?.contentHashes
191
+ const publishState: ContinuityPublishState = !ready
192
+ ? 'not-restored'
193
+ : !identity.backup?.cid
194
+ ? 'not-published'
195
+ : !localContentHashes || !publishedContentHashes
196
+ ? 'verify-needed'
197
+ : equalContinuitySnapshotHashes(localContentHashes, publishedContentHashes)
198
+ ? 'published'
199
+ : 'local-changes'
200
+
201
+ return {
202
+ ready,
203
+ ...(newestMs > 0 ? { newestLocalChangeAt: new Date(newestMs).toISOString() } : {}),
204
+ localChangedAfterBackup: publishState === 'local-changes',
205
+ publishState,
206
+ ...(localContentHashes ? { localContentHashes } : {}),
207
+ ...(publishedContentHashes ? { publishedContentHashes } : {}),
208
+ }
209
+ }
210
+
211
+ export async function localContinuitySnapshotContentHashes(
212
+ identity: EthagentIdentity,
213
+ ): Promise<ContinuitySnapshotContentHashes> {
214
+ const privateFiles = await readContinuityFiles(identity)
215
+ const publicSkills = await readPublicSkillsFile(identity)
216
+ return continuitySnapshotContentHashes(privateFiles, publicSkills)
217
+ }
218
+
219
+ export function continuitySnapshotContentHashes(
220
+ privateFiles: ContinuityFiles,
221
+ publicSkills: string,
222
+ ): ContinuitySnapshotContentHashes {
223
+ return {
224
+ 'SOUL.md': hashContinuitySnapshotContent(privateFiles['SOUL.md']),
225
+ 'MEMORY.md': hashContinuitySnapshotContent(privateFiles['MEMORY.md']),
226
+ 'skills.json': hashContinuitySnapshotContent(publicSkills),
227
+ }
228
+ }
229
+
230
+ export function equalContinuitySnapshotHashes(
231
+ a: ContinuitySnapshotContentHashes,
232
+ b: ContinuitySnapshotContentHashes,
233
+ ): boolean {
234
+ return a['SOUL.md'] === b['SOUL.md']
235
+ && a['MEMORY.md'] === b['MEMORY.md']
236
+ && a['skills.json'] === b['skills.json']
237
+ }
238
+
239
+ function hashContinuitySnapshotContent(value: string): string {
240
+ return createHash('sha256').update(normalizeSnapshotContent(value), 'utf8').digest('hex')
241
+ }
242
+
243
+ function normalizeSnapshotContent(value: string): string {
244
+ const normalized = value.replace(/\r\n?/g, '\n')
245
+ return normalized.endsWith('\n') ? normalized : `${normalized}\n`
246
+ }
247
+
248
+ export function continuityAgentSnapshot(identity: EthagentIdentity): ContinuityAgentSnapshot {
249
+ const state = identity.state ?? {}
250
+ return {
251
+ ...(identity.chainId ? { chainId: identity.chainId } : {}),
252
+ ...(identity.identityRegistryAddress ? { identityRegistryAddress: identity.identityRegistryAddress } : {}),
253
+ ...(identity.agentId ? { agentId: identity.agentId } : {}),
254
+ ...(identity.agentUri ? { agentUri: identity.agentUri } : {}),
255
+ ...(identity.metadataCid ? { metadataCid: identity.metadataCid } : {}),
256
+ ...(typeof state.name === 'string' ? { name: state.name } : {}),
257
+ ...(typeof state.description === 'string' ? { description: state.description } : {}),
258
+ }
259
+ }
260
+
261
+ export function defaultContinuityFiles(identity: EthagentIdentity, now = new Date()): ContinuityFiles {
262
+ const owner = identity.ownerAddress ?? identity.address
263
+ const created = now.toISOString().slice(0, 10)
264
+ const identityBlock = renderPrivateIdentityBlock({
265
+ owner,
266
+ token: identity.agentId ? `#${identity.agentId}` : 'pending registration',
267
+ chainId: identity.chainId ? identity.chainId.toString() : 'unknown',
268
+ registry: identity.identityRegistryAddress ?? 'unknown',
269
+ })
270
+ return {
271
+ 'SOUL.md': [
272
+ '# SOUL.md',
273
+ '',
274
+ identityBlock,
275
+ '',
276
+ '## Persona',
277
+ '',
278
+ '- Describe the private agent persona, voice, and collaboration style.',
279
+ '- Keep standing behavior that should survive model switches and device restores.',
280
+ '- Prefer stable guidance over session-specific preferences.',
281
+ '',
282
+ '## Operating Principles',
283
+ '',
284
+ '- Record durable values, decision preferences, and owner-approved working principles.',
285
+ '- Keep implementation-specific facts in MEMORY.md unless they define behavior.',
286
+ '',
287
+ '## Private Instructions',
288
+ '',
289
+ '- Keep owner-specific standing instructions in this file.',
290
+ '- Do not publish this file directly; use encrypted snapshot backup from Identity Hub.',
291
+ '- Public capabilities belong in skills.json.',
292
+ '',
293
+ '## Boundaries',
294
+ '',
295
+ '- Record private behavioral limits and owner-approved constraints here.',
296
+ '- Do not store seed phrases, private keys, raw wallet signatures, or API keys.',
297
+ '- Do not place public delegation claims here; keep them in skills.json.',
298
+ '',
299
+ '## Maintenance Rules',
300
+ '',
301
+ '- Keep the generated Agent Identity block intact; edit owner-authored sections below it.',
302
+ '- Do not duplicate the mutable public agent name here; it lives in token metadata and the Agent Card.',
303
+ '- Move factual project memory to MEMORY.md when it is not persona or instruction material.',
304
+ '- Revise or remove stale guidance instead of accumulating contradictions.',
305
+ '',
306
+ '## Change Notes',
307
+ '',
308
+ '- Add dated notes when the persona or long-lived private guidance changes.',
309
+ '',
310
+ `Created: ${created}`,
311
+ ].join('\n') + '\n',
312
+ 'MEMORY.md': [
313
+ '# MEMORY.md',
314
+ '',
315
+ identityBlock,
316
+ '',
317
+ '## Durable User Preferences',
318
+ '',
319
+ '- Add long-lived owner preferences that should survive across sessions and model switches.',
320
+ '',
321
+ '## Project Context',
322
+ '',
323
+ '- Add stable project facts, repo conventions, and active workstreams.',
324
+ '',
325
+ '## Decisions and Rationale',
326
+ '',
327
+ '- Record important decisions and why they were made.',
328
+ '',
329
+ '## Facts to Revalidate',
330
+ '',
331
+ '- Add time-sensitive facts that should be checked before reuse, with dates or source context when available.',
332
+ '',
333
+ '## Maintenance Rules',
334
+ '',
335
+ '- Prefer stable facts, preferences, and decisions over chat transcripts.',
336
+ '- Do not duplicate the mutable public agent name here; it lives in token metadata and the Agent Card.',
337
+ '- Add dates or source context when a note may become stale or environment-specific.',
338
+ '- Remove or rewrite stale memory instead of accumulating contradictions.',
339
+ '',
340
+ '## Boundaries',
341
+ '',
342
+ '- Do not store seed phrases, private keys, raw wallet signatures, or API keys.',
343
+ '- Do not store secrets unless the user explicitly asks and the risk is clear.',
344
+ '- Keep public capabilities in skills.json.',
345
+ '',
346
+ `Created: ${created}`,
347
+ ].join('\n') + '\n',
348
+ }
349
+ }
350
+
351
+ export function defaultPublicSkillsJson(identity: EthagentIdentity): string {
352
+ return renderPublicSkillsJson(defaultPublicSkillsProfile(identity))
353
+ }
354
+
355
+ function continuityVaultId(identity: Pick<EthagentIdentity, 'chainId' | 'identityRegistryAddress' | 'agentId' | 'address'>): string {
356
+ const chain = identity.chainId?.toString() ?? 'unknown-chain'
357
+ const registry = sanitizePathPart(identity.identityRegistryAddress ?? 'unknown-registry')
358
+ const token = sanitizePathPart(identity.agentId ?? identity.address)
359
+ return `${chain}-${registry}-${token}`
360
+ }
361
+
362
+ function sanitizePathPart(value: string): string {
363
+ return value.trim().toLowerCase().replace(/^0x/, '').replace(/[^a-z0-9._-]+/g, '-').slice(0, 120) || 'unknown'
364
+ }
365
+
366
+ async function writeMissingPrivateFile(file: string, content: string): Promise<void> {
367
+ if (await exists(file)) return
368
+ await atomicWriteText(file, ensureTrailingNewline(content), { mode: 0o600 })
369
+ }
370
+
371
+ async function resolvePublicSkillsFallback(
372
+ identity: EthagentIdentity,
373
+ fallback: string | (() => Promise<string>) | undefined,
374
+ ): Promise<string> {
375
+ if (typeof fallback === 'string') return fallback
376
+ if (fallback) {
377
+ try {
378
+ return await fallback()
379
+ } catch {
380
+ return defaultPublicSkillsJson(identity)
381
+ }
382
+ }
383
+ return defaultPublicSkillsJson(identity)
384
+ }
385
+
386
+ async function readOrDefault(file: string, fallback: string): Promise<string> {
387
+ try {
388
+ return await fs.readFile(file, 'utf8')
389
+ } catch (err: unknown) {
390
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return fallback
391
+ throw err
392
+ }
393
+ }
394
+
395
+ async function exists(file: string): Promise<boolean> {
396
+ try {
397
+ await fs.access(file)
398
+ return true
399
+ } catch {
400
+ return false
401
+ }
402
+ }
403
+
404
+ async function statIfExists(file: string): Promise<import('node:fs').Stats | null> {
405
+ try {
406
+ return await fs.stat(file)
407
+ } catch {
408
+ return null
409
+ }
410
+ }
411
+
412
+ function ensureTrailingNewline(value: string): string {
413
+ return value.endsWith('\n') ? value : `${value}\n`
414
+ }
415
+
416
+ type SyncBlock = {
417
+ marker: string
418
+ legacyHeading?: string
419
+ }
420
+
421
+ function renderPrivateIdentityBlock(args: {
422
+ owner: string
423
+ token: string
424
+ chainId: string
425
+ registry: string
426
+ }): string {
427
+ return [
428
+ '<!-- ethagent:identity:start -->',
429
+ '## Agent Identity',
430
+ `- Owner wallet: ${args.owner}`,
431
+ `- ERC-8004 token: ${args.token}`,
432
+ `- Chain ID: ${args.chainId}`,
433
+ `- Registry: ${args.registry}`,
434
+ '- Visibility: private local working file; encrypted before IPFS backup.',
435
+ '<!-- ethagent:identity:end -->',
436
+ ].join('\n')
437
+ }
438
+
439
+ function syncGeneratedMarkdown(existing: string, fresh: string, blocks: SyncBlock[]): string {
440
+ let next = replaceFirstHeading(existing, firstHeading(fresh))
441
+ for (const block of blocks) {
442
+ next = replaceOrInsertMarkedBlock(next, fresh, block)
443
+ }
444
+ return ensureTrailingNewline(next)
445
+ }
446
+
447
+ function firstHeading(markdown: string): string {
448
+ return markdown.split(/\r?\n/).find(line => line.startsWith('# ')) ?? ''
449
+ }
450
+
451
+ function replaceFirstHeading(markdown: string, heading: string): string {
452
+ if (!heading) return markdown
453
+ const lines = markdown.split(/\r?\n/)
454
+ const index = lines.findIndex(line => line.startsWith('# '))
455
+ if (index === -1) return `${heading}\n\n${markdown.trimStart()}`
456
+ lines[index] = heading
457
+ return lines.join('\n')
458
+ }
459
+
460
+ function replaceOrInsertMarkedBlock(markdown: string, fresh: string, block: SyncBlock): string {
461
+ const freshBlock = extractMarkedBlock(fresh, block.marker)
462
+ if (!freshBlock) return markdown
463
+ const replaced = replaceMarkedBlock(markdown, block.marker, freshBlock)
464
+ if (replaced) return replaced
465
+ if (block.legacyHeading) {
466
+ const replacedLegacy = replaceMarkdownSection(markdown, block.legacyHeading, freshBlock)
467
+ if (replacedLegacy) return replacedLegacy
468
+ }
469
+ return insertAfterFirstHeading(markdown, freshBlock)
470
+ }
471
+
472
+ function extractMarkedBlock(markdown: string, marker: string): string | null {
473
+ const start = `<!-- ethagent:${marker}:start -->`
474
+ const end = `<!-- ethagent:${marker}:end -->`
475
+ const startIndex = markdown.indexOf(start)
476
+ const endIndex = markdown.indexOf(end, startIndex + start.length)
477
+ if (startIndex === -1 || endIndex === -1) return null
478
+ return markdown.slice(startIndex, endIndex + end.length).trim()
479
+ }
480
+
481
+ function replaceMarkedBlock(markdown: string, marker: string, replacement: string): string | null {
482
+ const start = `<!-- ethagent:${marker}:start -->`
483
+ const end = `<!-- ethagent:${marker}:end -->`
484
+ const startIndex = markdown.indexOf(start)
485
+ const endIndex = markdown.indexOf(end, startIndex + start.length)
486
+ if (startIndex === -1 || endIndex === -1) return null
487
+ return `${markdown.slice(0, startIndex)}${replacement}${markdown.slice(endIndex + end.length)}`
488
+ }
489
+
490
+ function replaceMarkdownSection(markdown: string, heading: string, replacement: string): string | null {
491
+ const lines = markdown.split(/\r?\n/)
492
+ const start = lines.findIndex(line => line.trim() === `## ${heading}`)
493
+ if (start === -1) return null
494
+ const end = lines.findIndex((line, index) => index > start && /^##\s+/.test(line))
495
+ const before = lines.slice(0, start)
496
+ const after = end === -1 ? [] : lines.slice(end)
497
+ return [...before, replacement, '', ...after].join('\n')
498
+ }
499
+
500
+ function insertAfterFirstHeading(markdown: string, block: string): string {
501
+ const lines = markdown.split(/\r?\n/)
502
+ const headingIndex = lines.findIndex(line => line.startsWith('# '))
503
+ if (headingIndex === -1) return `${block}\n\n${markdown.trimStart()}`
504
+ const before = lines.slice(0, headingIndex + 1)
505
+ const after = lines.slice(headingIndex + 1)
506
+ return [...before, '', block, '', ...after].join('\n')
507
+ }