ethagent 0.2.1 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -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,68 @@
1
+ import fs from 'node:fs/promises'
2
+
3
+ const RETRYABLE_RENAME_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
4
+ const RETRY_DELAYS_MS = [20, 60, 120]
5
+
6
+ type WriteOptions = {
7
+ mode?: number
8
+ }
9
+
10
+ export async function atomicWriteText(
11
+ file: string,
12
+ data: string,
13
+ options: WriteOptions = {},
14
+ ): Promise<void> {
15
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
16
+ const mode = options.mode ?? 0o600
17
+
18
+ await fs.writeFile(tmp, data, { encoding: 'utf8', mode })
19
+
20
+ try {
21
+ await replaceFileWithRetry(tmp, file)
22
+ } catch (error: unknown) {
23
+ await cleanupTempFile(tmp)
24
+ throw error
25
+ }
26
+ }
27
+
28
+ async function replaceFileWithRetry(tmp: string, file: string): Promise<void> {
29
+ let lastError: unknown
30
+
31
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
32
+ try {
33
+ await fs.rename(tmp, file)
34
+ return
35
+ } catch (error: unknown) {
36
+ lastError = error
37
+ const code = (error as NodeJS.ErrnoException).code
38
+ if (!code || !RETRYABLE_RENAME_CODES.has(code)) break
39
+ if (attempt === RETRY_DELAYS_MS.length) break
40
+
41
+ await sleep(RETRY_DELAYS_MS[attempt]!)
42
+
43
+ try {
44
+ await fs.copyFile(tmp, file)
45
+ await cleanupTempFile(tmp)
46
+ return
47
+ } catch (copyError: unknown) {
48
+ lastError = copyError
49
+ const copyCode = (copyError as NodeJS.ErrnoException).code
50
+ if (!copyCode || !RETRYABLE_RENAME_CODES.has(copyCode)) break
51
+ }
52
+ }
53
+ }
54
+
55
+ throw lastError
56
+ }
57
+
58
+ async function cleanupTempFile(file: string): Promise<void> {
59
+ try {
60
+ await fs.unlink(file)
61
+ } catch (error: unknown) {
62
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
63
+ }
64
+ }
65
+
66
+ async function sleep(ms: number): Promise<void> {
67
+ await new Promise(resolve => setTimeout(resolve, ms))
68
+ }
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import { z } from 'zod'
5
+ import { atomicWriteText } from './atomicWrite.js'
6
+
7
+ export const PROVIDERS = ['llamacpp', 'openai', 'anthropic', 'gemini'] as const
8
+ export type ProviderId = (typeof PROVIDERS)[number]
9
+ const LEGACY_PROVIDERS = ['ollama', ...PROVIDERS] as const
10
+
11
+ export const SELECTABLE_NETWORKS = ['mainnet', 'arbitrum', 'base', 'optimism', 'polygon'] as const
12
+ export type SelectableNetwork = (typeof SELECTABLE_NETWORKS)[number]
13
+
14
+ const IdentitySchema = z.object({
15
+ address: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
16
+ createdAt: z.string(),
17
+ source: z.enum(['local-key', 'erc8004']).optional(),
18
+ ownerAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/).optional(),
19
+ chainId: z.number().int().positive().optional(),
20
+ rpcUrl: z.string().url().optional(),
21
+ identityRegistryAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/).optional(),
22
+ agentId: z.string().min(1).optional(),
23
+ agentUri: z.string().min(1).optional(),
24
+ metadataCid: z.string().min(1).optional(),
25
+ state: z.record(z.unknown()).optional(),
26
+ backup: z.object({
27
+ cid: z.string().min(1),
28
+ createdAt: z.string(),
29
+ envelopeVersion: z.string().min(1),
30
+ ipfsApiUrl: z.string().url(),
31
+ status: z.enum(['pinned', 'restored', 'failed', 'unknown']),
32
+ ownerAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/).optional(),
33
+ chainId: z.number().int().positive().optional(),
34
+ rpcUrl: z.string().url().optional(),
35
+ identityRegistryAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/).optional(),
36
+ agentId: z.string().min(1).optional(),
37
+ agentUri: z.string().min(1).optional(),
38
+ metadataCid: z.string().min(1).optional(),
39
+ txHash: z.string().regex(/^0x[a-fA-F0-9]+$/).optional(),
40
+ pastBackups: z.array(z.object({
41
+ cid: z.string().min(1),
42
+ createdAt: z.string(),
43
+ })).optional(),
44
+ }).optional(),
45
+ publicSkills: z.object({
46
+ cid: z.string().min(1).optional(),
47
+ agentCardCid: z.string().min(1).optional(),
48
+ updatedAt: z.string().optional(),
49
+ status: z.enum(['pinned', 'failed', 'unknown']).optional(),
50
+ }).optional(),
51
+ })
52
+
53
+ const ConfigSchema = z.object({
54
+ version: z.literal(1),
55
+ provider: z.enum(PROVIDERS),
56
+ model: z.string().min(1),
57
+ baseUrl: z.string().url().optional(),
58
+ firstRunAt: z.string(),
59
+ identity: IdentitySchema.optional(),
60
+ erc8004: z.object({
61
+ chainId: z.number().int().positive(),
62
+ rpcUrl: z.string().url(),
63
+ identityRegistryAddress: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
64
+ fromBlock: z.string().regex(/^\d+$/).optional(),
65
+ }).optional(),
66
+ selectedNetwork: z.enum(SELECTABLE_NETWORKS).optional(),
67
+ })
68
+
69
+ const LEGACY_OLLAMA_BASE_URL = 'http://localhost:11434/v1'
70
+ const LegacyConfigSchema = ConfigSchema.extend({
71
+ provider: z.enum(LEGACY_PROVIDERS),
72
+ })
73
+
74
+ type LegacyConfig = z.infer<typeof LegacyConfigSchema>
75
+
76
+ export type EthagentIdentity = z.infer<typeof IdentitySchema>
77
+
78
+ export type EthagentConfig = z.infer<typeof ConfigSchema>
79
+
80
+ export function getConfigDir(): string {
81
+ return path.join(os.homedir(), '.ethagent')
82
+ }
83
+
84
+ export function getConfigPath(): string {
85
+ return path.join(getConfigDir(), 'config.json')
86
+ }
87
+
88
+ export async function ensureConfigDir(): Promise<void> {
89
+ await fs.mkdir(getConfigDir(), { recursive: true })
90
+ }
91
+
92
+ export async function loadConfig(): Promise<EthagentConfig | null> {
93
+ const file = getConfigPath()
94
+ let raw: string
95
+ try {
96
+ raw = await fs.readFile(file, 'utf8')
97
+ } catch (err: unknown) {
98
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null
99
+ throw err
100
+ }
101
+ try {
102
+ const parsed = JSON.parse(raw)
103
+ const active = ConfigSchema.safeParse(parsed)
104
+ if (active.success) return normalizeConfig(active.data)
105
+ const legacy = LegacyConfigSchema.safeParse(parsed)
106
+ if (legacy.success) return migrateLegacyConfig(legacy.data)
107
+ return null
108
+ } catch {
109
+ return null
110
+ }
111
+ }
112
+
113
+ export async function saveConfig(config: EthagentConfig): Promise<void> {
114
+ await ensureConfigDir()
115
+ const validated = ConfigSchema.parse(normalizeConfig(config))
116
+ const file = getConfigPath()
117
+ await atomicWriteText(file, JSON.stringify(validated, null, 2) + '\n')
118
+ }
119
+
120
+ export async function deleteConfig(): Promise<void> {
121
+ try {
122
+ await fs.unlink(getConfigPath())
123
+ } catch (err: unknown) {
124
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
125
+ }
126
+ }
127
+
128
+ export function defaultModelFor(provider: ProviderId): string {
129
+ switch (provider) {
130
+ case 'openai': return 'gpt-5.2'
131
+ case 'anthropic': return 'claude-sonnet-4-5'
132
+ case 'gemini': return 'gemini-2.0-flash'
133
+ case 'llamacpp': return 'huggingface-link'
134
+ }
135
+ }
136
+
137
+ export function defaultBaseUrlFor(provider: ProviderId): string | undefined {
138
+ if (provider === 'llamacpp') return 'http://localhost:8080/v1'
139
+ return undefined
140
+ }
141
+
142
+ export type LocalProviderId = Extract<ProviderId, 'llamacpp'>
143
+
144
+ export function localProviderBaseUrlFor(provider: LocalProviderId, baseUrl?: string): string {
145
+ const fallback = defaultBaseUrlFor(provider) ?? ''
146
+ if (!baseUrl) return fallback
147
+ return isDefaultBaseUrlFor(baseUrl, LEGACY_OLLAMA_BASE_URL) ? fallback : baseUrl
148
+ }
149
+
150
+ export function normalizeConfig(config: EthagentConfig): EthagentConfig {
151
+ if (config.provider !== 'llamacpp') return config
152
+ const baseUrl = localProviderBaseUrlFor(config.provider, config.baseUrl)
153
+ return config.baseUrl === baseUrl ? config : { ...config, baseUrl }
154
+ }
155
+
156
+ function migrateLegacyConfig(config: LegacyConfig): EthagentConfig {
157
+ if (config.provider !== 'ollama') return normalizeConfig(ConfigSchema.parse(config))
158
+ return {
159
+ ...config,
160
+ provider: 'llamacpp',
161
+ model: defaultModelFor('llamacpp'),
162
+ baseUrl: defaultBaseUrlFor('llamacpp'),
163
+ }
164
+ }
165
+
166
+ function isDefaultBaseUrlFor(value: string, fallback: string | undefined): boolean {
167
+ if (!fallback) return false
168
+ try {
169
+ const url = new URL(value)
170
+ const defaultUrl = new URL(fallback)
171
+ if (url.protocol !== defaultUrl.protocol) return false
172
+ if (url.hostname.toLowerCase() !== defaultUrl.hostname.toLowerCase()) return false
173
+ if (effectivePort(url) !== effectivePort(defaultUrl)) return false
174
+ const path = stripTrailingSlash(url.pathname) || '/'
175
+ const defaultPath = stripTrailingSlash(defaultUrl.pathname) || '/'
176
+ return path === '/' || path === defaultPath
177
+ } catch {
178
+ return stripTrailingSlash(value) === stripTrailingSlash(fallback)
179
+ }
180
+ }
181
+
182
+ function effectivePort(url: URL): string {
183
+ if (url.port) return url.port
184
+ return url.protocol === 'https:' ? '443' : '80'
185
+ }
186
+
187
+ function stripTrailingSlash(value: string): string {
188
+ return value.replace(/\/+$/, '')
189
+ }
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { PROVIDERS, getConfigDir } from './config.js'
4
+ import { rmSecret } from './secrets.js'
5
+
6
+ const PRESERVED_LOCAL_MODEL_ENTRIES = new Set([
7
+ 'local-models.json',
8
+ 'local-runner.json',
9
+ 'models',
10
+ 'runners',
11
+ ])
12
+
13
+ const SECRET_ACCOUNTS = [
14
+ 'ethereum:default',
15
+ 'pinata:jwt',
16
+ ...PROVIDERS,
17
+ ] as const
18
+
19
+ export type FactoryResetPlan = {
20
+ configDir: string
21
+ deletePaths: string[]
22
+ preservedPaths: string[]
23
+ preservedDescriptions: string[]
24
+ remoteDescriptions: string[]
25
+ }
26
+
27
+ export type FactoryResetResult = {
28
+ deletedPaths: string[]
29
+ preservedPaths: string[]
30
+ clearedSecretAccounts: string[]
31
+ }
32
+
33
+ export async function createFactoryResetPlan(): Promise<FactoryResetPlan> {
34
+ const configDir = path.resolve(getConfigDir())
35
+ const entries = await readConfigEntries(configDir)
36
+ const deletePaths: string[] = []
37
+ const preservedPaths: string[] = []
38
+
39
+ for (const entry of entries) {
40
+ const target = path.join(configDir, entry)
41
+ assertInsideConfigDir(configDir, target)
42
+ if (PRESERVED_LOCAL_MODEL_ENTRIES.has(entry)) preservedPaths.push(target)
43
+ else deletePaths.push(target)
44
+ }
45
+
46
+ return {
47
+ configDir,
48
+ deletePaths,
49
+ preservedPaths,
50
+ preservedDescriptions: [
51
+ 'Hugging Face GGUF model files under models/',
52
+ 'local model registry local-models.json',
53
+ 'llama.cpp runner assets under runners/',
54
+ 'local runner path config local-runner.json',
55
+ 'external package-installed runtimes outside ~/.ethagent',
56
+ ],
57
+ remoteDescriptions: [
58
+ 'ERC-8004 tokens and onchain records',
59
+ 'IPFS-pinned encrypted snapshots and public skills metadata',
60
+ ],
61
+ }
62
+ }
63
+
64
+ export async function runFactoryReset(options: { clearSecrets?: boolean } = {}): Promise<FactoryResetResult> {
65
+ const plan = await createFactoryResetPlan()
66
+ const clearedSecretAccounts: string[] = []
67
+ if (options.clearSecrets ?? true) {
68
+ for (const account of SECRET_ACCOUNTS) {
69
+ try {
70
+ await rmSecret(account)
71
+ clearedSecretAccounts.push(account)
72
+ } catch {
73
+ continue
74
+ }
75
+ }
76
+ }
77
+
78
+ const deletedPaths: string[] = []
79
+ for (const target of plan.deletePaths) {
80
+ assertInsideConfigDir(plan.configDir, target)
81
+ await fs.rm(target, { recursive: true, force: true })
82
+ deletedPaths.push(target)
83
+ }
84
+
85
+ return {
86
+ deletedPaths,
87
+ preservedPaths: plan.preservedPaths,
88
+ clearedSecretAccounts,
89
+ }
90
+ }
91
+
92
+ export function formatFactoryResetPlan(plan: FactoryResetPlan): string {
93
+ return [
94
+ 'ethagent reset',
95
+ '',
96
+ 'will delete:',
97
+ ...formatPaths(plan.deletePaths, plan.configDir),
98
+ '',
99
+ 'will keep:',
100
+ ...plan.preservedDescriptions.map(item => ` · ${item}`),
101
+ '',
102
+ 'not touched:',
103
+ ...plan.remoteDescriptions.map(item => ` · ${item}`),
104
+ '',
105
+ 'type confirm to reset this machine.',
106
+ ].join('\n')
107
+ }
108
+
109
+ async function readConfigEntries(configDir: string): Promise<string[]> {
110
+ try {
111
+ return await fs.readdir(configDir)
112
+ } catch (err: unknown) {
113
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
114
+ throw err
115
+ }
116
+ }
117
+
118
+ function formatPaths(paths: string[], configDir: string): string[] {
119
+ if (paths.length === 0) return [' · no local ethagent data found']
120
+ return paths
121
+ .map(target => ` · ${path.relative(configDir, target) || path.basename(target)}`)
122
+ .sort()
123
+ }
124
+
125
+ function assertInsideConfigDir(configDir: string, target: string): void {
126
+ const relative = path.relative(path.resolve(configDir), path.resolve(target))
127
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
128
+ throw new Error(`refusing to reset path outside ethagent config: ${target}`)
129
+ }
130
+ }
@@ -0,0 +1,58 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { getConfigDir } from './config.js'
4
+
5
+ const MAX_ENTRIES = 500
6
+
7
+ export function getHistoryPath(): string {
8
+ return path.join(getConfigDir(), 'history.jsonl')
9
+ }
10
+
11
+ type Entry = { text: string; ts: number }
12
+
13
+ export async function appendHistory(text: string): Promise<void> {
14
+ const clean = text.trim()
15
+ if (!clean) return
16
+ try {
17
+ await fs.mkdir(getConfigDir(), { recursive: true })
18
+ } catch {
19
+ return
20
+ }
21
+ const line = JSON.stringify({ text: clean, ts: Date.now() } satisfies Entry) + '\n'
22
+ try {
23
+ await fs.appendFile(getHistoryPath(), line, { mode: 0o600 })
24
+ } catch {
25
+ return
26
+ }
27
+ }
28
+
29
+ export async function readHistory(): Promise<string[]> {
30
+ let raw: string
31
+ try {
32
+ raw = await fs.readFile(getHistoryPath(), 'utf8')
33
+ } catch (err: unknown) {
34
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
35
+ throw err
36
+ }
37
+ const entries: Entry[] = []
38
+ for (const line of raw.split('\n')) {
39
+ const trimmed = line.trim()
40
+ if (!trimmed) continue
41
+ try {
42
+ entries.push(JSON.parse(trimmed) as Entry)
43
+ } catch {
44
+ continue
45
+ }
46
+ }
47
+ const seen = new Set<string>()
48
+ const out: string[] = []
49
+ for (let i = entries.length - 1; i >= 0; i--) {
50
+ const entry = entries[i]
51
+ if (!entry) continue
52
+ if (seen.has(entry.text)) continue
53
+ seen.add(entry.text)
54
+ out.unshift(entry.text)
55
+ if (out.length >= MAX_ENTRIES) break
56
+ }
57
+ return out
58
+ }
@@ -0,0 +1,99 @@
1
+ import {
2
+ loadConfig,
3
+ saveConfig,
4
+ type EthagentConfig,
5
+ type EthagentIdentity,
6
+ } from './config.js'
7
+ import {
8
+ getSecret,
9
+ rmSecret,
10
+ hasSecret,
11
+ whichBackend,
12
+ type KeyBackend,
13
+ } from './secrets.js'
14
+
15
+ const IDENTITY_ACCOUNT = 'ethereum:default'
16
+
17
+ export type IdentityStatus = {
18
+ address: string
19
+ createdAt: string
20
+ backend: KeyBackend | 'browser-wallet'
21
+ backup?: EthagentIdentity['backup']
22
+ publicSkills?: EthagentIdentity['publicSkills']
23
+ source?: EthagentIdentity['source']
24
+ agentId?: string
25
+ chainId?: number
26
+ } | null
27
+
28
+ export async function getIdentityStatus(config?: EthagentConfig): Promise<IdentityStatus> {
29
+ const resolved = config ?? (await loadConfig())
30
+ if (!resolved?.identity) return null
31
+ if (resolved.identity.source === 'erc8004' || resolved.identity.agentId) {
32
+ return {
33
+ address: resolved.identity.address,
34
+ createdAt: resolved.identity.createdAt,
35
+ backend: 'browser-wallet',
36
+ backup: resolved.identity.backup,
37
+ publicSkills: resolved.identity.publicSkills,
38
+ source: resolved.identity.source,
39
+ agentId: resolved.identity.agentId,
40
+ chainId: resolved.identity.chainId,
41
+ }
42
+ }
43
+ const present = await hasSecret(IDENTITY_ACCOUNT)
44
+ if (!present) return null
45
+ const backend = await whichBackend()
46
+ return {
47
+ address: resolved.identity.address,
48
+ createdAt: resolved.identity.createdAt,
49
+ backend,
50
+ backup: resolved.identity.backup,
51
+ publicSkills: resolved.identity.publicSkills,
52
+ source: resolved.identity.source,
53
+ chainId: resolved.identity.chainId,
54
+ }
55
+ }
56
+
57
+ export async function setTokenIdentity(
58
+ config: EthagentConfig,
59
+ identity: EthagentIdentity,
60
+ ): Promise<EthagentConfig> {
61
+ if (!identity.address || !identity.agentId || !identity.agentUri || !identity.ownerAddress) {
62
+ throw new Error('token identity is missing ERC-8004 metadata')
63
+ }
64
+ const next: EthagentConfig = {
65
+ ...config,
66
+ identity: {
67
+ ...identity,
68
+ source: 'erc8004',
69
+ },
70
+ }
71
+ await saveConfig(next)
72
+ return next
73
+ }
74
+
75
+ export async function updateIdentityBackup(
76
+ config: EthagentConfig,
77
+ backup: NonNullable<EthagentIdentity['backup']>,
78
+ ): Promise<EthagentConfig> {
79
+ if (!config.identity) throw new Error('no identity set')
80
+ const next: EthagentConfig = {
81
+ ...config,
82
+ identity: {
83
+ ...config.identity,
84
+ backup,
85
+ },
86
+ }
87
+ await saveConfig(next)
88
+ return next
89
+ }
90
+
91
+ export async function clearIdentity(config: EthagentConfig): Promise<EthagentConfig> {
92
+ await rmSecret(IDENTITY_ACCOUNT)
93
+ if (!config.identity) return config
94
+ const { identity: _drop, ...rest } = config
95
+ void _drop
96
+ const next = { ...rest } as EthagentConfig
97
+ await saveConfig(next)
98
+ return next
99
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { z } from 'zod'
4
+ import { ensureConfigDir, getConfigDir } from './config.js'
5
+ import { SessionPermissionRuleSchema, type SessionPermissionRule } from '../tools/contracts.js'
6
+ import { atomicWriteText } from './atomicWrite.js'
7
+
8
+ const StoredPermissionRuleSchema = z.object({
9
+ workspaceRoot: z.string().min(1),
10
+ rule: SessionPermissionRuleSchema,
11
+ })
12
+
13
+ type StoredPermissionRule = z.infer<typeof StoredPermissionRuleSchema>
14
+
15
+ function getPermissionsPath(): string {
16
+ return path.join(getConfigDir(), 'permissions.json')
17
+ }
18
+
19
+ export async function loadPermissionRules(workspaceRoot: string): Promise<SessionPermissionRule[]> {
20
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
21
+ const allRules = await loadAllPermissionRules()
22
+ return allRules
23
+ .filter(entry => path.resolve(entry.workspaceRoot) === normalizedWorkspaceRoot)
24
+ .map(entry => entry.rule)
25
+ }
26
+
27
+ export async function deletePermissionRule(workspaceRoot: string, rule: SessionPermissionRule): Promise<void> {
28
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
29
+ const allRules = await loadAllPermissionRules()
30
+ const next = allRules.filter(entry =>
31
+ !(path.resolve(entry.workspaceRoot) === normalizedWorkspaceRoot && JSON.stringify(entry.rule) === JSON.stringify(rule)),
32
+ )
33
+ await writeAllPermissionRules(next)
34
+ }
35
+
36
+ export async function clearPermissionRules(workspaceRoot: string): Promise<void> {
37
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
38
+ const allRules = await loadAllPermissionRules()
39
+ const next = allRules.filter(entry => path.resolve(entry.workspaceRoot) !== normalizedWorkspaceRoot)
40
+ await writeAllPermissionRules(next)
41
+ }
42
+
43
+ export async function savePermissionRule(workspaceRoot: string, rule: SessionPermissionRule): Promise<void> {
44
+ const allRules = await loadAllPermissionRules()
45
+ const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
46
+ const nextEntry: StoredPermissionRule = { workspaceRoot: normalizedWorkspaceRoot, rule }
47
+ const deduped = [...allRules.filter(entry => !sameStoredRule(entry, nextEntry)), nextEntry]
48
+ await writeAllPermissionRules(deduped)
49
+ }
50
+
51
+ async function loadAllPermissionRules(): Promise<StoredPermissionRule[]> {
52
+ let raw: string
53
+ try {
54
+ raw = await fs.readFile(getPermissionsPath(), 'utf8')
55
+ } catch (error: unknown) {
56
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
57
+ throw error
58
+ }
59
+
60
+ try {
61
+ const parsed = JSON.parse(raw)
62
+ return z.array(StoredPermissionRuleSchema).parse(parsed)
63
+ } catch {
64
+ return []
65
+ }
66
+ }
67
+
68
+ async function writeAllPermissionRules(rules: StoredPermissionRule[]): Promise<void> {
69
+ await ensureConfigDir()
70
+ const file = getPermissionsPath()
71
+ await atomicWriteText(file, JSON.stringify(rules, null, 2) + '\n')
72
+ }
73
+
74
+ function sameStoredRule(left: StoredPermissionRule, right: StoredPermissionRule): boolean {
75
+ return left.workspaceRoot === right.workspaceRoot && JSON.stringify(left.rule) === JSON.stringify(right.rule)
76
+ }