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.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|