ethagent 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +18 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +157 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +12 -1
  8. package/src/chat/ChatScreen.tsx +17 -5
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +4 -1
  12. package/src/chat/chatTurnOrchestrator.ts +65 -2
  13. package/src/chat/input/ChatInput.tsx +28 -2
  14. package/src/chat/input/imageRefs.ts +30 -0
  15. package/src/chat/input/textCursor.ts +13 -3
  16. package/src/chat/transcript/TranscriptView.tsx +7 -5
  17. package/src/chat/transcript/transcriptViewport.ts +88 -17
  18. package/src/chat/views/PermissionPrompt.tsx +26 -26
  19. package/src/chat/views/PermissionsView.tsx +18 -12
  20. package/src/chat/views/ResumeView.tsx +16 -7
  21. package/src/chat/views/RewindView.tsx +3 -1
  22. package/src/cli/ResetConfirmView.tsx +24 -9
  23. package/src/identity/continuity/editor.ts +27 -2
  24. package/src/identity/continuity/envelope.ts +125 -0
  25. package/src/identity/continuity/publicSkills.ts +37 -1
  26. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  27. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  28. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  29. package/src/identity/continuity/skills/scaffold.ts +52 -0
  30. package/src/identity/continuity/skills/types.ts +30 -0
  31. package/src/identity/continuity/storage/defaults.ts +28 -47
  32. package/src/identity/continuity/storage/files.ts +1 -0
  33. package/src/identity/continuity/storage/paths.ts +1 -0
  34. package/src/identity/continuity/storage/scaffold.ts +25 -23
  35. package/src/identity/continuity/storage/status.ts +34 -5
  36. package/src/identity/continuity/storage/types.ts +3 -2
  37. package/src/identity/continuity/storage.ts +3 -0
  38. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  39. package/src/identity/hub/Routes.tsx +5 -3
  40. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  41. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  42. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  43. package/src/identity/hub/continuity/effects.ts +36 -5
  44. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  45. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  46. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  47. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  48. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  49. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  50. package/src/identity/hub/continuity/snapshot.ts +3 -0
  51. package/src/identity/hub/continuity/state.ts +3 -2
  52. package/src/identity/hub/continuity/vault.ts +42 -10
  53. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  54. package/src/identity/hub/identityHubReducer.ts +21 -0
  55. package/src/identity/hub/profile/effects.ts +16 -3
  56. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  57. package/src/identity/hub/restore/apply.ts +12 -1
  58. package/src/identity/hub/restore/recovery.ts +11 -1
  59. package/src/identity/hub/restore/resolve.ts +1 -1
  60. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  61. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  62. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  63. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  64. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  65. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  66. package/src/identity/hub/shared/effects/sync.ts +16 -3
  67. package/src/identity/hub/shared/model/copy.ts +2 -4
  68. package/src/identity/hub/transfer/effects.ts +15 -2
  69. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  70. package/src/identity/hub/useIdentityHubController.ts +5 -1
  71. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  72. package/src/mcp/manager.ts +1 -1
  73. package/src/models/ModelPicker.tsx +211 -74
  74. package/src/models/huggingface.ts +180 -2
  75. package/src/models/llamacpp.ts +261 -17
  76. package/src/models/llamacppPreflight.ts +16 -12
  77. package/src/models/modelPickerOptions.ts +57 -38
  78. package/src/providers/anthropic.ts +36 -5
  79. package/src/providers/contracts.ts +10 -1
  80. package/src/providers/gemini.ts +29 -3
  81. package/src/providers/openai-chat.ts +131 -11
  82. package/src/providers/openai-responses-format.ts +29 -8
  83. package/src/providers/openai-responses.ts +41 -11
  84. package/src/providers/registry.ts +1 -0
  85. package/src/runtime/toolExecution.ts +4 -3
  86. package/src/runtime/turn.ts +61 -30
  87. package/src/storage/config.ts +1 -0
  88. package/src/storage/sessions.ts +14 -2
  89. package/src/tools/changeDirectoryTool.ts +1 -1
  90. package/src/tools/contracts.ts +10 -0
  91. package/src/tools/deleteFileTool.ts +1 -1
  92. package/src/tools/editTool.ts +1 -1
  93. package/src/tools/listDirectoryTool.ts +1 -1
  94. package/src/tools/listSkillFilesTool.ts +77 -0
  95. package/src/tools/listSkillsTool.ts +68 -0
  96. package/src/tools/mcpResourceTools.ts +2 -2
  97. package/src/tools/privateContinuityReadTool.ts +1 -1
  98. package/src/tools/readSkillTool.ts +107 -0
  99. package/src/tools/readTool.ts +1 -1
  100. package/src/tools/registry.ts +6 -0
  101. package/src/tools/writeFileTool.ts +22 -2
  102. package/src/ui/Spinner.tsx +15 -3
  103. package/src/ui/theme.ts +2 -0
  104. package/src/utils/images.ts +140 -0
  105. package/src/utils/messages.ts +2 -0
  106. package/src/identity/continuity/localBackup.ts +0 -249
  107. package/src/identity/continuity/zipWriter.ts +0 -95
  108. package/src/identity/hub/continuity/index.ts +0 -7
  109. package/src/identity/hub/ens/index.ts +0 -11
  110. package/src/identity/hub/restore/index.ts +0 -22
@@ -0,0 +1,107 @@
1
+ import path from 'node:path'
2
+ import { z } from 'zod'
3
+ import {
4
+ continuityVaultRef,
5
+ } from '../identity/continuity/storage.js'
6
+ import {
7
+ readSkill,
8
+ readSkillFile,
9
+ } from '../identity/continuity/skills/loadSkills.js'
10
+ import type { Tool } from './contracts.js'
11
+
12
+ const schema = z.object({
13
+ name: z.string().min(1),
14
+ file: z.string().min(1).optional(),
15
+ })
16
+
17
+ export const readSkillTool: Tool<typeof schema> = {
18
+ name: 'read_private_skill',
19
+ kind: 'private-continuity-read',
20
+ readOnly: true,
21
+ description: [
22
+ 'Read a file from the owner-authored private skill folder for the active identity.',
23
+ 'Pass the skill folder name as `name` (e.g. "writing-obit"). Without `file`, returns the SKILL.md entry point.',
24
+ 'With `file`, returns a supporting file from the same folder (e.g. file: "references/api.md"). Supporting-file responses include an absolute filesystem `path=` attribute; run executable scripts via run_bash using that absolute path.',
25
+ 'List available supporting files with list_private_skill_files. Bodies are markdown and may include private instructions; treat them like SOUL.md guidance.',
26
+ ].join(' '),
27
+ inputSchema: schema,
28
+ inputSchemaJson: {
29
+ type: 'object',
30
+ properties: {
31
+ name: { type: 'string', description: 'Skill folder name from the private skills index, e.g. "writing-obit".' },
32
+ file: { type: 'string', description: 'Optional supporting file path inside the skill folder. Defaults to SKILL.md.' },
33
+ },
34
+ required: ['name'],
35
+ additionalProperties: false,
36
+ },
37
+ parse(input) {
38
+ return schema.parse(input)
39
+ },
40
+ async buildPermissionRequest(input, context) {
41
+ const identity = context.config?.identity
42
+ if (!identity) throw new Error('No active identity; create or load an identity before reading a private skill')
43
+ const ref = continuityVaultRef(identity)
44
+ const folder = input.name.replace(/^.*:/, '')
45
+ const file = input.file ?? 'SKILL.md'
46
+ const skillPath = path.join(ref.skillsDir, folder, file)
47
+ const display = input.file ? `${folder}/${input.file}` : `${folder}/SKILL.md`
48
+ return {
49
+ kind: 'private-skill-read',
50
+ path: skillPath,
51
+ relativePath: `identity-vault/skills/${display}`,
52
+ directoryPath: path.dirname(skillPath),
53
+ title: 'Allow private skill read?',
54
+ subtitle: `Load ${display}`,
55
+ skillName: input.name,
56
+ mode: 'read',
57
+ }
58
+ },
59
+ async execute(input, context) {
60
+ const identity = context.config?.identity
61
+ if (!identity) {
62
+ return { ok: false, summary: 'no active identity', content: 'No active identity; cannot read private skill.' }
63
+ }
64
+ try {
65
+ const folder = input.name.replace(/^.*:/, '')
66
+ if (input.file && input.file !== 'SKILL.md') {
67
+ const result = await readSkillFile(identity, folder, input.file)
68
+ return {
69
+ ok: true,
70
+ summary: `read ${result.relativePath}`,
71
+ content: [
72
+ `<private_skill_file name="${folder}" file="${input.file}" path="${result.absolutePath}">`,
73
+ result.content,
74
+ '</private_skill_file>',
75
+ ].join('\n'),
76
+ }
77
+ }
78
+ const skill = await readSkill(identity, folder)
79
+ const meta: string[] = []
80
+ meta.push(`name: ${skill.displayName ?? skill.name}`)
81
+ if (skill.whenToUse) meta.push(`when_to_use: ${skill.whenToUse}`)
82
+ if (skill.argumentHint) meta.push(`argument_hint: ${skill.argumentHint}`)
83
+ if (skill.version) meta.push(`version: ${skill.version}`)
84
+ if (skill.tags && skill.tags.length > 0) meta.push(`tags: ${skill.tags.join(', ')}`)
85
+ meta.push(`visibility: ${skill.visibility}`)
86
+ const content = [
87
+ `<private_skill name="${skill.name}" visibility="${skill.visibility}">`,
88
+ ...meta,
89
+ '',
90
+ skill.body,
91
+ '</private_skill>',
92
+ ].join('\n')
93
+ return {
94
+ ok: true,
95
+ summary: `read private skill ${skill.name}`,
96
+ content,
97
+ }
98
+ } catch (err: unknown) {
99
+ const message = (err as Error).message
100
+ return {
101
+ ok: false,
102
+ summary: 'read failed',
103
+ content: message,
104
+ }
105
+ }
106
+ },
107
+ }
@@ -37,7 +37,7 @@ export const readTool: Tool<typeof schema> = {
37
37
  path: fullPath,
38
38
  relativePath,
39
39
  directoryPath: path.dirname(fullPath),
40
- title: 'allow file read?',
40
+ title: 'Allow file read?',
41
41
  subtitle: input.startLine || input.endLine
42
42
  ? `${fullPath} · lines ${input.startLine ?? 1}-${input.endLine ?? 'end'}`
43
43
  : fullPath,
@@ -8,8 +8,11 @@ import { changeDirectoryTool } from './changeDirectoryTool.js'
8
8
  import { deleteFileTool } from './deleteFileTool.js'
9
9
  import { editTool } from './editTool.js'
10
10
  import { listDirectoryTool } from './listDirectoryTool.js'
11
+ import { listSkillsTool } from './listSkillsTool.js'
12
+ import { listSkillFilesTool } from './listSkillFilesTool.js'
11
13
  import { privateContinuityEditTool } from './privateContinuityEditTool.js'
12
14
  import { privateContinuityReadTool } from './privateContinuityReadTool.js'
15
+ import { readSkillTool } from './readSkillTool.js'
13
16
  import { readTool } from './readTool.js'
14
17
  import { listMcpResourcesTool, readMcpResourceTool } from './mcpResourceTools.js'
15
18
  import { writeFileTool } from './writeFileTool.js'
@@ -19,6 +22,9 @@ export const BUILTIN_TOOLS: Tool[] = [
19
22
  listDirectoryTool,
20
23
  readTool,
21
24
  privateContinuityReadTool,
25
+ listSkillsTool,
26
+ listSkillFilesTool,
27
+ readSkillTool,
22
28
  listMcpResourcesTool,
23
29
  readMcpResourceTool,
24
30
  writeFileTool,
@@ -28,7 +28,7 @@ export const writeFileTool: Tool<typeof schema> = {
28
28
  required: ['path', 'content'],
29
29
  },
30
30
  parse(input) {
31
- return schema.parse(input)
31
+ return schema.parse(normalizeWriteFileInput(input))
32
32
  },
33
33
  async buildPermissionRequest(input, context) {
34
34
  const prepared = await prepareWrite(input, context)
@@ -37,7 +37,7 @@ export const writeFileTool: Tool<typeof schema> = {
37
37
  path: prepared.fullPath,
38
38
  relativePath: prepared.relativePath,
39
39
  directoryPath: path.dirname(prepared.fullPath),
40
- title: prepared.existedBefore ? 'allow file rewrite?' : 'allow file creation?',
40
+ title: prepared.existedBefore ? 'Allow file rewrite?' : 'Allow file creation?',
41
41
  subtitle: prepared.fullPath,
42
42
  before: previewText(prepared.before),
43
43
  after: previewText(input.content),
@@ -76,6 +76,26 @@ export const writeFileTool: Tool<typeof schema> = {
76
76
  },
77
77
  }
78
78
 
79
+ function normalizeWriteFileInput(input: unknown): unknown {
80
+ let value: unknown = input
81
+ if (typeof value === 'string') {
82
+ const trimmed = value.trim()
83
+ if (trimmed.startsWith('{')) {
84
+ try {
85
+ value = JSON.parse(trimmed)
86
+ } catch {
87
+ return input
88
+ }
89
+ }
90
+ }
91
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
92
+ const normalized: Record<string, unknown> = { ...(value as Record<string, unknown>) }
93
+ if (typeof normalized.path === 'string' && normalized.path.trim() === '') {
94
+ normalized.path = undefined
95
+ }
96
+ return normalized
97
+ }
98
+
79
99
  async function prepareWrite(
80
100
  input: z.infer<typeof schema>,
81
101
  context: { workspaceRoot: string; config?: EthagentConfig },
@@ -243,7 +243,7 @@ export const Spinner: React.FC<SpinnerProps> = ({
243
243
  useEffect(() => {
244
244
  if (!active) return
245
245
  internalStartedAtRef.current = startedAt ?? Date.now()
246
- }, [active, startedAt, label, verb])
246
+ }, [active, startedAt])
247
247
 
248
248
  useEffect(() => {
249
249
  if (!active) return
@@ -295,8 +295,20 @@ export const Spinner: React.FC<SpinnerProps> = ({
295
295
  function formatElapsedSeconds(milliseconds: number): string {
296
296
  const seconds = Math.max(0, Math.floor(milliseconds / 1000))
297
297
  if (seconds < 60) return `${seconds}s`
298
- const minutes = Math.floor(seconds / 60)
299
- return `${minutes}:${(seconds % 60).toString().padStart(2, '0')}`
298
+
299
+ const hours = Math.floor(seconds / 3600)
300
+ const minutes = Math.floor((seconds % 3600) / 60)
301
+ const remainingSeconds = seconds % 60
302
+
303
+ if (hours > 0) {
304
+ return remainingSeconds > 0
305
+ ? `${hours}h ${minutes}min ${remainingSeconds}s`
306
+ : `${hours}h ${minutes}min`
307
+ }
308
+
309
+ return remainingSeconds > 0
310
+ ? `${minutes}min ${remainingSeconds}s`
311
+ : `${minutes}min`
300
312
  }
301
313
 
302
314
  function restoreSpinnerTerms(value: string): string {
package/src/ui/theme.ts CHANGED
@@ -11,6 +11,8 @@ export const theme = {
11
11
  accentBlue: '#e8eefd',
12
12
  accentWhite: '#f5f8ff',
13
13
  accentError: '#d99898',
14
+ modePlan: '#f0c7a8',
15
+ modeAcceptEdits: '#c7b6f2',
14
16
  diffAdded: '#8fd49d',
15
17
  diffRemoved: '#d99898',
16
18
  diffAddedBackground: '#16351f',
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { ImageBlock, Message, MessageContentBlock } from '../providers/contracts.js'
4
+
5
+ const IMAGE_MARKER_RE = /\[image:\s*([^\]]+?)\]/gi
6
+ const PLACEHOLDER_RE = /^([<{[].*[>}\]]|#\d+)$/
7
+
8
+ export class ImageLoadError extends Error {
9
+ readonly imagePath: string
10
+ constructor(imagePath: string, message: string) {
11
+ super(message)
12
+ this.name = 'ImageLoadError'
13
+ this.imagePath = imagePath
14
+ }
15
+ }
16
+
17
+ export function collapseImagePathsToRefs(text: string): string {
18
+ let counter = 0
19
+ return text.replace(IMAGE_MARKER_RE, (full, raw: string) => {
20
+ const trimmed = raw.trim()
21
+ if (!trimmed || PLACEHOLDER_RE.test(trimmed)) return full
22
+ counter += 1
23
+ return `[Image #${counter}]`
24
+ })
25
+ }
26
+
27
+ export function modelSupportsImages(
28
+ provider: string,
29
+ model: string,
30
+ extra?: { mmprojPath?: string },
31
+ ): boolean {
32
+ const normalized = model.toLowerCase()
33
+ switch (provider) {
34
+ case 'anthropic':
35
+ return /claude-3|claude-sonnet-4|claude-opus-4|claude-haiku-4/.test(normalized)
36
+ case 'gemini':
37
+ return /gemini-1\.5|gemini-2\.0|gemini-2\.5/.test(normalized)
38
+ case 'openai':
39
+ if (normalized.includes('gpt-3.5')) return false
40
+ return /gpt-4o|gpt-4\.1|gpt-4-turbo|gpt-4-vision|gpt-5|o1|o3|o4|chatgpt-4/.test(normalized)
41
+ case 'llamacpp':
42
+ return Boolean(extra?.mmprojPath)
43
+ default:
44
+ return false
45
+ }
46
+ }
47
+
48
+ export function hasImageBlocks(messages: Message[]): boolean {
49
+ return messages.some(message => Array.isArray(message.content) && message.content.some(block => block.type === 'image'))
50
+ }
51
+
52
+ export function userTextToContentBlocks(text: string): string | MessageContentBlock[] {
53
+ const blocks = parseImageMarkers(text)
54
+ return blocks.length === 1 && blocks[0]?.type === 'text' ? blocks[0].text : blocks
55
+ }
56
+
57
+ export function parseImageMarkers(text: string): MessageContentBlock[] {
58
+ const out: MessageContentBlock[] = []
59
+ let lastIndex = 0
60
+ let match: RegExpExecArray | null
61
+
62
+ while ((match = IMAGE_MARKER_RE.exec(text)) !== null) {
63
+ const full = match[0]
64
+ const rawPath = match[1]?.trim() ?? ''
65
+ if (match.index > lastIndex) {
66
+ const prefix = text.slice(lastIndex, match.index)
67
+ if (prefix) out.push({ type: 'text', text: prefix })
68
+ }
69
+ if (rawPath && !PLACEHOLDER_RE.test(rawPath)) {
70
+ out.push({ type: 'image', path: rawPath })
71
+ } else {
72
+ out.push({ type: 'text', text: full })
73
+ }
74
+ lastIndex = match.index + full.length
75
+ }
76
+
77
+ if (lastIndex < text.length) {
78
+ const suffix = text.slice(lastIndex)
79
+ if (suffix) out.push({ type: 'text', text: suffix })
80
+ }
81
+
82
+ if (out.length === 0) return text ? [{ type: 'text', text }] : []
83
+ return mergeAdjacentTextBlocks(out)
84
+ }
85
+
86
+ export async function loadImageBlock(block: ImageBlock): Promise<ImageBlock> {
87
+ if (block.dataBase64 && block.mimeType) return block
88
+ if (block.url) return block
89
+ const rawPath = block.path?.trim() ?? ''
90
+ if (!rawPath) throw new ImageLoadError(rawPath, 'image path is empty')
91
+ if (PLACEHOLDER_RE.test(rawPath)) {
92
+ throw new ImageLoadError(rawPath, `image path looks like a placeholder, not a real file: ${rawPath}`)
93
+ }
94
+ let file: Buffer
95
+ try {
96
+ file = await fs.readFile(rawPath)
97
+ } catch (err: unknown) {
98
+ const code = (err as NodeJS.ErrnoException).code
99
+ if (code === 'ENOENT') {
100
+ throw new ImageLoadError(rawPath, `image file not found: ${rawPath}`)
101
+ }
102
+ throw new ImageLoadError(rawPath, `could not read image at ${rawPath}: ${(err as Error).message}`)
103
+ }
104
+ const mimeType = block.mimeType ?? mimeTypeForPath(rawPath)
105
+ return {
106
+ ...block,
107
+ path: rawPath,
108
+ mimeType,
109
+ dataBase64: file.toString('base64'),
110
+ }
111
+ }
112
+
113
+ export function imagePlaceholder(pathValue: string): string {
114
+ return `[image: ${path.basename(pathValue)}]`
115
+ }
116
+
117
+ function mergeAdjacentTextBlocks(blocks: MessageContentBlock[]): MessageContentBlock[] {
118
+ const out: MessageContentBlock[] = []
119
+ for (const block of blocks) {
120
+ const prev = out[out.length - 1]
121
+ if (block.type === 'text' && prev?.type === 'text') {
122
+ prev.text += block.text
123
+ continue
124
+ }
125
+ out.push(block)
126
+ }
127
+ return out
128
+ }
129
+
130
+ function mimeTypeForPath(filePath: string): string {
131
+ switch (path.extname(filePath).toLowerCase()) {
132
+ case '.png': return 'image/png'
133
+ case '.jpg':
134
+ case '.jpeg': return 'image/jpeg'
135
+ case '.webp': return 'image/webp'
136
+ case '.gif': return 'image/gif'
137
+ case '.bmp': return 'image/bmp'
138
+ default: return 'application/octet-stream'
139
+ }
140
+ }
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { Message, MessageContentBlock } from '../providers/contracts.js'
2
3
 
3
4
  export function systemMessage(content: string): Message {
@@ -20,6 +21,7 @@ export function blocksToText(blocks: MessageContentBlock[]): string {
20
21
  return blocks
21
22
  .map(block => {
22
23
  if (block.type === 'text') return block.text
24
+ if (block.type === 'image') return `[image attached: ${path.basename(block.path)}]`
23
25
  if (block.type === 'tool_use') return `[tool use: ${block.name}]`
24
26
  return block.isError
25
27
  ? `[tool error: ${block.content}]`
@@ -1,249 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import fs from 'node:fs'
3
- import fsp from 'node:fs/promises'
4
- import os from 'node:os'
5
- import path from 'node:path'
6
- import type { EthagentIdentity } from '../../storage/config.js'
7
- import { continuityVaultRef } from './storage.js'
8
- import { buildZip, type ZipEntry } from './zipWriter.js'
9
-
10
- type LocalBackupResult =
11
- | { ok: true; path: string; method: string }
12
- | { ok: false; cancelled: boolean; error: string }
13
-
14
- type SaveDialogCommand = {
15
- cmd: string
16
- args: string[]
17
- method: string
18
- }
19
-
20
- export async function exportLocalBackup(
21
- identity: EthagentIdentity,
22
- options: {
23
- platform?: NodeJS.Platform
24
- env?: NodeJS.ProcessEnv
25
- timeoutMs?: number
26
- spawnImpl?: typeof spawn
27
- homeDir?: string
28
- } = {},
29
- ): Promise<LocalBackupResult> {
30
- const platform = options.platform ?? process.platform
31
- const env = options.env ?? process.env
32
- const homeDir = options.homeDir ?? os.homedir()
33
- const ref = continuityVaultRef(identity)
34
-
35
- const entries: ZipEntry[] = []
36
- for (const [name, file] of [
37
- ['SOUL.md', ref.soulPath],
38
- ['MEMORY.md', ref.memoryPath],
39
- ['skills.json', ref.publicSkillsPath],
40
- ] as const) {
41
- try {
42
- const data = await fsp.readFile(file)
43
- entries.push({ name, data })
44
- } catch (err: unknown) {
45
- const code = (err as NodeJS.ErrnoException).code
46
- if (code !== 'ENOENT') {
47
- return { ok: false, cancelled: false, error: `read ${name} failed: ${(err as Error).message}` }
48
- }
49
- }
50
- }
51
- if (entries.length === 0) {
52
- return { ok: false, cancelled: false, error: 'no local continuity files to back up' }
53
- }
54
-
55
- const archive = buildZip(entries)
56
- const defaultName = defaultBackupFilename(identity)
57
- const dialog = resolveSaveDialogCommand(platform, env, defaultName)
58
-
59
- if (dialog) {
60
- const chosen = await runSaveDialog(dialog, options.spawnImpl ?? spawn, options.timeoutMs ?? 120_000)
61
- if (chosen.ok) {
62
- const target = ensureZipExtension(chosen.file)
63
- try {
64
- await fsp.writeFile(target, archive)
65
- return { ok: true, path: target, method: dialog.method }
66
- } catch (err: unknown) {
67
- return { ok: false, cancelled: false, error: `write failed: ${(err as Error).message}` }
68
- }
69
- }
70
- if (!chosen.cancelled) {
71
- const fallback = path.join(homeDir, defaultName)
72
- try {
73
- await fsp.writeFile(fallback, archive)
74
- return { ok: true, path: fallback, method: 'home dir fallback' }
75
- } catch (err: unknown) {
76
- return { ok: false, cancelled: false, error: `write failed: ${(err as Error).message}` }
77
- }
78
- }
79
- return chosen
80
- }
81
-
82
- const fallback = path.join(homeDir, defaultName)
83
- try {
84
- await fsp.writeFile(fallback, archive)
85
- return { ok: true, path: fallback, method: 'home dir' }
86
- } catch (err: unknown) {
87
- return { ok: false, cancelled: false, error: `write failed: ${(err as Error).message}` }
88
- }
89
- }
90
-
91
- function defaultBackupFilename(identity: EthagentIdentity): string {
92
- const idPart = identity.agentId ? `agent-${identity.agentId}` : 'agent'
93
- const stamp = timestampSlug(new Date())
94
- return `ethagent-backup-${idPart}-${stamp}.zip`
95
- }
96
-
97
- function timestampSlug(date: Date): string {
98
- const pad = (n: number): string => String(n).padStart(2, '0')
99
- return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
100
- }
101
-
102
- function ensureZipExtension(file: string): string {
103
- return /\.zip$/i.test(file) ? file : `${file}.zip`
104
- }
105
-
106
- function resolveSaveDialogCommand(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, defaultName: string): SaveDialogCommand | null {
107
- if (platform === 'win32') {
108
- const powershell = findExecutable('powershell.exe', env, platform) ?? findExecutable('pwsh.exe', env, platform)
109
- if (!powershell) return null
110
- return {
111
- cmd: powershell,
112
- args: [
113
- '-NoProfile',
114
- '-STA',
115
- '-ExecutionPolicy',
116
- 'Bypass',
117
- '-Command',
118
- [
119
- '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
120
- 'Add-Type -AssemblyName System.Windows.Forms',
121
- '$dialog = New-Object System.Windows.Forms.SaveFileDialog',
122
- '$dialog.Title = "Save Local Backup"',
123
- '$dialog.Filter = "Zip archive (*.zip)|*.zip|All files (*.*)|*.*"',
124
- '$dialog.DefaultExt = "zip"',
125
- `$dialog.FileName = "${defaultName.replace(/"/g, '`"')}"`,
126
- '$dialog.OverwritePrompt = $true',
127
- 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dialog.FileName }',
128
- ].join('; '),
129
- ],
130
- method: 'windows save dialog',
131
- }
132
- }
133
- if (platform === 'darwin') {
134
- return {
135
- cmd: 'osascript',
136
- args: [
137
- '-e',
138
- `set chosen to choose file name with prompt "Save Local Backup" default name "${defaultName.replace(/"/g, '\\"')}"`,
139
- '-e',
140
- 'POSIX path of chosen',
141
- ],
142
- method: 'macOS save dialog',
143
- }
144
- }
145
- const zenity = findExecutable('zenity', env, platform)
146
- if (zenity) {
147
- return {
148
- cmd: zenity,
149
- args: [
150
- '--file-selection',
151
- '--save',
152
- '--confirm-overwrite',
153
- '--title=Save Local Backup',
154
- `--filename=${path.join(env.HOME ?? '.', defaultName)}`,
155
- '--file-filter=Zip archive | *.zip',
156
- ],
157
- method: 'zenity',
158
- }
159
- }
160
- const kdialog = findExecutable('kdialog', env, platform)
161
- if (kdialog) {
162
- return {
163
- cmd: kdialog,
164
- args: ['--getsavefilename', path.join(env.HOME ?? '.', defaultName), 'Zip archive (*.zip)'],
165
- method: 'kdialog',
166
- }
167
- }
168
- return null
169
- }
170
-
171
- function runSaveDialog(
172
- command: SaveDialogCommand,
173
- spawnImpl: typeof spawn,
174
- timeoutMs: number,
175
- ): Promise<{ ok: true; file: string } | { ok: false; cancelled: boolean; error: string }> {
176
- return new Promise(resolve => {
177
- let stdout = ''
178
- let stderr = ''
179
- let settled = false
180
- let child: ReturnType<typeof spawn>
181
- try {
182
- child = spawnImpl(command.cmd, command.args, {
183
- stdio: ['ignore', 'pipe', 'pipe'],
184
- windowsHide: true,
185
- })
186
- } catch (err: unknown) {
187
- resolve({ ok: false, cancelled: false, error: (err as Error).message })
188
- return
189
- }
190
- const timer = setTimeout(() => {
191
- if (settled) return
192
- settled = true
193
- child.kill()
194
- resolve({ ok: false, cancelled: false, error: 'save dialog timed out' })
195
- }, timeoutMs)
196
- child.stdout?.setEncoding('utf8')
197
- child.stderr?.setEncoding('utf8')
198
- child.stdout?.on('data', chunk => { stdout += String(chunk) })
199
- child.stderr?.on('data', chunk => { stderr += String(chunk) })
200
- child.on('error', err => {
201
- if (settled) return
202
- settled = true
203
- clearTimeout(timer)
204
- resolve({ ok: false, cancelled: false, error: err.message })
205
- })
206
- child.on('close', code => {
207
- if (settled) return
208
- settled = true
209
- clearTimeout(timer)
210
- const file = stdout.trim()
211
- if (code === 0 && file) {
212
- resolve({ ok: true, file })
213
- return
214
- }
215
- const detail = stderr.trim()
216
- const cancelled = code === 0 || /cancel/i.test(detail)
217
- resolve({ ok: false, cancelled, error: cancelled ? 'save cancelled' : detail || `${command.method} exited ${code}` })
218
- })
219
- })
220
- }
221
-
222
- function findExecutable(command: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | null {
223
- const hasPathSeparator = command.includes('/') || command.includes('\\')
224
- if (hasPathSeparator || path.isAbsolute(command)) return canAccessExecutable(command) ? command : null
225
- const pathParts = (env.PATH ?? '').split(path.delimiter).filter(Boolean)
226
- const extensions = platform === 'win32'
227
- ? (env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
228
- : ['']
229
- for (const dir of pathParts) {
230
- for (const ext of extensions) {
231
- const candidate = path.join(dir, platform === 'win32' && path.extname(command) === '' ? `${command}${ext}` : command)
232
- if (canAccessExecutable(candidate)) return candidate
233
- if (platform === 'win32') {
234
- const lower = path.join(dir, path.extname(command) === '' ? `${command}${ext.toLowerCase()}` : command)
235
- if (canAccessExecutable(lower)) return lower
236
- }
237
- }
238
- }
239
- return null
240
- }
241
-
242
- function canAccessExecutable(file: string): boolean {
243
- try {
244
- fs.accessSync(file, fs.constants.X_OK)
245
- return true
246
- } catch {
247
- return false
248
- }
249
- }