ethagent 0.2.1 → 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.
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 +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -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,97 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from './theme.js'
4
+ import { useAppInput } from '../app/input/AppInputProvider.js'
5
+
6
+ type TextInputProps = {
7
+ label?: string
8
+ placeholder?: string
9
+ isSecret?: boolean
10
+ initialValue?: string
11
+ allowEmpty?: boolean
12
+ maxLength?: number
13
+ validate?: (value: string) => string | null
14
+ onSubmit: (value: string) => void
15
+ onCancel?: () => void
16
+ }
17
+
18
+ export function TextInput({
19
+ label,
20
+ placeholder,
21
+ isSecret,
22
+ initialValue = '',
23
+ allowEmpty = false,
24
+ maxLength = 4096,
25
+ validate,
26
+ onSubmit,
27
+ onCancel,
28
+ }: TextInputProps) {
29
+ const [value, setValue] = useState(initialValue)
30
+ const [error, setError] = useState<string | null>(null)
31
+
32
+ useAppInput((input, key) => {
33
+ if (key.return) {
34
+ if (!allowEmpty && value.trim().length === 0) {
35
+ setError('value cannot be empty')
36
+ return
37
+ }
38
+ const validationError = validate?.(value) ?? null
39
+ if (validationError) {
40
+ setError(validationError)
41
+ return
42
+ }
43
+ setError(null)
44
+ onSubmit(value)
45
+ return
46
+ }
47
+ if (key.escape) {
48
+ onCancel?.()
49
+ return
50
+ }
51
+ if (key.backspace || key.delete) {
52
+ setValue(v => v.slice(0, -1))
53
+ if (error) setError(null)
54
+ return
55
+ }
56
+ if (key.ctrl && input === 'u') {
57
+ setValue('')
58
+ if (error) setError(null)
59
+ return
60
+ }
61
+ if (key.ctrl || key.meta || key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.tab) {
62
+ return
63
+ }
64
+ if (input) {
65
+ const clean = input.replace(/[\r\n]/g, '')
66
+ if (clean) {
67
+ setValue(v => (v + clean).slice(0, maxLength))
68
+ if (error) setError(null)
69
+ }
70
+ }
71
+ })
72
+
73
+ const display = isSecret ? '*'.repeat(value.length) : value
74
+ const showPlaceholder = value.length === 0 && placeholder
75
+
76
+ return (
77
+ <Box flexDirection="column">
78
+ {label ? <Text color={theme.dim}>{label}</Text> : null}
79
+ <Box flexDirection="row">
80
+ <Text color={theme.accentPrimary}>{'> '}</Text>
81
+ {showPlaceholder ? (
82
+ <>
83
+ <Text color={theme.accentPrimary}>|</Text>
84
+ <Text color={theme.dim}>{placeholder}</Text>
85
+ </>
86
+ ) : (
87
+ <>
88
+ <Text color={theme.text}>{display}</Text>
89
+ <Text color={theme.accentPrimary}>|</Text>
90
+ </>
91
+ )}
92
+ </Box>
93
+ {error ? <Text color="#e87070">{error}</Text> : null}
94
+ </Box>
95
+ )
96
+ }
97
+
@@ -0,0 +1,59 @@
1
+ export const palette: Array<[number, number, number]> = [
2
+ [0xff, 0xff, 0xff],
3
+ [0xf2, 0xf9, 0xf4],
4
+ [0xe7, 0xf4, 0xec],
5
+ [0xd4, 0xee, 0xdd],
6
+ [0xe7, 0xf4, 0xec],
7
+ [0xff, 0xff, 0xff],
8
+ ]
9
+
10
+ export const eyePalette: Array<[number, number, number]> = [
11
+ [0xf5, 0xd8, 0xd8],
12
+ [0xf5, 0xe7, 0xcf],
13
+ [0xf5, 0xf0, 0xd4],
14
+ [0xd4, 0xee, 0xdd],
15
+ [0xd4, 0xe6, 0xf5],
16
+ ] as const
17
+
18
+ export const theme = {
19
+ accentPrimary: '#d4eedd',
20
+ accentWarm: '#d8cda8',
21
+ accentNeutral: '#e4e3b5',
22
+ accentSecondary: '#c0e3cb',
23
+ accentMint: '#e7f4ec',
24
+ accentPeach: '#e7cdb7',
25
+ accentLavender: '#d9cae8',
26
+ accentInfo: '#90b8e8',
27
+ border: '#555555',
28
+ dim: '#777777',
29
+ text: '#f1f1f1',
30
+ textSubtle: '#9b9b9b',
31
+ } as const
32
+
33
+ export function gradientColor(t: number): string {
34
+ const s = Math.max(0, Math.min(1, t)) * (palette.length - 1)
35
+ const i = Math.min(Math.floor(s), palette.length - 2)
36
+ const f = s - i
37
+ const lo = palette[i] ?? palette[0]!
38
+ const hi = palette[i + 1] ?? palette[palette.length - 1]!
39
+ const [r1, g1, b1] = lo
40
+ const [r2, g2, b2] = hi
41
+ const r = Math.round(r1 + (r2 - r1) * f)
42
+ const g = Math.round(g1 + (g2 - g1) * f)
43
+ const b = Math.round(b1 + (b2 - b1) * f)
44
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
45
+ }
46
+
47
+ export function eyeGradientColor(t: number): string {
48
+ const s = Math.max(0, Math.min(1, t)) * (eyePalette.length - 1)
49
+ const i = Math.min(Math.floor(s), eyePalette.length - 2)
50
+ const f = s - i
51
+ const lo = eyePalette[i] ?? eyePalette[0]!
52
+ const hi = eyePalette[i + 1] ?? eyePalette[eyePalette.length - 1]!
53
+ const [r1, g1, b1] = lo
54
+ const [r2, g2, b2] = hi
55
+ const r = Math.round(r1 + (r2 - r1) * f)
56
+ const g = Math.round(g1 + (g2 - g1) * f)
57
+ const b = Math.round(b1 + (b2 - b1) * f)
58
+ return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
59
+ }
@@ -0,0 +1,216 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdir, stat } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ export type CopyResult = { ok: true; method: string } | { ok: false; error: string }
7
+ export type ReadResult = { ok: true; text: string; method: string } | { ok: false; error: string }
8
+ export type ReadImageResult = { ok: true; path: string; method: string } | { ok: false; error: string }
9
+
10
+ export async function copyToClipboard(text: string): Promise<CopyResult> {
11
+ const native = await tryNative(text)
12
+ if (native.ok) return native
13
+
14
+ const tmux = await tryTmux(text)
15
+ if (tmux.ok) return tmux
16
+
17
+ try {
18
+ process.stdout.write(osc52(text))
19
+ return { ok: true, method: 'osc52' }
20
+ } catch (err: unknown) {
21
+ return { ok: false, error: (err as Error).message || 'osc52 write failed' }
22
+ }
23
+ }
24
+
25
+ async function tryNative(text: string): Promise<CopyResult> {
26
+ if (process.platform === 'darwin') {
27
+ return pipeTo('pbcopy', [], text, 'pbcopy')
28
+ }
29
+ if (process.platform === 'win32') {
30
+ return pipeTo('clip', [], text, 'clip.exe')
31
+ }
32
+ const wl = await probe('wl-copy', ['--version'])
33
+ if (wl) return pipeTo('wl-copy', [], text, 'wl-copy')
34
+ const xclip = await probe('xclip', ['-version'])
35
+ if (xclip) return pipeTo('xclip', ['-selection', 'clipboard'], text, 'xclip')
36
+ const xsel = await probe('xsel', ['--version'])
37
+ if (xsel) return pipeTo('xsel', ['--clipboard', '--input'], text, 'xsel')
38
+ return { ok: false, error: 'no native clipboard tool found' }
39
+ }
40
+
41
+ async function tryTmux(text: string): Promise<CopyResult> {
42
+ if (!process.env['TMUX']) return { ok: false, error: 'not in tmux' }
43
+ return pipeTo('tmux', ['load-buffer', '-w', '-'], text, 'tmux load-buffer')
44
+ }
45
+
46
+ function pipeTo(cmd: string, args: string[], text: string, method: string): Promise<CopyResult> {
47
+ return new Promise(resolve => {
48
+ let child
49
+ try {
50
+ child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] })
51
+ } catch (err: unknown) {
52
+ resolve({ ok: false, error: (err as Error).message })
53
+ return
54
+ }
55
+ child.on('error', err => resolve({ ok: false, error: err.message }))
56
+ child.on('close', code => {
57
+ if (code === 0) resolve({ ok: true, method })
58
+ else resolve({ ok: false, error: `${cmd} exited ${code}` })
59
+ })
60
+ child.stdin?.end(text, 'utf8')
61
+ })
62
+ }
63
+
64
+ function probe(cmd: string, args: string[]): Promise<boolean> {
65
+ return new Promise(resolve => {
66
+ let child
67
+ try {
68
+ child = spawn(cmd, args, { stdio: 'ignore' })
69
+ } catch {
70
+ resolve(false)
71
+ return
72
+ }
73
+ child.on('error', () => resolve(false))
74
+ child.on('close', code => resolve(code === 0))
75
+ })
76
+ }
77
+
78
+ function osc52(text: string): string {
79
+ const b64 = Buffer.from(text, 'utf8').toString('base64')
80
+ return `\x1b]52;c;${b64}\x07`
81
+ }
82
+
83
+ export async function readClipboard(): Promise<ReadResult> {
84
+ const native = await tryReadNative()
85
+ if (native.ok) return native
86
+ const tmux = await tryReadTmux()
87
+ if (tmux.ok) return tmux
88
+ return { ok: false, error: native.error || tmux.error || 'no clipboard tool available' }
89
+ }
90
+
91
+ async function tryReadNative(): Promise<ReadResult> {
92
+ if (process.platform === 'darwin') {
93
+ return readFrom('pbpaste', [], 'pbpaste')
94
+ }
95
+ if (process.platform === 'win32') {
96
+ return readFrom('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw'], 'powershell Get-Clipboard')
97
+ }
98
+ if (process.env['WAYLAND_DISPLAY']) {
99
+ const wl = await probe('wl-paste', ['--version'])
100
+ if (wl) return readFrom('wl-paste', ['--no-newline'], 'wl-paste')
101
+ }
102
+ const xclip = await probe('xclip', ['-version'])
103
+ if (xclip) return readFrom('xclip', ['-o', '-selection', 'clipboard'], 'xclip')
104
+ const xsel = await probe('xsel', ['--version'])
105
+ if (xsel) return readFrom('xsel', ['--output', '--clipboard'], 'xsel')
106
+ return { ok: false, error: 'no native clipboard tool found' }
107
+ }
108
+
109
+ async function tryReadTmux(): Promise<ReadResult> {
110
+ if (!process.env['TMUX']) return { ok: false, error: 'not in tmux' }
111
+ return readFrom('tmux', ['save-buffer', '-'], 'tmux save-buffer')
112
+ }
113
+
114
+ function readFrom(cmd: string, args: string[], method: string): Promise<ReadResult> {
115
+ return new Promise(resolve => {
116
+ let child
117
+ try {
118
+ child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] })
119
+ } catch (err: unknown) {
120
+ resolve({ ok: false, error: (err as Error).message })
121
+ return
122
+ }
123
+ const chunks: Buffer[] = []
124
+ child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
125
+ child.on('error', err => resolve({ ok: false, error: err.message }))
126
+ child.on('close', code => {
127
+ if (code === 0) {
128
+ const text = Buffer.concat(chunks).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
129
+ resolve({ ok: true, text: text.replace(/\n$/, ''), method })
130
+ } else {
131
+ resolve({ ok: false, error: `${cmd} exited ${code}` })
132
+ }
133
+ })
134
+ })
135
+ }
136
+
137
+ export async function readClipboardImage(): Promise<ReadImageResult> {
138
+ const dir = path.join(os.homedir(), '.ethagent', 'pastes')
139
+ try {
140
+ await mkdir(dir, { recursive: true })
141
+ } catch (err: unknown) {
142
+ return { ok: false, error: (err as Error).message }
143
+ }
144
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
145
+ const dest = path.join(dir, `paste-${stamp}.png`)
146
+
147
+ if (process.platform === 'win32') return readImageWindows(dest)
148
+ if (process.platform === 'darwin') return readImageMac(dest)
149
+ return readImageLinux(dest)
150
+ }
151
+
152
+ function readImageWindows(dest: string): Promise<ReadImageResult> {
153
+ const script = [
154
+ "Add-Type -AssemblyName System.Windows.Forms",
155
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
156
+ "if ($null -eq $img) { exit 2 }",
157
+ "$img.Save($env:ETHAGENT_PASTE_PATH, [System.Drawing.Imaging.ImageFormat]::Png)",
158
+ ].join('; ')
159
+ return spawnImage(
160
+ 'powershell',
161
+ ['-NoProfile', '-Sta', '-Command', script],
162
+ { ETHAGENT_PASTE_PATH: dest },
163
+ dest,
164
+ 'powershell',
165
+ )
166
+ }
167
+
168
+ async function readImageMac(dest: string): Promise<ReadImageResult> {
169
+ const has = await probe('pngpaste', ['-v'])
170
+ if (!has) return { ok: false, error: 'pngpaste not installed (brew install pngpaste)' }
171
+ return spawnImage('pngpaste', [dest], {}, dest, 'pngpaste')
172
+ }
173
+
174
+ async function readImageLinux(dest: string): Promise<ReadImageResult> {
175
+ const hasXclip = await probe('xclip', ['-version'])
176
+ if (!hasXclip) return { ok: false, error: 'xclip not available' }
177
+ return spawnImage('sh', ['-c', `xclip -selection clipboard -t image/png -o > "${dest}"`], {}, dest, 'xclip')
178
+ }
179
+
180
+ function spawnImage(
181
+ cmd: string,
182
+ args: string[],
183
+ env: Record<string, string>,
184
+ destPath: string,
185
+ method: string,
186
+ ): Promise<ReadImageResult> {
187
+ return new Promise(resolve => {
188
+ let child
189
+ try {
190
+ child = spawn(cmd, args, {
191
+ stdio: ['ignore', 'ignore', 'ignore'],
192
+ env: { ...process.env, ...env },
193
+ })
194
+ } catch (err: unknown) {
195
+ resolve({ ok: false, error: (err as Error).message })
196
+ return
197
+ }
198
+ child.on('error', err => resolve({ ok: false, error: err.message }))
199
+ child.on('close', code => {
200
+ if (code === 2) {
201
+ resolve({ ok: false, error: 'no image on clipboard' })
202
+ return
203
+ }
204
+ if (code !== 0) {
205
+ resolve({ ok: false, error: `${cmd} exited ${code}` })
206
+ return
207
+ }
208
+ void stat(destPath)
209
+ .then(s => {
210
+ if (s.size === 0) resolve({ ok: false, error: 'no image saved' })
211
+ else resolve({ ok: true, path: destPath, method })
212
+ })
213
+ .catch(() => resolve({ ok: false, error: 'image file not created' }))
214
+ })
215
+ })
216
+ }
@@ -0,0 +1,51 @@
1
+ export type Segment =
2
+ | { kind: 'text'; content: string; preview: string }
3
+ | { kind: 'code'; lang: string | null; content: string; preview: string }
4
+
5
+ const FENCE = /^[ \t]{0,3}```([\w+-]*)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]{0,3}```[ \t]*(?:\r?\n|$)/gm
6
+
7
+ export function parseSegments(markdown: string): Segment[] {
8
+ if (!markdown) return []
9
+ const segments: Segment[] = []
10
+ let lastIndex = 0
11
+ FENCE.lastIndex = 0
12
+ let match: RegExpExecArray | null
13
+ while ((match = FENCE.exec(markdown)) !== null) {
14
+ const before = markdown.slice(lastIndex, match.index)
15
+ const textSeg = toTextSegment(before)
16
+ if (textSeg) segments.push(textSeg)
17
+ const lang = match[1] && match[1].length > 0 ? match[1] : null
18
+ const body = match[2] ?? ''
19
+ segments.push({
20
+ kind: 'code',
21
+ lang,
22
+ content: body,
23
+ preview: codePreview(lang, body),
24
+ })
25
+ lastIndex = match.index + match[0].length
26
+ }
27
+ const tail = markdown.slice(lastIndex)
28
+ const tailSeg = toTextSegment(tail)
29
+ if (tailSeg) segments.push(tailSeg)
30
+ return segments
31
+ }
32
+
33
+ function toTextSegment(raw: string): Segment | null {
34
+ const cleaned = raw.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
35
+ if (!cleaned) return null
36
+ return { kind: 'text', content: cleaned, preview: textPreview(cleaned) }
37
+ }
38
+
39
+ function textPreview(text: string): string {
40
+ const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? text
41
+ const trimmed = firstLine.trim()
42
+ const chars = text.length
43
+ const snippet = trimmed.length > 48 ? trimmed.slice(0, 47) + '…' : trimmed
44
+ return `text · ${chars} chars · ${snippet}`
45
+ }
46
+
47
+ function codePreview(lang: string | null, body: string): string {
48
+ const lineCount = body.length === 0 ? 0 : body.split('\n').length
49
+ const label = lang ?? 'code'
50
+ return `code · ${label} · ${lineCount} line${lineCount === 1 ? '' : 's'}`
51
+ }
@@ -0,0 +1,35 @@
1
+ import type { Message, MessageContentBlock } from '../providers/contracts.js'
2
+
3
+ export function systemMessage(content: string): Message {
4
+ return { role: 'system', content }
5
+ }
6
+
7
+ export function userMessage(content: string | MessageContentBlock[]): Message {
8
+ return { role: 'user', content }
9
+ }
10
+
11
+ export function assistantMessage(content: string | MessageContentBlock[]): Message {
12
+ return { role: 'assistant', content }
13
+ }
14
+
15
+ export function messageTextContent(message: Message): string {
16
+ return typeof message.content === 'string' ? message.content : blocksToText(message.content)
17
+ }
18
+
19
+ export function blocksToText(blocks: MessageContentBlock[]): string {
20
+ return blocks
21
+ .map(block => {
22
+ if (block.type === 'text') return block.text
23
+ if (block.type === 'tool_use') return `[tool use: ${block.name}]`
24
+ return block.isError
25
+ ? `[tool error: ${block.content}]`
26
+ : `[tool result: ${block.content}]`
27
+ })
28
+ .join('\n')
29
+ }
30
+
31
+ export function approximateTokens(messages: Message[]): number {
32
+ let chars = 0
33
+ for (const m of messages) chars += messageTextContent(m).length
34
+ return Math.ceil(chars / 4)
35
+ }