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,246 @@
|
|
|
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 { atomicWriteText } from './atomicWrite.js'
|
|
6
|
+
|
|
7
|
+
const RewindSnapshotSchema = z.object({
|
|
8
|
+
id: z.string().optional(),
|
|
9
|
+
workspaceRoot: z.string().min(1),
|
|
10
|
+
filePath: z.string().min(1),
|
|
11
|
+
relativePath: z.string().optional(),
|
|
12
|
+
existedBefore: z.boolean(),
|
|
13
|
+
previousContent: z.string(),
|
|
14
|
+
changeSummary: z.string().optional(),
|
|
15
|
+
createdAt: z.string(),
|
|
16
|
+
sessionId: z.string().optional(),
|
|
17
|
+
turnId: z.string().optional(),
|
|
18
|
+
messageRole: z.literal('user').optional(),
|
|
19
|
+
promptSnippet: z.string().optional(),
|
|
20
|
+
checkpointLabel: z.string().optional(),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
type RewindSnapshot = z.infer<typeof RewindSnapshotSchema>
|
|
24
|
+
export type RewindEntry = {
|
|
25
|
+
id: string
|
|
26
|
+
workspaceRoot: string
|
|
27
|
+
filePath: string
|
|
28
|
+
relativePath: string
|
|
29
|
+
existedBefore: boolean
|
|
30
|
+
previousContent: string
|
|
31
|
+
changeSummary: string
|
|
32
|
+
createdAt: string
|
|
33
|
+
sessionId?: string
|
|
34
|
+
turnId?: string
|
|
35
|
+
messageRole?: 'user'
|
|
36
|
+
promptSnippet: string
|
|
37
|
+
checkpointLabel: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ListRewindEntriesOptions = {
|
|
41
|
+
limit?: number
|
|
42
|
+
offset?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getRewindPath(): string {
|
|
46
|
+
return path.join(getConfigDir(), 'rewind.jsonl')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function recordRewindSnapshot(snapshot: RewindSnapshot): Promise<void> {
|
|
50
|
+
await ensureConfigDir()
|
|
51
|
+
const normalized = normalizeSnapshot(snapshot)
|
|
52
|
+
if (isIdentityMarkdownSnapshot(normalized)) return
|
|
53
|
+
await fs.appendFile(getRewindPath(), `${JSON.stringify(normalized)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function rewindWorkspaceEdits(
|
|
57
|
+
workspaceRoot: string,
|
|
58
|
+
steps = 1,
|
|
59
|
+
): Promise<{ reverted: number; files: string[] }> {
|
|
60
|
+
const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
61
|
+
const snapshots = await loadSnapshots()
|
|
62
|
+
const candidates = snapshots
|
|
63
|
+
.map((snapshot, index) => ({ snapshot, index }))
|
|
64
|
+
.filter(entry =>
|
|
65
|
+
path.resolve(entry.snapshot.workspaceRoot) === normalizedWorkspaceRoot &&
|
|
66
|
+
!isIdentityMarkdownSnapshot(entry.snapshot),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if (candidates.length === 0) return { reverted: 0, files: [] }
|
|
70
|
+
|
|
71
|
+
const selected = candidates.slice(Math.max(0, candidates.length - steps))
|
|
72
|
+
const revertedFiles: string[] = []
|
|
73
|
+
|
|
74
|
+
for (let index = selected.length - 1; index >= 0; index -= 1) {
|
|
75
|
+
const snapshot = selected[index]!.snapshot
|
|
76
|
+
if (snapshot.existedBefore) {
|
|
77
|
+
await fs.mkdir(path.dirname(snapshot.filePath), { recursive: true })
|
|
78
|
+
await fs.writeFile(snapshot.filePath, snapshot.previousContent, 'utf8')
|
|
79
|
+
} else {
|
|
80
|
+
try {
|
|
81
|
+
await fs.unlink(snapshot.filePath)
|
|
82
|
+
} catch (error: unknown) {
|
|
83
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
revertedFiles.push(snapshot.filePath)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const selectedIndexes = new Set(selected.map(entry => entry.index))
|
|
90
|
+
const remaining = snapshots.filter((_snapshot, index) => !selectedIndexes.has(index))
|
|
91
|
+
await writeSnapshots(remaining)
|
|
92
|
+
|
|
93
|
+
return { reverted: selected.length, files: revertedFiles }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function loadSnapshots(): Promise<RewindSnapshot[]> {
|
|
97
|
+
let raw: string
|
|
98
|
+
try {
|
|
99
|
+
raw = await fs.readFile(getRewindPath(), 'utf8')
|
|
100
|
+
} catch (error: unknown) {
|
|
101
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const out: RewindSnapshot[] = []
|
|
106
|
+
for (const line of raw.split('\n')) {
|
|
107
|
+
const trimmed = line.trim()
|
|
108
|
+
if (!trimmed) continue
|
|
109
|
+
try {
|
|
110
|
+
out.push(normalizeSnapshot(RewindSnapshotSchema.parse(JSON.parse(trimmed))))
|
|
111
|
+
} catch {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function listRewindEntries(
|
|
119
|
+
workspaceRoot: string,
|
|
120
|
+
options: ListRewindEntriesOptions = {},
|
|
121
|
+
): Promise<RewindEntry[]> {
|
|
122
|
+
const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
123
|
+
const limit = options.limit ?? 30
|
|
124
|
+
const offset = options.offset ?? 0
|
|
125
|
+
const snapshots = await loadSnapshots()
|
|
126
|
+
return snapshots
|
|
127
|
+
.filter(snapshot => !isIdentityMarkdownSnapshot(snapshot))
|
|
128
|
+
.filter(snapshot => isSnapshotWithinScope(snapshot, normalizedWorkspaceRoot))
|
|
129
|
+
.map(snapshot => toEntry(snapshot))
|
|
130
|
+
.reverse()
|
|
131
|
+
.slice(offset, offset + limit)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function rewindWorkspaceEditsByEntryIds(
|
|
135
|
+
workspaceRoot: string,
|
|
136
|
+
entryIds: string[],
|
|
137
|
+
): Promise<{ reverted: number; files: string[] }> {
|
|
138
|
+
const normalizedWorkspaceRoot = path.resolve(workspaceRoot)
|
|
139
|
+
const selectedIds = new Set(entryIds)
|
|
140
|
+
const snapshots = await loadSnapshots()
|
|
141
|
+
const selected = snapshots
|
|
142
|
+
.map((snapshot, index) => ({ snapshot, index }))
|
|
143
|
+
.filter(entry =>
|
|
144
|
+
path.resolve(entry.snapshot.workspaceRoot) === normalizedWorkspaceRoot &&
|
|
145
|
+
!isIdentityMarkdownSnapshot(entry.snapshot) &&
|
|
146
|
+
selectedIds.has(entry.snapshot.id!),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if (selected.length === 0) return { reverted: 0, files: [] }
|
|
150
|
+
|
|
151
|
+
const revertedFiles: string[] = []
|
|
152
|
+
for (let index = selected.length - 1; index >= 0; index -= 1) {
|
|
153
|
+
const snapshot = selected[index]!.snapshot
|
|
154
|
+
if (snapshot.existedBefore) {
|
|
155
|
+
await fs.mkdir(path.dirname(snapshot.filePath), { recursive: true })
|
|
156
|
+
await fs.writeFile(snapshot.filePath, snapshot.previousContent, 'utf8')
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
await fs.unlink(snapshot.filePath)
|
|
160
|
+
} catch (error: unknown) {
|
|
161
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
revertedFiles.push(snapshot.filePath)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const selectedIndexes = new Set(selected.map(entry => entry.index))
|
|
168
|
+
const remaining = snapshots.filter((_snapshot, index) => !selectedIndexes.has(index))
|
|
169
|
+
await writeSnapshots(remaining)
|
|
170
|
+
|
|
171
|
+
return { reverted: selected.length, files: revertedFiles }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function writeSnapshots(snapshots: RewindSnapshot[]): Promise<void> {
|
|
175
|
+
await ensureConfigDir()
|
|
176
|
+
const file = getRewindPath()
|
|
177
|
+
const body = snapshots.map(snapshot => JSON.stringify(snapshot)).join('\n')
|
|
178
|
+
await atomicWriteText(file, body ? `${body}\n` : '')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeSnapshot(snapshot: RewindSnapshot): RewindSnapshot {
|
|
182
|
+
const workspaceRoot = path.resolve(snapshot.workspaceRoot)
|
|
183
|
+
const filePath = path.resolve(snapshot.filePath)
|
|
184
|
+
return {
|
|
185
|
+
...snapshot,
|
|
186
|
+
id: snapshot.id ?? stableSnapshotId(workspaceRoot, filePath, snapshot.createdAt),
|
|
187
|
+
workspaceRoot,
|
|
188
|
+
filePath,
|
|
189
|
+
relativePath: snapshot.relativePath ?? (path.relative(workspaceRoot, filePath) || path.basename(filePath)),
|
|
190
|
+
changeSummary: snapshot.changeSummary ?? (snapshot.existedBefore ? 'restore previous file contents' : 'remove created file'),
|
|
191
|
+
promptSnippet: normalizeSnippet(snapshot.promptSnippet),
|
|
192
|
+
checkpointLabel: normalizeSnippet(snapshot.checkpointLabel) || normalizeSnippet(snapshot.promptSnippet),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toEntry(snapshot: RewindSnapshot): RewindEntry {
|
|
197
|
+
return {
|
|
198
|
+
id: snapshot.id!,
|
|
199
|
+
workspaceRoot: snapshot.workspaceRoot,
|
|
200
|
+
filePath: snapshot.filePath,
|
|
201
|
+
relativePath: snapshot.relativePath!,
|
|
202
|
+
existedBefore: snapshot.existedBefore,
|
|
203
|
+
previousContent: snapshot.previousContent,
|
|
204
|
+
changeSummary: snapshot.changeSummary!,
|
|
205
|
+
createdAt: snapshot.createdAt,
|
|
206
|
+
sessionId: snapshot.sessionId,
|
|
207
|
+
turnId: snapshot.turnId,
|
|
208
|
+
messageRole: snapshot.messageRole,
|
|
209
|
+
promptSnippet: snapshot.promptSnippet ?? '',
|
|
210
|
+
checkpointLabel: snapshot.checkpointLabel ?? snapshot.promptSnippet ?? 'Untitled checkpoint',
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stableSnapshotId(workspaceRoot: string, filePath: string, createdAt: string): string {
|
|
215
|
+
const rel = path.relative(workspaceRoot, filePath) || path.basename(filePath)
|
|
216
|
+
return `${createdAt}:${rel}`.replaceAll('\\', '/')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isSnapshotWithinScope(snapshot: RewindSnapshot, scopeRoot: string): boolean {
|
|
220
|
+
if (path.resolve(snapshot.workspaceRoot) === scopeRoot) return true
|
|
221
|
+
const relative = path.relative(scopeRoot, path.resolve(snapshot.filePath))
|
|
222
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isIdentityMarkdownSnapshot(snapshot: RewindSnapshot): boolean {
|
|
226
|
+
const relativePath = (snapshot.relativePath ?? '').replaceAll('\\', '/').toLowerCase()
|
|
227
|
+
const basename = path.basename(snapshot.filePath).toLowerCase()
|
|
228
|
+
if (relativePath.startsWith('identity-vault/') && IDENTITY_MARKDOWN_FILES.has(basename)) return true
|
|
229
|
+
|
|
230
|
+
const continuityRoot = path.resolve(getConfigDir(), 'continuity')
|
|
231
|
+
const relativeToContinuity = path.relative(continuityRoot, path.resolve(snapshot.filePath))
|
|
232
|
+
return (
|
|
233
|
+
relativeToContinuity !== '' &&
|
|
234
|
+
!relativeToContinuity.startsWith('..') &&
|
|
235
|
+
!path.isAbsolute(relativeToContinuity) &&
|
|
236
|
+
IDENTITY_MARKDOWN_FILES.has(basename)
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const IDENTITY_MARKDOWN_FILES = new Set(['soul.md', 'memory.md', 'skills.json'])
|
|
241
|
+
|
|
242
|
+
function normalizeSnippet(input?: string): string {
|
|
243
|
+
const normalized = (input ?? '').replace(/\s+/g, ' ').trim()
|
|
244
|
+
if (!normalized) return ''
|
|
245
|
+
return normalized.length <= 120 ? normalized : `${normalized.slice(0, 117)}...`
|
|
246
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { getConfigDir, ensureConfigDir, type ProviderId } from './config.js'
|
|
6
|
+
import { atomicWriteText } from './atomicWrite.js'
|
|
7
|
+
|
|
8
|
+
const KEYTAR_SERVICE = 'ethagent'
|
|
9
|
+
|
|
10
|
+
type Keytar = {
|
|
11
|
+
getPassword: (service: string, account: string) => Promise<string | null>
|
|
12
|
+
setPassword: (service: string, account: string, password: string) => Promise<void>
|
|
13
|
+
deletePassword: (service: string, account: string) => Promise<boolean>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let keytarCache: Keytar | null | undefined
|
|
17
|
+
|
|
18
|
+
async function loadKeytar(): Promise<Keytar | null> {
|
|
19
|
+
if (keytarCache !== undefined) return keytarCache
|
|
20
|
+
try {
|
|
21
|
+
const modulePath: string = 'keytar'
|
|
22
|
+
const mod = (await import(modulePath)) as { default?: Keytar } & Partial<Keytar>
|
|
23
|
+
const api = mod.default ?? (mod as Keytar)
|
|
24
|
+
if (typeof api.getPassword !== 'function'
|
|
25
|
+
|| typeof api.setPassword !== 'function'
|
|
26
|
+
|| typeof api.deletePassword !== 'function') {
|
|
27
|
+
throw new Error('keytar module shape unexpected')
|
|
28
|
+
}
|
|
29
|
+
await api.getPassword(KEYTAR_SERVICE, '__ethagent_probe__')
|
|
30
|
+
keytarCache = api
|
|
31
|
+
return api
|
|
32
|
+
} catch {
|
|
33
|
+
keytarCache = null
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function saltPath(): string { return path.join(getConfigDir(), '.salt') }
|
|
39
|
+
function keysPath(): string { return path.join(getConfigDir(), 'keys.enc') }
|
|
40
|
+
|
|
41
|
+
async function atomicWrite(file: string, data: string, mode = 0o600): Promise<void> {
|
|
42
|
+
await atomicWriteText(file, data, { mode })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadSalt(): Promise<Buffer> {
|
|
46
|
+
await ensureConfigDir()
|
|
47
|
+
try {
|
|
48
|
+
const b64 = await fs.readFile(saltPath(), 'utf8')
|
|
49
|
+
return Buffer.from(b64.trim(), 'base64')
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
52
|
+
const salt = crypto.randomBytes(32)
|
|
53
|
+
await atomicWrite(saltPath(), salt.toString('base64'))
|
|
54
|
+
return salt
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function deriveKey(): Promise<Buffer> {
|
|
59
|
+
const salt = await loadSalt()
|
|
60
|
+
const material = `${os.hostname()}::${os.userInfo().username}`
|
|
61
|
+
return await new Promise<Buffer>((resolve, reject) => {
|
|
62
|
+
crypto.scrypt(material, salt, 32, (err, key) => {
|
|
63
|
+
if (err) reject(err)
|
|
64
|
+
else resolve(key)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function readEncryptedFile(): Promise<Record<string, string>> {
|
|
70
|
+
try {
|
|
71
|
+
const raw = await fs.readFile(keysPath(), 'utf8')
|
|
72
|
+
const parsed = JSON.parse(raw) as unknown
|
|
73
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
74
|
+
const result: Record<string, string> = {}
|
|
75
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
76
|
+
if (typeof v === 'string') result[k] = v
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
return {}
|
|
81
|
+
} catch (err: unknown) {
|
|
82
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
|
83
|
+
return {}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function writeEncryptedFile(entries: Record<string, string>): Promise<void> {
|
|
88
|
+
await ensureConfigDir()
|
|
89
|
+
await atomicWrite(keysPath(), JSON.stringify(entries, null, 2))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function encryptValue(key: Buffer, plaintext: string): string {
|
|
93
|
+
const iv = crypto.randomBytes(12)
|
|
94
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
95
|
+
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
96
|
+
const authTag = cipher.getAuthTag()
|
|
97
|
+
return Buffer.concat([iv, authTag, enc]).toString('base64')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function decryptValue(key: Buffer, payload: string): string | null {
|
|
101
|
+
try {
|
|
102
|
+
const buf = Buffer.from(payload, 'base64')
|
|
103
|
+
if (buf.length < 12 + 16 + 1) return null
|
|
104
|
+
const iv = buf.subarray(0, 12)
|
|
105
|
+
const authTag = buf.subarray(12, 28)
|
|
106
|
+
const ct = buf.subarray(28)
|
|
107
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
|
108
|
+
decipher.setAuthTag(authTag)
|
|
109
|
+
const dec = Buffer.concat([decipher.update(ct), decipher.final()])
|
|
110
|
+
return dec.toString('utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type KeyBackend = 'keyring' | 'encrypted-file'
|
|
117
|
+
|
|
118
|
+
export async function getSecret(account: string): Promise<string | null> {
|
|
119
|
+
const keytar = await loadKeytar()
|
|
120
|
+
if (keytar) {
|
|
121
|
+
const v = await keytar.getPassword(KEYTAR_SERVICE, account)
|
|
122
|
+
return v ?? null
|
|
123
|
+
}
|
|
124
|
+
const file = await readEncryptedFile()
|
|
125
|
+
const payload = file[account]
|
|
126
|
+
if (!payload) return null
|
|
127
|
+
const key = await deriveKey()
|
|
128
|
+
return decryptValue(key, payload)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function setSecret(account: string, value: string): Promise<KeyBackend> {
|
|
132
|
+
const trimmed = value.trim()
|
|
133
|
+
if (!trimmed) throw new Error('secret value is empty')
|
|
134
|
+
const keytar = await loadKeytar()
|
|
135
|
+
if (keytar) {
|
|
136
|
+
await keytar.setPassword(KEYTAR_SERVICE, account, trimmed)
|
|
137
|
+
return 'keyring'
|
|
138
|
+
}
|
|
139
|
+
const [key, file] = await Promise.all([deriveKey(), readEncryptedFile()])
|
|
140
|
+
file[account] = encryptValue(key, trimmed)
|
|
141
|
+
await writeEncryptedFile(file)
|
|
142
|
+
return 'encrypted-file'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function rmSecret(account: string): Promise<void> {
|
|
146
|
+
const keytar = await loadKeytar()
|
|
147
|
+
if (keytar) {
|
|
148
|
+
await keytar.deletePassword(KEYTAR_SERVICE, account)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const file = await readEncryptedFile()
|
|
152
|
+
if (account in file) {
|
|
153
|
+
delete file[account]
|
|
154
|
+
await writeEncryptedFile(file)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function hasSecret(account: string): Promise<boolean> {
|
|
159
|
+
const value = await getSecret(account)
|
|
160
|
+
return value !== null && value.length > 0
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function whichBackend(): Promise<KeyBackend> {
|
|
164
|
+
return (await loadKeytar()) ? 'keyring' : 'encrypted-file'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getKey(provider: ProviderId): Promise<string | null> {
|
|
168
|
+
return getSecret(provider)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function setKey(provider: ProviderId, value: string): Promise<KeyBackend> {
|
|
172
|
+
return setSecret(provider, value)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function rmKey(provider: ProviderId): Promise<void> {
|
|
176
|
+
return rmSecret(provider)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function hasKey(provider: ProviderId): Promise<boolean> {
|
|
180
|
+
return hasSecret(provider)
|
|
181
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { getConfigDir } from './config.js'
|
|
4
|
+
import type { SessionMessage } from './sessions.js'
|
|
5
|
+
|
|
6
|
+
export function getExportsDir(): string {
|
|
7
|
+
return path.join(getConfigDir(), 'exports')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function exportSessionMarkdown(
|
|
11
|
+
id: string,
|
|
12
|
+
messages: SessionMessage[],
|
|
13
|
+
meta: { model: string; provider: string },
|
|
14
|
+
): Promise<string> {
|
|
15
|
+
await fs.mkdir(getExportsDir(), { recursive: true })
|
|
16
|
+
const file = path.join(getExportsDir(), `${id}.md`)
|
|
17
|
+
|
|
18
|
+
const userTurns = messages.filter(m => m.role === 'user').length
|
|
19
|
+
const lines: string[] = []
|
|
20
|
+
lines.push('---')
|
|
21
|
+
lines.push(`session: ${id}`)
|
|
22
|
+
lines.push(`provider: ${meta.provider}`)
|
|
23
|
+
lines.push(`model: ${meta.model}`)
|
|
24
|
+
lines.push(`turns: ${userTurns}`)
|
|
25
|
+
lines.push(`exportedAt: ${new Date().toISOString()}`)
|
|
26
|
+
lines.push('---')
|
|
27
|
+
lines.push('')
|
|
28
|
+
for (const m of messages) {
|
|
29
|
+
if (m.role === 'system') continue
|
|
30
|
+
const header =
|
|
31
|
+
m.role === 'user'
|
|
32
|
+
? '## user'
|
|
33
|
+
: m.role === 'assistant'
|
|
34
|
+
? '## assistant'
|
|
35
|
+
: m.role === 'tool_use'
|
|
36
|
+
? `## tool use · ${m.name}`
|
|
37
|
+
: `## tool result · ${m.name}`
|
|
38
|
+
lines.push(`${header} <sub>${m.createdAt}</sub>`)
|
|
39
|
+
lines.push('')
|
|
40
|
+
if (m.role === 'tool_use') {
|
|
41
|
+
lines.push(JSON.stringify(m.input, null, 2))
|
|
42
|
+
} else {
|
|
43
|
+
lines.push(m.content)
|
|
44
|
+
}
|
|
45
|
+
lines.push('')
|
|
46
|
+
}
|
|
47
|
+
await fs.writeFile(file, lines.join('\n'), { mode: 0o600 })
|
|
48
|
+
return file
|
|
49
|
+
}
|