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.
- package/README.md +18 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +157 -15
- package/src/app/FirstRunTimeline.tsx +4 -0
- package/src/app/input/AppInputProvider.tsx +19 -0
- package/src/app/input/appInputParser.ts +19 -4
- package/src/chat/ChatBottomPane.tsx +12 -1
- package/src/chat/ChatScreen.tsx +17 -5
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +4 -1
- package/src/chat/chatTurnOrchestrator.ts +65 -2
- package/src/chat/input/ChatInput.tsx +28 -2
- package/src/chat/input/imageRefs.ts +30 -0
- package/src/chat/input/textCursor.ts +13 -3
- package/src/chat/transcript/TranscriptView.tsx +7 -5
- package/src/chat/transcript/transcriptViewport.ts +88 -17
- package/src/chat/views/PermissionPrompt.tsx +26 -26
- package/src/chat/views/PermissionsView.tsx +18 -12
- package/src/chat/views/ResumeView.tsx +16 -7
- package/src/chat/views/RewindView.tsx +3 -1
- package/src/cli/ResetConfirmView.tsx +24 -9
- package/src/identity/continuity/editor.ts +27 -2
- package/src/identity/continuity/envelope.ts +125 -0
- package/src/identity/continuity/publicSkills.ts +37 -1
- package/src/identity/continuity/skills/frontmatter.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +609 -0
- package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
- package/src/identity/continuity/skills/scaffold.ts +52 -0
- package/src/identity/continuity/skills/types.ts +30 -0
- package/src/identity/continuity/storage/defaults.ts +28 -47
- package/src/identity/continuity/storage/files.ts +1 -0
- package/src/identity/continuity/storage/paths.ts +1 -0
- package/src/identity/continuity/storage/scaffold.ts +25 -23
- package/src/identity/continuity/storage/status.ts +34 -5
- package/src/identity/continuity/storage/types.ts +3 -2
- package/src/identity/continuity/storage.ts +3 -0
- package/src/identity/hub/OperationalRoutes.tsx +105 -3
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
- package/src/identity/hub/continuity/effects.ts +36 -5
- package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
- package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
- package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
- package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
- package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +3 -2
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/identityHubReducer.ts +21 -0
- package/src/identity/hub/profile/effects.ts +16 -3
- package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
- package/src/identity/hub/restore/apply.ts +12 -1
- package/src/identity/hub/restore/recovery.ts +11 -1
- package/src/identity/hub/restore/resolve.ts +1 -1
- package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
- package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
- package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
- package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
- package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
- package/src/identity/hub/shared/effects/sync.ts +16 -3
- package/src/identity/hub/shared/model/copy.ts +2 -4
- package/src/identity/hub/transfer/effects.ts +15 -2
- package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
- package/src/identity/hub/useIdentityHubController.ts +5 -1
- package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
- package/src/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +211 -74
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +261 -17
- package/src/models/llamacppPreflight.ts +16 -12
- package/src/models/modelPickerOptions.ts +57 -38
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +10 -1
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +131 -11
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +41 -11
- package/src/providers/registry.ts +1 -0
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/storage/config.ts +1 -0
- package/src/storage/sessions.ts +14 -2
- package/src/tools/changeDirectoryTool.ts +1 -1
- package/src/tools/contracts.ts +10 -0
- package/src/tools/deleteFileTool.ts +1 -1
- package/src/tools/editTool.ts +1 -1
- package/src/tools/listDirectoryTool.ts +1 -1
- package/src/tools/listSkillFilesTool.ts +77 -0
- package/src/tools/listSkillsTool.ts +68 -0
- package/src/tools/mcpResourceTools.ts +2 -2
- package/src/tools/privateContinuityReadTool.ts +1 -1
- package/src/tools/readSkillTool.ts +107 -0
- package/src/tools/readTool.ts +1 -1
- package/src/tools/registry.ts +6 -0
- package/src/tools/writeFileTool.ts +22 -2
- package/src/ui/Spinner.tsx +15 -3
- package/src/ui/theme.ts +2 -0
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
- package/src/identity/continuity/localBackup.ts +0 -249
- package/src/identity/continuity/zipWriter.ts +0 -95
- package/src/identity/hub/continuity/index.ts +0 -7
- package/src/identity/hub/ens/index.ts +0 -11
- 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
|
+
}
|
package/src/tools/readTool.ts
CHANGED
|
@@ -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: '
|
|
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,
|
package/src/tools/registry.ts
CHANGED
|
@@ -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 ? '
|
|
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 },
|
package/src/ui/Spinner.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
299
|
-
|
|
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
|
@@ -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
|
+
}
|
package/src/utils/messages.ts
CHANGED
|
@@ -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
|
-
}
|