ethagent 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,153 @@
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 type { ContinuityFiles } from './envelope.js'
6
+ import {
7
+ continuityVaultRef,
8
+ ensureContinuityVault,
9
+ writePublicSkillsFile,
10
+ writeContinuityFiles,
11
+ type PrivateContinuityFile,
12
+ } from './storage.js'
13
+
14
+ export type PrivateContinuityHistorySnapshot = {
15
+ version: 1
16
+ id: string
17
+ createdAt: string
18
+ file: PrivateContinuityFile
19
+ filePath: string
20
+ existedBefore: boolean
21
+ previousContent: string
22
+ previousFiles?: ContinuityFiles
23
+ previousPublicSkills?: string
24
+ changeSummary: string
25
+ identity: {
26
+ address: string
27
+ ownerAddress?: string
28
+ chainId?: number
29
+ identityRegistryAddress?: string
30
+ agentId?: string
31
+ }
32
+ sessionId?: string
33
+ turnId?: string
34
+ promptSnippet?: string
35
+ checkpointLabel?: string
36
+ }
37
+
38
+ export type RecordPrivateContinuityHistoryInput = {
39
+ identity: EthagentIdentity
40
+ file: PrivateContinuityFile
41
+ filePath: string
42
+ existedBefore: boolean
43
+ previousContent: string
44
+ previousFiles?: ContinuityFiles
45
+ previousPublicSkills?: string
46
+ changeSummary: string
47
+ createdAt?: string
48
+ sessionId?: string
49
+ turnId?: string
50
+ promptSnippet?: string
51
+ checkpointLabel?: string
52
+ }
53
+
54
+ export function privateContinuityHistoryPath(identity: EthagentIdentity): string {
55
+ return path.join(continuityVaultRef(identity).dir, '.history.jsonl')
56
+ }
57
+
58
+ export async function recordPrivateContinuityHistorySnapshot(
59
+ input: RecordPrivateContinuityHistoryInput,
60
+ ): Promise<PrivateContinuityHistorySnapshot> {
61
+ await ensureContinuityVault(input.identity)
62
+ const createdAt = input.createdAt ?? new Date().toISOString()
63
+ const snapshot: PrivateContinuityHistorySnapshot = {
64
+ version: 1,
65
+ id: `${createdAt}:${input.file}`.replaceAll('\\', '/'),
66
+ createdAt,
67
+ file: input.file,
68
+ filePath: path.resolve(input.filePath),
69
+ existedBefore: input.existedBefore,
70
+ previousContent: input.previousContent,
71
+ ...(input.previousFiles ? { previousFiles: input.previousFiles } : {}),
72
+ ...(input.previousPublicSkills !== undefined ? { previousPublicSkills: input.previousPublicSkills } : {}),
73
+ changeSummary: input.changeSummary,
74
+ identity: {
75
+ address: input.identity.address,
76
+ ...(input.identity.ownerAddress ? { ownerAddress: input.identity.ownerAddress } : {}),
77
+ ...(input.identity.chainId ? { chainId: input.identity.chainId } : {}),
78
+ ...(input.identity.identityRegistryAddress ? { identityRegistryAddress: input.identity.identityRegistryAddress } : {}),
79
+ ...(input.identity.agentId ? { agentId: input.identity.agentId } : {}),
80
+ },
81
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
82
+ ...(input.turnId ? { turnId: input.turnId } : {}),
83
+ ...(input.promptSnippet ? { promptSnippet: normalizeSnippet(input.promptSnippet) } : {}),
84
+ ...(input.checkpointLabel ? { checkpointLabel: normalizeSnippet(input.checkpointLabel) } : {}),
85
+ }
86
+ await fs.appendFile(privateContinuityHistoryPath(input.identity), `${JSON.stringify(snapshot)}\n`, {
87
+ encoding: 'utf8',
88
+ mode: 0o600,
89
+ })
90
+ return snapshot
91
+ }
92
+
93
+ export async function listPrivateContinuityHistory(
94
+ identity: EthagentIdentity,
95
+ limit = 30,
96
+ ): Promise<PrivateContinuityHistorySnapshot[]> {
97
+ let raw: string
98
+ try {
99
+ raw = await fs.readFile(privateContinuityHistoryPath(identity), 'utf8')
100
+ } catch (error: unknown) {
101
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
102
+ throw error
103
+ }
104
+
105
+ const snapshots: PrivateContinuityHistorySnapshot[] = []
106
+ for (const line of raw.split('\n')) {
107
+ const trimmed = line.trim()
108
+ if (!trimmed) continue
109
+ try {
110
+ snapshots.push(JSON.parse(trimmed) as PrivateContinuityHistorySnapshot)
111
+ } catch {
112
+ continue
113
+ }
114
+ }
115
+ return snapshots.reverse().slice(0, limit)
116
+ }
117
+
118
+ export async function restorePrivateContinuityHistorySnapshot(
119
+ identity: EthagentIdentity,
120
+ snapshotId: string,
121
+ ): Promise<PrivateContinuityHistorySnapshot> {
122
+ const snapshot = (await listPrivateContinuityHistory(identity, 500))
123
+ .find(item => item.id === snapshotId)
124
+ if (!snapshot) throw new Error('private continuity checkpoint was not found')
125
+
126
+ if (snapshot.previousFiles) {
127
+ await writeContinuityFiles(identity, snapshot.previousFiles)
128
+ if (snapshot.previousPublicSkills !== undefined) {
129
+ await writePublicSkillsFile(identity, snapshot.previousPublicSkills)
130
+ }
131
+ return snapshot
132
+ }
133
+
134
+ await ensureContinuityVault(identity)
135
+ if (snapshot.existedBefore) {
136
+ await atomicWriteText(snapshot.filePath, ensureTrailingNewline(snapshot.previousContent), { mode: 0o600 })
137
+ } else {
138
+ await fs.rm(snapshot.filePath, { force: true })
139
+ }
140
+ if (snapshot.previousPublicSkills !== undefined) {
141
+ await writePublicSkillsFile(identity, snapshot.previousPublicSkills)
142
+ }
143
+ return snapshot
144
+ }
145
+
146
+ function normalizeSnippet(input: string): string {
147
+ const normalized = input.replace(/\s+/g, ' ').trim()
148
+ return normalized.length <= 120 ? normalized : `${normalized.slice(0, 117)}...`
149
+ }
150
+
151
+ function ensureTrailingNewline(value: string): string {
152
+ return value.endsWith('\n') ? value : `${value}\n`
153
+ }
@@ -0,0 +1,334 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { atomicWriteText } from '../../storage/atomicWrite.js'
4
+ import type { EthagentConfig, EthagentIdentity } from '../../storage/config.js'
5
+ import { applyRequestedEdit } from '../../tools/editUtils.js'
6
+ import {
7
+ continuityVaultRef,
8
+ defaultContinuityFiles,
9
+ ensureContinuityVault,
10
+ type PrivateContinuityFile,
11
+ } from './storage.js'
12
+
13
+ export type PrivateContinuityEditInput = {
14
+ file: PrivateContinuityFile
15
+ oldText?: string
16
+ newText?: string
17
+ appendToSection?: string
18
+ appendText?: string
19
+ replaceAll?: boolean
20
+ replaceWholeFile?: boolean
21
+ }
22
+
23
+ export type PreparedPrivateContinuityEdit = {
24
+ identity: EthagentIdentity
25
+ file: PrivateContinuityFile
26
+ fullPath: string
27
+ relativePath: string
28
+ directoryPath: string
29
+ existedBefore: boolean
30
+ previousContent: string
31
+ before: string
32
+ after: string
33
+ previewBefore: string
34
+ previewAfter: string
35
+ changeSummary: string
36
+ diff: string
37
+ }
38
+
39
+ export async function preparePrivateContinuityEdit(
40
+ input: PrivateContinuityEditInput,
41
+ config: EthagentConfig | undefined,
42
+ ): Promise<PreparedPrivateContinuityEdit> {
43
+ const identity = config?.identity
44
+ if (!identity) {
45
+ throw new Error('no active identity; create or load an identity before proposing private continuity edits')
46
+ }
47
+
48
+ const fullPath = privateContinuityPath(identity, input.file)
49
+ const existing = await readPrivateContinuityFile(identity, input.file, fullPath)
50
+ const applied = applyPrivateContinuityEdit(input, existing.content, identity)
51
+
52
+ return {
53
+ identity,
54
+ file: input.file,
55
+ fullPath,
56
+ relativePath: `identity-vault/${input.file}`,
57
+ directoryPath: path.dirname(fullPath),
58
+ existedBefore: existing.existedBefore,
59
+ previousContent: existing.existedBefore ? existing.content : '',
60
+ before: existing.content,
61
+ after: applied.after,
62
+ previewBefore: applied.previewBefore,
63
+ previewAfter: applied.previewAfter,
64
+ changeSummary: applied.summary,
65
+ diff: renderPrivateContinuityDiff(input.file, applied.before, applied.after),
66
+ }
67
+ }
68
+
69
+ function applyPrivateContinuityEdit(input: PrivateContinuityEditInput, before: string, identity: EthagentIdentity) {
70
+ if (input.replaceWholeFile) {
71
+ throw new Error('private continuity files must be edited in place; whole-file replacement is disabled')
72
+ }
73
+ if (input.appendToSection || input.appendText) {
74
+ if (!input.appendToSection?.trim()) throw new Error('appendToSection is required for append edits')
75
+ if (!input.appendText?.trim()) throw new Error('appendText is required for append edits')
76
+ if (input.oldText || input.newText !== undefined) {
77
+ throw new Error('use either appendToSection+appendText or oldText+newText, not both')
78
+ }
79
+ return appendToMarkdownSection(identity, input.file, before, input.appendToSection, input.appendText)
80
+ }
81
+ if (!input.oldText?.trim()) {
82
+ throw new Error('oldText is required; private continuity edits must patch existing scaffold text')
83
+ }
84
+ if (input.newText === undefined) {
85
+ throw new Error('newText is required for targeted private continuity edits')
86
+ }
87
+ return applyRequestedEdit(
88
+ input.file,
89
+ before,
90
+ input.oldText,
91
+ input.newText,
92
+ input.replaceAll ?? false,
93
+ false,
94
+ )
95
+ }
96
+
97
+ export async function writePreparedPrivateContinuityEdit(edit: PreparedPrivateContinuityEdit): Promise<void> {
98
+ await ensureContinuityVault(edit.identity)
99
+ await atomicWriteText(edit.fullPath, ensureTrailingNewline(edit.after), { mode: 0o600 })
100
+ }
101
+
102
+ function privateContinuityPath(identity: EthagentIdentity, file: PrivateContinuityFile): string {
103
+ const ref = continuityVaultRef(identity)
104
+ return file === 'SOUL.md' ? ref.soulPath : ref.memoryPath
105
+ }
106
+
107
+ async function readPrivateContinuityFile(
108
+ identity: EthagentIdentity,
109
+ file: PrivateContinuityFile,
110
+ fullPath: string,
111
+ ): Promise<{ content: string; existedBefore: boolean }> {
112
+ try {
113
+ return { content: await fs.readFile(fullPath, 'utf8'), existedBefore: true }
114
+ } catch (err: unknown) {
115
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
116
+ return { content: defaultContinuityFiles(identity)[file], existedBefore: false }
117
+ }
118
+ throw err
119
+ }
120
+ }
121
+
122
+ function renderPrivateContinuityDiff(file: PrivateContinuityFile, before: string, after: string): string {
123
+ if (before === after) return '(no changes)'
124
+ const changedLines = changedMarkdownLines(before, after)
125
+ const lines = [
126
+ `--- ${file}`,
127
+ `+++ ${file}`,
128
+ ...(changedLines.length > 0 ? changedLines : ['(only whitespace or line ending changes)']),
129
+ ]
130
+ const diff = lines.join('\n')
131
+ return diff.length <= 2400 ? diff : `${diff.slice(0, 2397)}...`
132
+ }
133
+
134
+ function changedMarkdownLines(before: string, after: string): string[] {
135
+ const beforeLines = markdownLines(before)
136
+ const afterLines = markdownLines(after)
137
+ const lengths = lcsLengths(beforeLines, afterLines)
138
+ const changed: string[] = []
139
+ let beforeIndex = 0
140
+ let afterIndex = 0
141
+
142
+ while (beforeIndex < beforeLines.length && afterIndex < afterLines.length) {
143
+ if (beforeLines[beforeIndex] === afterLines[afterIndex]) {
144
+ beforeIndex += 1
145
+ afterIndex += 1
146
+ continue
147
+ }
148
+
149
+ const deleteScore = lengths[beforeIndex + 1]![afterIndex]!
150
+ const insertScore = lengths[beforeIndex]![afterIndex + 1]!
151
+ const deleteRevealsMatch = beforeLines[beforeIndex + 1] === afterLines[afterIndex]
152
+ const insertRevealsMatch = beforeLines[beforeIndex] === afterLines[afterIndex + 1]
153
+
154
+ if (insertRevealsMatch && insertScore >= deleteScore) {
155
+ changed.push(`+${afterLines[afterIndex]}`)
156
+ afterIndex += 1
157
+ } else if (deleteRevealsMatch && deleteScore >= insertScore) {
158
+ changed.push(`-${beforeLines[beforeIndex]}`)
159
+ beforeIndex += 1
160
+ } else if (deleteScore >= insertScore) {
161
+ changed.push(`-${beforeLines[beforeIndex]}`)
162
+ beforeIndex += 1
163
+ } else {
164
+ changed.push(`+${afterLines[afterIndex]}`)
165
+ afterIndex += 1
166
+ }
167
+ }
168
+
169
+ while (beforeIndex < beforeLines.length) {
170
+ changed.push(`-${beforeLines[beforeIndex]}`)
171
+ beforeIndex += 1
172
+ }
173
+ while (afterIndex < afterLines.length) {
174
+ changed.push(`+${afterLines[afterIndex]}`)
175
+ afterIndex += 1
176
+ }
177
+
178
+ return changed
179
+ }
180
+
181
+ function lcsLengths(beforeLines: string[], afterLines: string[]): number[][] {
182
+ const lengths = Array.from(
183
+ { length: beforeLines.length + 1 },
184
+ () => Array<number>(afterLines.length + 1).fill(0),
185
+ )
186
+
187
+ for (let beforeIndex = beforeLines.length - 1; beforeIndex >= 0; beforeIndex -= 1) {
188
+ for (let afterIndex = afterLines.length - 1; afterIndex >= 0; afterIndex -= 1) {
189
+ lengths[beforeIndex]![afterIndex] = beforeLines[beforeIndex] === afterLines[afterIndex]
190
+ ? lengths[beforeIndex + 1]![afterIndex + 1]! + 1
191
+ : Math.max(lengths[beforeIndex + 1]![afterIndex]!, lengths[beforeIndex]![afterIndex + 1]!)
192
+ }
193
+ }
194
+
195
+ return lengths
196
+ }
197
+
198
+ function appendToMarkdownSection(
199
+ identity: EthagentIdentity,
200
+ file: PrivateContinuityFile,
201
+ before: string,
202
+ section: string,
203
+ appendText: string,
204
+ ) {
205
+ const heading = normalizeSectionHeading(section)
206
+ let working = before
207
+ let repairedMissingSection = false
208
+ let lines = working.split(/\r?\n/)
209
+ let bounds = findMarkdownSectionBounds(lines, heading)
210
+ if (!bounds) {
211
+ const repaired = insertDefaultScaffoldSection(identity, file, before, heading)
212
+ if (!repaired) {
213
+ throw new Error(`section "${section}" was not found in ${file}; target an existing scaffold section`)
214
+ }
215
+ working = repaired
216
+ repairedMissingSection = true
217
+ lines = working.split(/\r?\n/)
218
+ bounds = findMarkdownSectionBounds(lines, heading)
219
+ }
220
+ if (!bounds) {
221
+ throw new Error(`section "${section}" was not found in ${file}; target an existing scaffold section`)
222
+ }
223
+ const { start, end: insertAt } = bounds
224
+ const prefix = lines.slice(0, insertAt).join('\n').replace(/\s+$/g, '')
225
+ const suffix = insertAt >= lines.length ? '' : lines.slice(insertAt).join('\n').replace(/^\s+/g, '')
226
+ const normalizedAppend = ensureTrailingNewline(appendText.trim())
227
+ const after = ensureTrailingNewline(suffix
228
+ ? `${prefix}\n${normalizedAppend}\n${suffix}`
229
+ : `${prefix}\n${normalizedAppend}`)
230
+ const afterLines = after.split(/\r?\n/)
231
+ const afterBounds = findMarkdownSectionBounds(afterLines, heading)
232
+ return {
233
+ before,
234
+ after,
235
+ summary: repairedMissingSection
236
+ ? `repair ${heading} section and append to ${heading} in ${file}`
237
+ : `append to ${heading} in ${file}`,
238
+ previewBefore: repairedMissingSection
239
+ ? `section "${heading}" was missing in ${file}; approval will add the scaffold section before appending.`
240
+ : previewText(sectionPreview(lines, start, insertAt)),
241
+ previewAfter: previewText(afterBounds
242
+ ? sectionPreview(afterLines, afterBounds.start, afterBounds.end)
243
+ : normalizedAppend),
244
+ }
245
+ }
246
+
247
+ function insertDefaultScaffoldSection(
248
+ identity: EthagentIdentity,
249
+ file: PrivateContinuityFile,
250
+ before: string,
251
+ heading: string,
252
+ ): string | null {
253
+ const defaults = defaultContinuityFiles(identity)[file]
254
+ const defaultSection = extractMarkdownSection(defaults, heading)
255
+ if (!defaultSection) return null
256
+
257
+ const defaultHeadings = markdownSectionHeadings(defaults)
258
+ const targetIndex = defaultHeadings.indexOf(heading)
259
+ if (targetIndex === -1) return null
260
+
261
+ const lines = before.split(/\r?\n/)
262
+ const followingHeadings = new Set(defaultHeadings.slice(targetIndex + 1))
263
+ const followingIndex = lines.findIndex(line => followingHeadings.has(normalizeSectionHeading(line)))
264
+ if (followingIndex !== -1) {
265
+ return insertSectionAtLine(before, followingIndex, defaultSection)
266
+ }
267
+
268
+ const previousHeadings = new Set(defaultHeadings.slice(0, targetIndex))
269
+ let insertAfterPrevious: number | null = null
270
+ for (let index = 0; index < lines.length; index += 1) {
271
+ if (!previousHeadings.has(normalizeSectionHeading(lines[index] ?? ''))) continue
272
+ const bounds = findMarkdownSectionBounds(lines, normalizeSectionHeading(lines[index] ?? ''))
273
+ if (bounds && bounds.end > (insertAfterPrevious ?? -1)) insertAfterPrevious = bounds.end
274
+ }
275
+ if (insertAfterPrevious !== null) {
276
+ return insertSectionAtLine(before, insertAfterPrevious, defaultSection)
277
+ }
278
+
279
+ const firstHeading = lines.findIndex(line => /^#\s+/.test(line.trim()))
280
+ return insertSectionAtLine(before, firstHeading === -1 ? 0 : firstHeading + 1, defaultSection)
281
+ }
282
+
283
+ function insertSectionAtLine(markdown: string, lineIndex: number, section: string): string {
284
+ const lines = markdown.split(/\r?\n/)
285
+ const before = lines.slice(0, lineIndex).join('\n').replace(/\s+$/g, '')
286
+ const after = lines.slice(lineIndex).join('\n').replace(/^\s+/g, '')
287
+ const block = section.trim()
288
+ if (!before) return ensureTrailingNewline(after ? `${block}\n\n${after}` : block)
289
+ return ensureTrailingNewline(after ? `${before}\n\n${block}\n\n${after}` : `${before}\n\n${block}`)
290
+ }
291
+
292
+ function extractMarkdownSection(markdown: string, heading: string): string | null {
293
+ const lines = markdown.split(/\r?\n/)
294
+ const bounds = findMarkdownSectionBounds(lines, heading)
295
+ if (!bounds) return null
296
+ return lines.slice(bounds.start, bounds.end).join('\n').trim()
297
+ }
298
+
299
+ function markdownSectionHeadings(markdown: string): string[] {
300
+ return markdown
301
+ .split(/\r?\n/)
302
+ .filter(line => /^##\s+/.test(line.trim()))
303
+ .map(normalizeSectionHeading)
304
+ }
305
+
306
+ function findMarkdownSectionBounds(lines: string[], heading: string): { start: number; end: number } | null {
307
+ const start = lines.findIndex(line => normalizeSectionHeading(line) === heading && /^#{1,6}\s+/.test(line.trim()))
308
+ if (start === -1) return null
309
+ const nextSection = lines.findIndex((line, index) => index > start && /^##\s+/.test(line.trim()))
310
+ return { start, end: nextSection === -1 ? lines.length : nextSection }
311
+ }
312
+
313
+ function normalizeSectionHeading(value: string): string {
314
+ return value.trim().replace(/^#+\s*/, '').trim()
315
+ }
316
+
317
+ function sectionPreview(lines: string[], start: number, end: number): string {
318
+ return lines.slice(start, Math.min(end, start + 8)).join('\n')
319
+ }
320
+
321
+ function markdownLines(value: string): string[] {
322
+ const lines = value.split(/\r?\n/)
323
+ if (lines[lines.length - 1] === '') lines.pop()
324
+ return lines
325
+ }
326
+
327
+ function ensureTrailingNewline(value: string): string {
328
+ return value.endsWith('\n') ? value : `${value}\n`
329
+ }
330
+
331
+ function previewText(text: string, max = 700): string {
332
+ if (text.length <= max) return text
333
+ return `${text.slice(0, max - 3)}...`
334
+ }
@@ -0,0 +1,173 @@
1
+ import type { EthagentIdentity } from '../../storage/config.js'
2
+
3
+ export type PublicSkill = {
4
+ id: string
5
+ name: string
6
+ description: string
7
+ inputModes: string[]
8
+ outputModes: string[]
9
+ }
10
+
11
+ export type PublicSkillsProfile = {
12
+ name: string
13
+ description: string
14
+ version: string
15
+ imageUrl?: string
16
+ skills: PublicSkill[]
17
+ }
18
+
19
+ export type AgentCard = {
20
+ name: string
21
+ description: string
22
+ version: string
23
+ protocolVersion: string
24
+ url: string
25
+ image?: string
26
+ iconUrl?: string
27
+ defaultInputModes: string[]
28
+ defaultOutputModes: string[]
29
+ capabilities: {
30
+ streaming: boolean
31
+ pushNotifications: boolean
32
+ }
33
+ skills: Array<{
34
+ id: string
35
+ name: string
36
+ description: string
37
+ inputModes: string[]
38
+ outputModes: string[]
39
+ }>
40
+ }
41
+
42
+ export function defaultPublicSkillsProfile(identity: EthagentIdentity): PublicSkillsProfile {
43
+ const state = identity.state ?? {}
44
+ const name = typeof state.name === 'string' && state.name.trim()
45
+ ? state.name.trim()
46
+ : identity.agentId ? `ethagent #${identity.agentId}` : 'ethagent'
47
+ const description = typeof state.description === 'string' && state.description.trim()
48
+ ? state.description.trim()
49
+ : 'A wallet-owned AI coding agent.'
50
+ const imageUrl = typeof state.imageUrl === 'string' && state.imageUrl.trim()
51
+ ? state.imageUrl.trim()
52
+ : undefined
53
+ return {
54
+ name,
55
+ description,
56
+ version: '1.0.0',
57
+ ...(imageUrl ? { imageUrl } : {}),
58
+ skills: [
59
+ {
60
+ id: 'software-engineering',
61
+ name: 'Software engineering',
62
+ description: 'Read code, plan implementations, debug failures, refactor safely, and run focused tests.',
63
+ inputModes: ['text/markdown'],
64
+ outputModes: ['text/markdown'],
65
+ },
66
+ {
67
+ id: 'workspace-tools',
68
+ name: 'Workspace tools',
69
+ description: 'Use permissioned local file, shell, clipboard, and MCP tools for project work.',
70
+ inputModes: ['text/markdown'],
71
+ outputModes: ['text/markdown', 'application/json'],
72
+ },
73
+ {
74
+ id: 'ethereum-identity',
75
+ name: 'Ethereum identity',
76
+ description: 'Represent a portable ERC-8004 agent identity controlled by the owner wallet.',
77
+ inputModes: ['text/markdown'],
78
+ outputModes: ['text/markdown', 'application/json'],
79
+ },
80
+ ],
81
+ }
82
+ }
83
+
84
+ export function renderPublicSkillsJson(profile: PublicSkillsProfile): string {
85
+ const inputModes = unique(profile.skills.flatMap(skill => skill.inputModes))
86
+ const outputModes = unique(profile.skills.flatMap(skill => skill.outputModes))
87
+ const summary = {
88
+ schema: 'ethagent.public-skills.v1',
89
+ visibility: 'public',
90
+ name: profile.name,
91
+ description: profile.description,
92
+ version: profile.version,
93
+ ...(profile.imageUrl ? { imageUrl: profile.imageUrl } : {}),
94
+ inputModes,
95
+ outputModes,
96
+ boundary: 'Public discovery metadata only. This is not executable code, private memory, or a skill installation manifest.',
97
+ capabilities: {
98
+ softwareEngineering: true,
99
+ workspaceTools: 'permissioned',
100
+ mcp: true,
101
+ streaming: true,
102
+ ethereumIdentity: 'ERC-8004',
103
+ encryptedContinuity: true,
104
+ },
105
+ delegation: {
106
+ bestFor: [
107
+ 'code reading',
108
+ 'implementation planning',
109
+ 'debugging',
110
+ 'refactors',
111
+ 'tests',
112
+ 'workspace automation',
113
+ ],
114
+ requiresApprovalFor: [
115
+ 'workspace edits',
116
+ 'shell commands',
117
+ 'private continuity changes',
118
+ ],
119
+ },
120
+ privacy: {
121
+ publicOnly: true,
122
+ includesPrivateMemory: false,
123
+ includesExecutableCode: false,
124
+ includesSecrets: false,
125
+ },
126
+ skills: profile.skills.map(skill => ({
127
+ id: skill.id,
128
+ name: skill.name,
129
+ description: skill.description,
130
+ inputModes: skill.inputModes,
131
+ outputModes: skill.outputModes,
132
+ })),
133
+ }
134
+ return `${JSON.stringify(summary, null, 2)}\n`
135
+ }
136
+
137
+ export function createAgentCard(profile: PublicSkillsProfile, url = 'ipfs://pending-agent-endpoint'): AgentCard {
138
+ const inputModes = unique(profile.skills.flatMap(skill => skill.inputModes))
139
+ const outputModes = unique(profile.skills.flatMap(skill => skill.outputModes))
140
+ return {
141
+ name: profile.name,
142
+ description: profile.description,
143
+ version: profile.version,
144
+ protocolVersion: '0.2.6',
145
+ url,
146
+ ...(profile.imageUrl ? { image: profile.imageUrl, iconUrl: profile.imageUrl } : {}),
147
+ defaultInputModes: inputModes.length ? inputModes : ['text/markdown'],
148
+ defaultOutputModes: outputModes.length ? outputModes : ['text/markdown'],
149
+ capabilities: {
150
+ streaming: true,
151
+ pushNotifications: false,
152
+ },
153
+ skills: profile.skills.map(skill => ({
154
+ id: skill.id,
155
+ name: skill.name,
156
+ description: skill.description,
157
+ inputModes: [...skill.inputModes],
158
+ outputModes: [...skill.outputModes],
159
+ })),
160
+ }
161
+ }
162
+
163
+ export function serializeAgentCard(card: AgentCard): string {
164
+ return `${JSON.stringify(card, null, 2)}\n`
165
+ }
166
+
167
+ function unique(values: string[]): string[] {
168
+ const out: string[] = []
169
+ for (const value of values) {
170
+ if (!out.includes(value)) out.push(value)
171
+ }
172
+ return out
173
+ }