ethagent 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cursorColumn,
|
|
3
|
+
cursorOnLastLineAtColumn,
|
|
4
|
+
getVisualLines,
|
|
5
|
+
moveVerticalVisualCursor,
|
|
6
|
+
moveVerticalCursor,
|
|
7
|
+
normalizeCursor,
|
|
8
|
+
} from './textCursor.js'
|
|
9
|
+
|
|
10
|
+
export type ChatBuffer = {
|
|
11
|
+
value: string
|
|
12
|
+
cursor: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type FileMentionToken = {
|
|
16
|
+
start: number
|
|
17
|
+
end: number
|
|
18
|
+
query: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HistoryPreviewState = {
|
|
22
|
+
historyIndex: number | null
|
|
23
|
+
historyPreviewActive: boolean
|
|
24
|
+
draftBuffer: ChatBuffer
|
|
25
|
+
preferredColumn: number | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type VerticalMoveResult =
|
|
29
|
+
| { kind: 'moved'; cursor: number; preferredColumn: number }
|
|
30
|
+
| { kind: 'boundary-top'; cursor: number; preferredColumn: number }
|
|
31
|
+
| { kind: 'boundary-bottom'; cursor: number; preferredColumn: number }
|
|
32
|
+
|
|
33
|
+
export function emptyBuffer(): ChatBuffer {
|
|
34
|
+
return { value: '', cursor: 0 }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function bufferFromValue(value: string, cursor = value.length): ChatBuffer {
|
|
38
|
+
const next = normalizeCursor(value, cursor)
|
|
39
|
+
return { value: next.value, cursor: next.offset }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function canNavigateHistory(
|
|
43
|
+
_buffer: ChatBuffer,
|
|
44
|
+
historyLength: number,
|
|
45
|
+
_historyIndex: number | null,
|
|
46
|
+
_historyPreviewActive: boolean,
|
|
47
|
+
): boolean {
|
|
48
|
+
return historyLength > 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function beginHistoryPreview(
|
|
52
|
+
buffer: ChatBuffer,
|
|
53
|
+
history: string[],
|
|
54
|
+
direction: 1 | -1,
|
|
55
|
+
_preferredColumn: number | null,
|
|
56
|
+
): { preview: HistoryPreviewState; buffer: ChatBuffer } | null {
|
|
57
|
+
if (history.length === 0) return null
|
|
58
|
+
const nextIndex = direction === -1 ? history.length - 1 : 0
|
|
59
|
+
const chosen = history[nextIndex] ?? ''
|
|
60
|
+
return {
|
|
61
|
+
preview: {
|
|
62
|
+
historyIndex: nextIndex,
|
|
63
|
+
historyPreviewActive: true,
|
|
64
|
+
draftBuffer: buffer,
|
|
65
|
+
preferredColumn: null,
|
|
66
|
+
},
|
|
67
|
+
buffer: bufferFromValue(chosen),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function moveThroughHistory(
|
|
72
|
+
history: string[],
|
|
73
|
+
historyIndex: number,
|
|
74
|
+
direction: 1 | -1,
|
|
75
|
+
draftBuffer: ChatBuffer,
|
|
76
|
+
preferredColumn: number | null,
|
|
77
|
+
): { preview: HistoryPreviewState; buffer: ChatBuffer } {
|
|
78
|
+
const next = historyIndex + direction
|
|
79
|
+
if (next < 0) {
|
|
80
|
+
const chosen = history[0] ?? ''
|
|
81
|
+
return {
|
|
82
|
+
preview: {
|
|
83
|
+
historyIndex: 0,
|
|
84
|
+
historyPreviewActive: true,
|
|
85
|
+
draftBuffer,
|
|
86
|
+
preferredColumn: null,
|
|
87
|
+
},
|
|
88
|
+
buffer: bufferFromValue(chosen),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (next >= history.length) {
|
|
92
|
+
return {
|
|
93
|
+
preview: {
|
|
94
|
+
historyIndex: null,
|
|
95
|
+
historyPreviewActive: false,
|
|
96
|
+
draftBuffer,
|
|
97
|
+
preferredColumn: null,
|
|
98
|
+
},
|
|
99
|
+
buffer: bufferFromValue(draftBuffer.value),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const chosen = history[next] ?? ''
|
|
103
|
+
return {
|
|
104
|
+
preview: {
|
|
105
|
+
historyIndex: next,
|
|
106
|
+
historyPreviewActive: true,
|
|
107
|
+
draftBuffer,
|
|
108
|
+
preferredColumn: null,
|
|
109
|
+
},
|
|
110
|
+
buffer: bufferFromValue(chosen),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function exitHistoryPreview(
|
|
115
|
+
buffer: ChatBuffer,
|
|
116
|
+
): { historyIndex: null; historyPreviewActive: false; draftBuffer: ChatBuffer; preferredColumn: null } {
|
|
117
|
+
return {
|
|
118
|
+
historyIndex: null,
|
|
119
|
+
historyPreviewActive: false,
|
|
120
|
+
draftBuffer: buffer,
|
|
121
|
+
preferredColumn: null,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function bufferFromLastLine(value: string, preferredColumn: number | null = null): ChatBuffer {
|
|
126
|
+
const next = cursorOnLastLineAtColumn(value, preferredColumn ?? 0)
|
|
127
|
+
return { value: next.value, cursor: next.offset }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function deleteToLineStart(buffer: ChatBuffer, wrapWidth: number): ChatBuffer {
|
|
131
|
+
const normalized = normalizeCursor(buffer.value, buffer.cursor)
|
|
132
|
+
const { value, offset } = normalized
|
|
133
|
+
if (offset <= 0) return { value, cursor: 0 }
|
|
134
|
+
|
|
135
|
+
if (value[offset - 1] === '\n') {
|
|
136
|
+
return {
|
|
137
|
+
value: value.slice(0, offset - 1) + value.slice(offset),
|
|
138
|
+
cursor: offset - 1,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lineStart = visualLineStart(value, offset, wrapWidth)
|
|
143
|
+
return {
|
|
144
|
+
value: value.slice(0, lineStart) + value.slice(offset),
|
|
145
|
+
cursor: lineStart,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function moveVertical(
|
|
150
|
+
text: string,
|
|
151
|
+
cursor: number,
|
|
152
|
+
direction: 1 | -1,
|
|
153
|
+
preferredColumn: number | null = null,
|
|
154
|
+
): VerticalMoveResult {
|
|
155
|
+
const normalized = normalizeCursor(text, cursor)
|
|
156
|
+
const nextColumn = preferredColumn ?? cursorColumn(normalized.value, normalized.offset)
|
|
157
|
+
const moved = moveVerticalCursor(normalized, direction, nextColumn)
|
|
158
|
+
if (moved.moved) {
|
|
159
|
+
return { kind: 'moved', cursor: moved.cursor.offset, preferredColumn: nextColumn }
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
kind: direction === -1 ? 'boundary-top' : 'boundary-bottom',
|
|
163
|
+
cursor: normalized.offset,
|
|
164
|
+
preferredColumn: nextColumn,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function moveVerticalVisual(
|
|
169
|
+
text: string,
|
|
170
|
+
cursor: number,
|
|
171
|
+
direction: 1 | -1,
|
|
172
|
+
wrapWidth: number,
|
|
173
|
+
preferredColumn: number | null = null,
|
|
174
|
+
): VerticalMoveResult {
|
|
175
|
+
const normalized = normalizeCursor(text, cursor)
|
|
176
|
+
const position = visualPosition(normalized.value, normalized.offset, wrapWidth)
|
|
177
|
+
const nextColumn = preferredColumn ?? position.column
|
|
178
|
+
const moved = moveVerticalVisualCursor(normalized, direction, wrapWidth, nextColumn)
|
|
179
|
+
if (moved.moved) {
|
|
180
|
+
return { kind: 'moved', cursor: moved.cursor.offset, preferredColumn: nextColumn }
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
kind: direction === -1 ? 'boundary-top' : 'boundary-bottom',
|
|
184
|
+
cursor: normalized.offset,
|
|
185
|
+
preferredColumn: nextColumn,
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function detectActiveFileMention(value: string, cursor: number): FileMentionToken | undefined {
|
|
190
|
+
const safeCursor = Math.max(0, Math.min(cursor, value.length))
|
|
191
|
+
const left = value.slice(0, safeCursor)
|
|
192
|
+
const atIndex = left.lastIndexOf('@')
|
|
193
|
+
if (atIndex === -1) return undefined
|
|
194
|
+
const before = atIndex === 0 ? '' : value[atIndex - 1]
|
|
195
|
+
if (before && !/\s|\(|\[|\{/.test(before)) return undefined
|
|
196
|
+
const between = value.slice(atIndex + 1, safeCursor)
|
|
197
|
+
if (/\s/.test(between)) return undefined
|
|
198
|
+
let end = safeCursor
|
|
199
|
+
while (end < value.length && !/\s/.test(value[end]!)) end += 1
|
|
200
|
+
return { start: atIndex, end, query: between }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function replaceActiveFileMention(buffer: ChatBuffer, replacementPath: string): ChatBuffer {
|
|
204
|
+
const mention = detectActiveFileMention(buffer.value, buffer.cursor)
|
|
205
|
+
if (!mention) return buffer
|
|
206
|
+
const replacement = `@${replacementPath}`
|
|
207
|
+
const value = buffer.value.slice(0, mention.start) + replacement + buffer.value.slice(mention.end)
|
|
208
|
+
return { value, cursor: mention.start + replacement.length }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function visualPosition(value: string, offset: number, wrapWidth: number): { column: number } {
|
|
212
|
+
const lines = getVisualLines(value, wrapWidth)
|
|
213
|
+
for (const entry of lines) {
|
|
214
|
+
if (entry.start === entry.end && offset === entry.start) {
|
|
215
|
+
return { column: 0 }
|
|
216
|
+
}
|
|
217
|
+
if (offset >= entry.start && offset < entry.end) {
|
|
218
|
+
return { column: offset - entry.start }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
223
|
+
const entry = lines[index]!
|
|
224
|
+
if (offset === entry.end) {
|
|
225
|
+
return { column: entry.end - entry.start }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { column: 0 }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function visualLineStart(value: string, offset: number, wrapWidth: number): number {
|
|
233
|
+
const lines = getVisualLines(value, wrapWidth)
|
|
234
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
235
|
+
const entry = lines[index]!
|
|
236
|
+
if (entry.start === entry.end && offset === entry.start) return entry.start
|
|
237
|
+
if (offset > entry.start && offset <= entry.end) return entry.start
|
|
238
|
+
if (offset === entry.start) {
|
|
239
|
+
const previous = lines[index - 1]
|
|
240
|
+
if (previous && previous.end === entry.start) return previous.start
|
|
241
|
+
return entry.start
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const last = lines[lines.length - 1]
|
|
246
|
+
return last ? last.start : 0
|
|
247
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const LARGE_PASTE_THRESHOLD = 800
|
|
2
|
+
|
|
3
|
+
export type PastedTextRef = {
|
|
4
|
+
id: number
|
|
5
|
+
content: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizePastedText(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replaceAll('\x1b[200~', '')
|
|
11
|
+
.replaceAll('\x1b[201~', '')
|
|
12
|
+
.replace(/(^|\n)(?:\[?200~|\[?201~)(?=\n|$)/g, '$1')
|
|
13
|
+
.replace(/\r\n/g, '\n')
|
|
14
|
+
.replace(/\r/g, '\n')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function shouldCollapsePastedText(
|
|
18
|
+
text: string,
|
|
19
|
+
maxInlineLines: number,
|
|
20
|
+
threshold = LARGE_PASTE_THRESHOLD,
|
|
21
|
+
): boolean {
|
|
22
|
+
return text.length > threshold || countPastedTextLineBreaks(text) > maxInlineLines
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function countPastedTextLineBreaks(text: string): number {
|
|
26
|
+
return (text.match(/\n/g) ?? []).length
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatPastedTextRef(id: number, chars: number): string {
|
|
30
|
+
return `[Pasted Content ${chars} chars #${id}]`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function expandPastedTextRefs(input: string, refs: Map<number, PastedTextRef>): string {
|
|
34
|
+
const matches = [...input.matchAll(/\[Pasted Content (\d+) chars #(\d+)\]/g)]
|
|
35
|
+
let expanded = input
|
|
36
|
+
|
|
37
|
+
for (let index = matches.length - 1; index >= 0; index -= 1) {
|
|
38
|
+
const match = matches[index]!
|
|
39
|
+
const id = Number(match[2])
|
|
40
|
+
const ref = refs.get(id)
|
|
41
|
+
if (!ref) continue
|
|
42
|
+
expanded =
|
|
43
|
+
expanded.slice(0, match.index) +
|
|
44
|
+
ref.content +
|
|
45
|
+
expanded.slice((match.index ?? 0) + match[0].length)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return expanded
|
|
49
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { systemMessage } from '../utils/messages.js'
|
|
2
|
+
import { buildSystemPrompt } from '../runtime/systemPrompt.js'
|
|
3
|
+
import type { Message } from '../providers/contracts.js'
|
|
4
|
+
import { isLocalProvider } from '../providers/registry.js'
|
|
5
|
+
import type { SessionMode } from '../runtime/sessionMode.js'
|
|
6
|
+
import type { EthagentConfig } from '../storage/config.js'
|
|
7
|
+
import {
|
|
8
|
+
latestUserMessageCorrectsToolState,
|
|
9
|
+
sessionMessagesToProviderMessages,
|
|
10
|
+
TOOL_CORRECTION_CONTEXT_MESSAGE,
|
|
11
|
+
type SessionMessage,
|
|
12
|
+
} from '../storage/sessions.js'
|
|
13
|
+
import type { MessageRow } from './MessageList.js'
|
|
14
|
+
import { hidesSuccessfulToolResultContent } from './toolResultDisplay.js'
|
|
15
|
+
|
|
16
|
+
export type TurnCheckpoint = {
|
|
17
|
+
sessionId: string
|
|
18
|
+
turnId: string
|
|
19
|
+
messageRole: 'user'
|
|
20
|
+
promptSnippet: string
|
|
21
|
+
checkpointLabel: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildBaseMessages(
|
|
25
|
+
sessionMessages: SessionMessage[],
|
|
26
|
+
config: EthagentConfig,
|
|
27
|
+
hasTools: boolean,
|
|
28
|
+
cwd: string,
|
|
29
|
+
mode: SessionMode = 'chat',
|
|
30
|
+
options: { preserveTurnId?: string; compactToolHistory?: boolean } = {},
|
|
31
|
+
): Message[] {
|
|
32
|
+
const compactToolHistory = options.compactToolHistory ?? isLocalProvider(config.provider)
|
|
33
|
+
const correctionContext = latestUserMessageCorrectsToolState(sessionMessages)
|
|
34
|
+
? [systemMessage(TOOL_CORRECTION_CONTEXT_MESSAGE)]
|
|
35
|
+
: []
|
|
36
|
+
return [
|
|
37
|
+
systemMessage(buildSystemPrompt({
|
|
38
|
+
cwd,
|
|
39
|
+
model: config.model,
|
|
40
|
+
provider: config.provider,
|
|
41
|
+
hasTools,
|
|
42
|
+
hasIdentity: Boolean(config.identity),
|
|
43
|
+
mode,
|
|
44
|
+
})),
|
|
45
|
+
...correctionContext,
|
|
46
|
+
...sessionMessagesToProviderMessages(sessionMessages, {
|
|
47
|
+
compactToolHistory,
|
|
48
|
+
preserveTurnId: options.preserveTurnId,
|
|
49
|
+
}),
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: () => string): MessageRow[] {
|
|
54
|
+
const restored: MessageRow[] = []
|
|
55
|
+
for (const msg of messages) {
|
|
56
|
+
if (msg.role === 'user') restored.push({ role: 'user', id: nextRowId(), content: msg.content })
|
|
57
|
+
else if (msg.role === 'assistant') restored.push({ role: 'assistant', id: nextRowId(), content: msg.content })
|
|
58
|
+
else if (msg.role === 'tool_use') {
|
|
59
|
+
restored.push({
|
|
60
|
+
role: 'tool_use',
|
|
61
|
+
id: nextRowId(),
|
|
62
|
+
name: msg.name,
|
|
63
|
+
summary: msg.name,
|
|
64
|
+
input: summarizeToolInput(msg.input),
|
|
65
|
+
})
|
|
66
|
+
} else if (msg.role === 'tool_result') {
|
|
67
|
+
restored.push({
|
|
68
|
+
role: 'tool_result',
|
|
69
|
+
id: nextRowId(),
|
|
70
|
+
name: msg.name,
|
|
71
|
+
summary: msg.isError ? `${msg.name} failed` : `${msg.name} completed`,
|
|
72
|
+
content: toolResultContentForRow(msg.name, msg.content, msg.isError),
|
|
73
|
+
isError: msg.isError,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return restored
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function summarizeToolInput(input: Record<string, unknown>): string {
|
|
81
|
+
try {
|
|
82
|
+
const text = JSON.stringify(input)
|
|
83
|
+
if (text.length <= 160) return text
|
|
84
|
+
return `${text.slice(0, 157)}...`
|
|
85
|
+
} catch {
|
|
86
|
+
return '[unserializable input]'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function truncateForRow(text: string, max = 1200): string {
|
|
91
|
+
if (text.length <= max) return text
|
|
92
|
+
return `${text.slice(0, max - 3)}...`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function toolResultContentForRow(name: string, content: string, isError?: boolean): string {
|
|
96
|
+
return hidesSuccessfulToolResultContent(name, isError) ? '' : truncateForRow(content)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function splitStreamingContent(text: string): { committed: string; liveTail: string } {
|
|
100
|
+
if (!text) return { committed: '', liveTail: '' }
|
|
101
|
+
const boundary = findStableBoundary(text)
|
|
102
|
+
if (boundary <= 0 || boundary >= text.length) {
|
|
103
|
+
return { committed: boundary >= text.length ? text : '', liveTail: boundary >= text.length ? '' : text }
|
|
104
|
+
}
|
|
105
|
+
return { committed: text.slice(0, boundary), liveTail: text.slice(boundary) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function findStableBoundary(text: string): number {
|
|
109
|
+
let lastStructural = 0
|
|
110
|
+
let lastSentence = 0
|
|
111
|
+
let inFence = false
|
|
112
|
+
let offset = 0
|
|
113
|
+
const lines = text.match(/[^\n]*\n?|$/g)?.filter(Boolean) ?? []
|
|
114
|
+
|
|
115
|
+
for (const lineWithEnding of lines) {
|
|
116
|
+
const line = lineWithEnding.endsWith('\n') ? lineWithEnding.slice(0, -1) : lineWithEnding
|
|
117
|
+
const trimmed = line.trim()
|
|
118
|
+
const nextOffset = offset + lineWithEnding.length
|
|
119
|
+
|
|
120
|
+
if (/^```/.test(trimmed)) {
|
|
121
|
+
inFence = !inFence
|
|
122
|
+
if (!inFence) lastStructural = nextOffset
|
|
123
|
+
offset = nextOffset
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!inFence) {
|
|
128
|
+
if (!trimmed) {
|
|
129
|
+
lastStructural = nextOffset
|
|
130
|
+
} else if (/^(#{1,3}\s|>\s?|[-*+]\s|\d+\.\s)/.test(trimmed)) {
|
|
131
|
+
lastStructural = nextOffset
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let match: RegExpExecArray | null
|
|
135
|
+
const sentencePattern = /[.!?]["')\]]?(?=\s|$)/g
|
|
136
|
+
while ((match = sentencePattern.exec(line)) !== null) {
|
|
137
|
+
lastSentence = offset + match.index + match[0].length
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
offset = nextOffset
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (inFence) return lastStructural
|
|
145
|
+
if (lastStructural > 0) return lastStructural
|
|
146
|
+
if (text.length > 220 && lastSentence > 0) return lastSentence
|
|
147
|
+
if (text.length > 320) {
|
|
148
|
+
const fallbackSpace = text.lastIndexOf(' ', Math.max(160, text.length - 80))
|
|
149
|
+
if (fallbackSpace > 80) return fallbackSpace + 1
|
|
150
|
+
}
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function formatBytes(bytes: number): string {
|
|
155
|
+
if (bytes <= 0) return '0B'
|
|
156
|
+
const gb = bytes / (1024 * 1024 * 1024)
|
|
157
|
+
if (gb >= 1) return `${gb.toFixed(2)}GB`
|
|
158
|
+
const mb = bytes / (1024 * 1024)
|
|
159
|
+
if (mb >= 1) return `${mb.toFixed(0)}MB`
|
|
160
|
+
const kb = bytes / 1024
|
|
161
|
+
return `${kb.toFixed(0)}KB`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createTurnCheckpoint(sessionId: string, userText: string): TurnCheckpoint {
|
|
165
|
+
const promptSnippet = summarizePrompt(userText)
|
|
166
|
+
return {
|
|
167
|
+
sessionId,
|
|
168
|
+
turnId: `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
169
|
+
messageRole: 'user',
|
|
170
|
+
promptSnippet,
|
|
171
|
+
checkpointLabel: promptSnippet || 'Untitled checkpoint',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function summarizePrompt(text: string): string {
|
|
176
|
+
const normalized = text.replace(/\s+/g, ' ').trim()
|
|
177
|
+
if (!normalized) return ''
|
|
178
|
+
return normalized.length <= 96 ? normalized : `${normalized.slice(0, 93)}...`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function buildDeleteCommand(targetPath: string): string {
|
|
182
|
+
const escaped = targetPath.replace(/"/g, '""')
|
|
183
|
+
if (process.platform === 'win32') {
|
|
184
|
+
return `del /f /q "${escaped}"`
|
|
185
|
+
}
|
|
186
|
+
return `rm -f -- "${targetPath.replace(/"/g, '\\"')}"`
|
|
187
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { localProviderBaseUrlFor, type EthagentConfig } from '../storage/config.js'
|
|
2
|
+
import type { SessionMetadata, SessionMessage } from '../storage/sessions.js'
|
|
3
|
+
import type { SessionMode } from '../runtime/sessionMode.js'
|
|
4
|
+
import type { MessageRow } from './MessageList.js'
|
|
5
|
+
import type { ModelPickerSelection } from '../models/ModelPicker.js'
|
|
6
|
+
import { sessionMessagesToRows } from './chatScreenUtils.js'
|
|
7
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
8
|
+
|
|
9
|
+
export type ModelSelectionResolution =
|
|
10
|
+
| { kind: 'noop' }
|
|
11
|
+
| {
|
|
12
|
+
kind: 'switch'
|
|
13
|
+
config: EthagentConfig
|
|
14
|
+
notice: string
|
|
15
|
+
tone: 'info' | 'dim'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ResumedSessionState = {
|
|
19
|
+
cwd: string
|
|
20
|
+
mode: SessionMode
|
|
21
|
+
rows: MessageRow[]
|
|
22
|
+
promptHistory: string[]
|
|
23
|
+
statusStartedAt: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_PROMPT_HISTORY = 500
|
|
27
|
+
|
|
28
|
+
export function resolveModelSelection(
|
|
29
|
+
selection: ModelPickerSelection,
|
|
30
|
+
currentConfig: EthagentConfig,
|
|
31
|
+
): ModelSelectionResolution {
|
|
32
|
+
if (selection.kind === 'llamacpp') {
|
|
33
|
+
const baseUrl = localProviderBaseUrlFor(
|
|
34
|
+
'llamacpp',
|
|
35
|
+
currentConfig.provider === 'llamacpp' ? currentConfig.baseUrl : undefined,
|
|
36
|
+
)
|
|
37
|
+
if (
|
|
38
|
+
selection.model === currentConfig.model
|
|
39
|
+
&& currentConfig.provider === 'llamacpp'
|
|
40
|
+
&& currentConfig.baseUrl === baseUrl
|
|
41
|
+
) {
|
|
42
|
+
return { kind: 'noop' }
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
kind: 'switch',
|
|
46
|
+
config: {
|
|
47
|
+
...currentConfig,
|
|
48
|
+
provider: 'llamacpp',
|
|
49
|
+
model: selection.model,
|
|
50
|
+
baseUrl,
|
|
51
|
+
},
|
|
52
|
+
notice: `local Hugging Face model ready. now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}.`,
|
|
53
|
+
tone: 'info',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nextProvider = selection.provider
|
|
58
|
+
const nextBaseUrl =
|
|
59
|
+
nextProvider === 'openai' && currentConfig.provider === 'openai'
|
|
60
|
+
? currentConfig.baseUrl
|
|
61
|
+
: undefined
|
|
62
|
+
const nextConfig: EthagentConfig = {
|
|
63
|
+
...currentConfig,
|
|
64
|
+
provider: nextProvider,
|
|
65
|
+
model: selection.model,
|
|
66
|
+
baseUrl: nextBaseUrl,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
kind: 'switch',
|
|
71
|
+
config: nextConfig,
|
|
72
|
+
notice: `${selection.keyJustSet ? `${selection.provider} key saved.` : `${selection.provider} ready.`} now using ${nextConfig.provider} · ${formatModelDisplayName(nextConfig.provider, nextConfig.model, { maxLength: 64 })}.`,
|
|
73
|
+
tone: 'dim',
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildResumedSessionState(args: {
|
|
78
|
+
messages: SessionMessage[]
|
|
79
|
+
metadata: SessionMetadata | null
|
|
80
|
+
fallbackCwd: string
|
|
81
|
+
nextRowId: () => string
|
|
82
|
+
}): ResumedSessionState {
|
|
83
|
+
const cwd = args.metadata?.lastCwd ?? args.metadata?.workspaceRoot ?? args.fallbackCwd
|
|
84
|
+
return {
|
|
85
|
+
cwd,
|
|
86
|
+
mode: args.metadata?.mode ?? 'chat',
|
|
87
|
+
rows: [
|
|
88
|
+
...sessionMessagesToRows(args.messages, args.nextRowId),
|
|
89
|
+
{
|
|
90
|
+
role: 'note',
|
|
91
|
+
id: args.nextRowId(),
|
|
92
|
+
kind: 'dim',
|
|
93
|
+
content: formatResumeNote(args.metadata),
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
promptHistory: promptHistoryFromSessionMessages(args.messages),
|
|
97
|
+
statusStartedAt: Date.now(),
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function formatResumeNote(metadata: SessionMetadata | null): string {
|
|
102
|
+
const id = metadata?.id?.slice(0, 8) ?? ''
|
|
103
|
+
const source = metadata?.compactedFromSessionId ? ` summarized from ${metadata.compactedFromSessionId.slice(0, 8)}` : ''
|
|
104
|
+
return `resumed from session ${id}.${source}`.trim()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function restoreConversationState(
|
|
108
|
+
messages: SessionMessage[],
|
|
109
|
+
turnId: string,
|
|
110
|
+
nextRowId: () => string,
|
|
111
|
+
): {
|
|
112
|
+
messages: SessionMessage[]
|
|
113
|
+
rows: MessageRow[]
|
|
114
|
+
promptHistory: string[]
|
|
115
|
+
truncated: boolean
|
|
116
|
+
} {
|
|
117
|
+
const firstIndex = messages.findIndex(message => message.turnId === turnId)
|
|
118
|
+
const nextMessages =
|
|
119
|
+
firstIndex >= 0
|
|
120
|
+
? messages.slice(0, firstIndex)
|
|
121
|
+
: messages.filter(message => message.turnId !== turnId)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
messages: nextMessages,
|
|
125
|
+
rows: sessionMessagesToRows(nextMessages, nextRowId),
|
|
126
|
+
promptHistory: promptHistoryFromSessionMessages(nextMessages),
|
|
127
|
+
truncated: firstIndex >= 0,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function promptHistoryFromSessionMessages(messages: SessionMessage[]): string[] {
|
|
132
|
+
const prompts: string[] = []
|
|
133
|
+
for (const message of messages) {
|
|
134
|
+
if (message.role !== 'user') continue
|
|
135
|
+
if (message.synthetic) continue
|
|
136
|
+
const prompt = message.content.trim()
|
|
137
|
+
if (!prompt) continue
|
|
138
|
+
if (prompts[prompts.length - 1] === prompt) continue
|
|
139
|
+
prompts.push(prompt)
|
|
140
|
+
}
|
|
141
|
+
return prompts.slice(-MAX_PROMPT_HISTORY)
|
|
142
|
+
}
|