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,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
+ }