ethagent 3.0.2 → 3.1.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 +6 -1
- package/package.json +3 -1
- package/src/app/FirstRun.tsx +1 -24
- package/src/app/firstRunConfig.ts +26 -0
- package/src/auth/openaiOAuth/landingPage.ts +2 -11
- package/src/chat/ChatScreen.tsx +15 -116
- package/src/chat/MessageList.tsx +18 -260
- package/src/chat/chatEnvironment.ts +16 -0
- package/src/chat/chatTurnContext.ts +50 -0
- package/src/chat/chatTurnOrchestrator.ts +5 -112
- package/src/chat/chatTurnRows.ts +64 -0
- package/src/chat/commands.ts +3 -178
- package/src/chat/continuityEditReview.ts +42 -0
- package/src/chat/input/ChatInput.tsx +10 -144
- package/src/chat/input/chatInputHelpers.ts +62 -0
- package/src/chat/input/inputRendering.tsx +93 -0
- package/src/chat/messageMarkdown.ts +220 -0
- package/src/chat/messageRows.ts +43 -0
- package/src/chat/planImplementation.ts +62 -0
- package/src/chat/slashCommandHandlers.ts +165 -0
- package/src/chat/slashCommandViews.ts +120 -0
- package/src/identity/continuity/challenges.ts +123 -0
- package/src/identity/continuity/envelope.ts +49 -1484
- package/src/identity/continuity/envelopeCreate.ts +322 -0
- package/src/identity/continuity/envelopeCrypto.ts +182 -0
- package/src/identity/continuity/envelopeParse.ts +441 -0
- package/src/identity/continuity/envelopeTypes.ts +204 -0
- package/src/identity/continuity/envelopeVersion.ts +1 -0
- package/src/identity/continuity/payloadNormalization.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +12 -69
- package/src/identity/continuity/skills/skillPaths.ts +76 -0
- package/src/identity/continuity/skillsNormalization.ts +119 -0
- package/src/identity/continuity/snapshotToken.ts +28 -0
- package/src/identity/hub/continuity/completion.ts +67 -0
- package/src/identity/hub/continuity/effects.ts +5 -62
- package/src/identity/hub/profile/effects.ts +6 -170
- package/src/identity/hub/profile/operatorSave.ts +202 -0
- package/src/identity/wallet/browserWallet/html.ts +1 -57
- package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
- package/src/identity/wallet/page/controller.ts +1 -1
- package/src/identity/wallet/page/errorView.ts +122 -0
- package/src/identity/wallet/page/view.ts +3 -114
- package/src/mcp/manager.ts +8 -66
- package/src/mcp/managerHelpers.ts +70 -0
- package/src/models/ModelPicker.tsx +69 -889
- package/src/models/huggingface.ts +20 -137
- package/src/models/huggingfaceStorage.ts +136 -0
- package/src/models/llamacpp.ts +37 -303
- package/src/models/llamacppCommands.ts +44 -0
- package/src/models/llamacppConfig.ts +34 -0
- package/src/models/llamacppDiscovery.ts +176 -0
- package/src/models/llamacppOutput.ts +65 -0
- package/src/models/modelPickerCatalogFlow.ts +56 -0
- package/src/models/modelPickerCredentials.ts +166 -0
- package/src/models/modelPickerData.ts +41 -0
- package/src/models/modelPickerDisplay.tsx +132 -0
- package/src/models/modelPickerHfFlow.ts +192 -0
- package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
- package/src/models/modelPickerTypes.ts +69 -0
- package/src/models/modelPickerUninstallFlow.ts +48 -0
- package/src/models/modelPickerViewHelpers.ts +174 -0
- package/src/providers/openai-chat.ts +5 -124
- package/src/providers/openaiChatWire.ts +124 -0
- package/src/runtime/providerTurn.ts +38 -0
- package/src/runtime/textToolParser.ts +161 -0
- package/src/runtime/toolIntent.ts +1 -1
- package/src/runtime/turn.ts +43 -499
- package/src/runtime/turnNudges.ts +223 -0
- package/src/runtime/turnTypes.ts +86 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Text } from 'ink'
|
|
3
|
+
import { theme } from '../../ui/theme.js'
|
|
4
|
+
import {
|
|
5
|
+
getVisibleVisualLineWindow,
|
|
6
|
+
getVisualLineIndex,
|
|
7
|
+
getVisualLines,
|
|
8
|
+
} from './textCursor.js'
|
|
9
|
+
|
|
10
|
+
const STACK_HORIZONTAL_PADDING = 2
|
|
11
|
+
const INPUT_BORDER_WIDTH = 2
|
|
12
|
+
const INPUT_HORIZONTAL_PADDING = 4
|
|
13
|
+
const PROMPT_PREFIX_WIDTH = 2
|
|
14
|
+
|
|
15
|
+
type RenderedVisualLine = {
|
|
16
|
+
visualLineIndex: number
|
|
17
|
+
node: React.ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RenderedInputViewport = {
|
|
21
|
+
lines: RenderedVisualLine[]
|
|
22
|
+
hiddenAbove: number
|
|
23
|
+
hiddenBelow: number
|
|
24
|
+
visibleLineCount: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderWithCursor(
|
|
28
|
+
value: string,
|
|
29
|
+
cursor: number,
|
|
30
|
+
showCursor: boolean,
|
|
31
|
+
wrapWidth: number,
|
|
32
|
+
maxVisibleLines: number,
|
|
33
|
+
): RenderedInputViewport {
|
|
34
|
+
const lines = getVisualLines(value, wrapWidth)
|
|
35
|
+
const cursorLine = getVisualLineIndex(lines, cursor)
|
|
36
|
+
const window = getVisibleVisualLineWindow(lines.length, cursorLine, maxVisibleLines)
|
|
37
|
+
const visibleLines = lines.slice(window.start, window.end)
|
|
38
|
+
|
|
39
|
+
if (!showCursor) {
|
|
40
|
+
return {
|
|
41
|
+
lines: visibleLines.map((line, i) => ({
|
|
42
|
+
visualLineIndex: window.start + i,
|
|
43
|
+
node: (
|
|
44
|
+
<Text color={theme.text} wrap="wrap">
|
|
45
|
+
{value.slice(line.start, line.end) || ' '}
|
|
46
|
+
</Text>
|
|
47
|
+
),
|
|
48
|
+
})),
|
|
49
|
+
hiddenAbove: window.start,
|
|
50
|
+
hiddenBelow: lines.length - window.end,
|
|
51
|
+
visibleLineCount: Math.max(1, visibleLines.length),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
lines: visibleLines.map((line, i) => {
|
|
57
|
+
const visualLineIndex = window.start + i
|
|
58
|
+
const text = value.slice(line.start, line.end)
|
|
59
|
+
if (visualLineIndex !== cursorLine) {
|
|
60
|
+
return {
|
|
61
|
+
visualLineIndex,
|
|
62
|
+
node: <Text color={theme.text} wrap="wrap">{text || ' '}</Text>,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const column = Math.max(0, Math.min(cursor - line.start, text.length))
|
|
66
|
+
const before = text.slice(0, column)
|
|
67
|
+
const atChar = text[column] ?? ' '
|
|
68
|
+
const after = text.slice(column + 1)
|
|
69
|
+
return {
|
|
70
|
+
visualLineIndex,
|
|
71
|
+
node: (
|
|
72
|
+
<Text color={theme.text} wrap="wrap">
|
|
73
|
+
{before}
|
|
74
|
+
<Text backgroundColor={theme.accentPeriwinkle} color="#0c0c1f">{atChar}</Text>
|
|
75
|
+
{after}
|
|
76
|
+
</Text>
|
|
77
|
+
),
|
|
78
|
+
}
|
|
79
|
+
}),
|
|
80
|
+
hiddenAbove: window.start,
|
|
81
|
+
hiddenBelow: lines.length - window.end,
|
|
82
|
+
visibleLineCount: Math.max(1, visibleLines.length),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function inputWrapWidth(columns: number): number {
|
|
87
|
+
const fixedChromeWidth =
|
|
88
|
+
STACK_HORIZONTAL_PADDING
|
|
89
|
+
+ INPUT_BORDER_WIDTH
|
|
90
|
+
+ INPUT_HORIZONTAL_PADDING
|
|
91
|
+
+ PROMPT_PREFIX_WIDTH
|
|
92
|
+
return Math.max(1, Math.floor(columns) - fixedChromeWidth)
|
|
93
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
export type MarkdownBlock =
|
|
2
|
+
| { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
|
|
3
|
+
| { kind: 'paragraph'; text: string }
|
|
4
|
+
| { kind: 'quote'; lines: string[] }
|
|
5
|
+
| { kind: 'list'; ordered: boolean; items: string[] }
|
|
6
|
+
| { kind: 'code'; lang: string | null; code: string; open?: boolean }
|
|
7
|
+
|
|
8
|
+
export type InlineToken =
|
|
9
|
+
| { kind: 'text'; text: string }
|
|
10
|
+
| { kind: 'bold'; text: string }
|
|
11
|
+
| { kind: 'italic'; text: string }
|
|
12
|
+
| { kind: 'code'; text: string }
|
|
13
|
+
|
|
14
|
+
const UNREADABLE_REASONING_TEXT = 'reasoning output was not readable text'
|
|
15
|
+
|
|
16
|
+
export function blockContentWidth(lines: string[]): number {
|
|
17
|
+
return Math.max(1, ...lines.map(displayWidth))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function displayWidth(line: string): number {
|
|
21
|
+
return (line || ' ').replace(/\t/g, ' ').length
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
|
|
25
|
+
const text = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
26
|
+
if (!text.trim()) return []
|
|
27
|
+
|
|
28
|
+
const blocks: MarkdownBlock[] = []
|
|
29
|
+
const lines = text.split('\n')
|
|
30
|
+
let index = 0
|
|
31
|
+
|
|
32
|
+
while (index < lines.length) {
|
|
33
|
+
const line = lines[index] ?? ''
|
|
34
|
+
const trimmed = line.trim()
|
|
35
|
+
|
|
36
|
+
if (!trimmed) {
|
|
37
|
+
index += 1
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const fence = trimmed.match(/^```([\w+-]*)\s*$/)
|
|
42
|
+
if (fence) {
|
|
43
|
+
const lang = fence[1] && fence[1].length > 0 ? fence[1] : null
|
|
44
|
+
index += 1
|
|
45
|
+
const body: string[] = []
|
|
46
|
+
let closed = false
|
|
47
|
+
while (index < lines.length) {
|
|
48
|
+
const nextLine = lines[index] ?? ''
|
|
49
|
+
if (nextLine.trim().match(/^```\s*$/)) {
|
|
50
|
+
closed = true
|
|
51
|
+
index += 1
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
body.push(nextLine)
|
|
55
|
+
index += 1
|
|
56
|
+
}
|
|
57
|
+
blocks.push({ kind: 'code', lang, code: body.join('\n'), open: !closed })
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const heading = line.match(/^(#{1,6})\s+(.*)$/)
|
|
62
|
+
if (heading) {
|
|
63
|
+
const [, hashes = '#', headingText = ''] = heading
|
|
64
|
+
blocks.push({
|
|
65
|
+
kind: 'heading',
|
|
66
|
+
level: hashes.length as 1 | 2 | 3 | 4 | 5 | 6,
|
|
67
|
+
text: headingText.trim(),
|
|
68
|
+
})
|
|
69
|
+
index += 1
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/^>\s?/.test(trimmed)) {
|
|
74
|
+
const quoteLines: string[] = []
|
|
75
|
+
while (index < lines.length) {
|
|
76
|
+
const nextLine = lines[index] ?? ''
|
|
77
|
+
if (!/^>\s?/.test(nextLine.trim())) break
|
|
78
|
+
quoteLines.push(nextLine.trim().replace(/^>\s?/, ''))
|
|
79
|
+
index += 1
|
|
80
|
+
}
|
|
81
|
+
blocks.push({ kind: 'quote', lines: quoteLines })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const ordered = trimmed.match(/^\d+\.\s+(.*)$/)
|
|
86
|
+
const unordered = trimmed.match(/^[-*+]\s+(.*)$/)
|
|
87
|
+
if (ordered || unordered) {
|
|
88
|
+
const items: string[] = []
|
|
89
|
+
const orderedList = Boolean(ordered)
|
|
90
|
+
while (index < lines.length) {
|
|
91
|
+
const nextLine = lines[index] ?? ''
|
|
92
|
+
const match = orderedList
|
|
93
|
+
? nextLine.trim().match(/^\d+\.\s+(.*)$/)
|
|
94
|
+
: nextLine.trim().match(/^[-*+]\s+(.*)$/)
|
|
95
|
+
if (!match) break
|
|
96
|
+
items.push(match[1] ?? '')
|
|
97
|
+
index += 1
|
|
98
|
+
}
|
|
99
|
+
blocks.push({ kind: 'list', ordered: orderedList, items })
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const paragraph: string[] = []
|
|
104
|
+
while (index < lines.length) {
|
|
105
|
+
const nextLine = lines[index] ?? ''
|
|
106
|
+
const nextTrimmed = nextLine.trim()
|
|
107
|
+
if (!nextTrimmed) break
|
|
108
|
+
if (nextTrimmed.match(/^```([\w+-]*)\s*$/)) break
|
|
109
|
+
if (nextLine.match(/^(#{1,6})\s+(.*)$/)) break
|
|
110
|
+
if (/^>\s?/.test(nextTrimmed)) break
|
|
111
|
+
if (nextTrimmed.match(/^\d+\.\s+(.*)$/) || nextTrimmed.match(/^[-*+]\s+(.*)$/)) break
|
|
112
|
+
paragraph.push(nextLine)
|
|
113
|
+
index += 1
|
|
114
|
+
}
|
|
115
|
+
blocks.push({ kind: 'paragraph', text: paragraph.join('\n').trim() })
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return blocks
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function parseInlineTokens(text: string): InlineToken[] {
|
|
122
|
+
const tokens: InlineToken[] = []
|
|
123
|
+
const source = normalizeInlineDisplayText(text)
|
|
124
|
+
const pattern = /(`[^`\n]+`|\*\*[^*\n]+?\*\*|__[^_\n]+?__|\*[^*\n]+?\*|_[^_\n]+?_)/g
|
|
125
|
+
let lastIndex = 0
|
|
126
|
+
let match: RegExpExecArray | null
|
|
127
|
+
|
|
128
|
+
while ((match = pattern.exec(source)) !== null) {
|
|
129
|
+
if (match.index > lastIndex) {
|
|
130
|
+
tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex, match.index)) })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const token = match[0]
|
|
134
|
+
if ((token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__'))) {
|
|
135
|
+
tokens.push({ kind: 'bold', text: cleanPlainInlineText(token.slice(2, -2)) })
|
|
136
|
+
} else if ((token.startsWith('*') && token.endsWith('*')) || (token.startsWith('_') && token.endsWith('_'))) {
|
|
137
|
+
tokens.push({ kind: 'italic', text: cleanPlainInlineText(token.slice(1, -1)) })
|
|
138
|
+
} else if (token.startsWith('`') && token.endsWith('`')) {
|
|
139
|
+
tokens.push({ kind: 'code', text: token.slice(1, -1) })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
lastIndex = match.index + token.length
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (lastIndex < source.length || tokens.length === 0) {
|
|
146
|
+
tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex)) })
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return tokens.filter(token => token.text.length > 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeInlineDisplayText(text: string): string {
|
|
153
|
+
return text
|
|
154
|
+
.replace(/\\\(/g, '')
|
|
155
|
+
.replace(/\\\)/g, '')
|
|
156
|
+
.replace(/\\\[/g, '')
|
|
157
|
+
.replace(/\\\]/g, '')
|
|
158
|
+
.replace(/\$\$([^$]+)\$\$/g, '$1')
|
|
159
|
+
.replace(/\$([^$\n]+)\$/g, '$1')
|
|
160
|
+
.replace(/\\([{}[\]()])/g, '$1')
|
|
161
|
+
.replace(/\/([{}])/g, '$1')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cleanPlainInlineText(text: string): string {
|
|
165
|
+
return text.replace(/\*+/g, '')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function summarizeThinking(text: string): string {
|
|
169
|
+
const sample = text.length > 1000 ? text.slice(-1000) : text
|
|
170
|
+
const normalized = sample.replace(/\s+/g, ' ').trim()
|
|
171
|
+
if (!normalized) return ''
|
|
172
|
+
const prefix = text.length > sample.length ? '...' : ''
|
|
173
|
+
if (normalized.length + prefix.length <= 120) return `${prefix}${normalized}`
|
|
174
|
+
return `${prefix}${normalized.slice(Math.max(0, normalized.length - (120 - prefix.length)))}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function clipTextForDisplay(text: string, maxChars: number): { text: string; omittedChars: number } {
|
|
178
|
+
if (text.length <= maxChars) return { text, omittedChars: 0 }
|
|
179
|
+
const rawStart = Math.max(0, text.length - maxChars)
|
|
180
|
+
const newline = text.indexOf('\n', rawStart)
|
|
181
|
+
const start = newline >= 0 && newline - rawStart <= 240 ? newline + 1 : rawStart
|
|
182
|
+
return {
|
|
183
|
+
text: text.slice(start),
|
|
184
|
+
omittedChars: start,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function sanitizeReasoningForDisplay(text: string): string {
|
|
189
|
+
const normalized = text
|
|
190
|
+
.replace(/\r\n/g, '\n')
|
|
191
|
+
.replace(/\r/g, '\n')
|
|
192
|
+
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
|
193
|
+
const controlCount = countMatches(normalized, /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g)
|
|
194
|
+
const cleaned = normalized
|
|
195
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g, '')
|
|
196
|
+
.replace(/\t/g, ' ')
|
|
197
|
+
const visibleLength = cleaned.replace(/\s/g, '').length
|
|
198
|
+
if (visibleLength === 0) return ''
|
|
199
|
+
if (controlCount > 0 && controlCount / Math.max(1, text.length) > 0.05) return UNREADABLE_REASONING_TEXT
|
|
200
|
+
if (looksLikeUnreadableReasoning(cleaned)) return UNREADABLE_REASONING_TEXT
|
|
201
|
+
return cleaned
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function looksLikeUnreadableReasoning(text: string): boolean {
|
|
205
|
+
const visible = text.replace(/\s/g, '')
|
|
206
|
+
if (visible.length < 120) return false
|
|
207
|
+
const letters = countMatches(visible, /[A-Za-z]/g)
|
|
208
|
+
const digits = countMatches(visible, /\d/g)
|
|
209
|
+
const words = text.match(/[A-Za-z]{3,}/g) ?? []
|
|
210
|
+
const wordChars = words.reduce((sum, word) => sum + word.length, 0)
|
|
211
|
+
const whitespace = countMatches(text, /\s/g)
|
|
212
|
+
const symbolDensity = (visible.length - letters - digits) / visible.length
|
|
213
|
+
const wordDensity = wordChars / visible.length
|
|
214
|
+
const whitespaceDensity = whitespace / Math.max(1, text.length)
|
|
215
|
+
return symbolDensity > 0.38 && wordDensity < 0.32 && whitespaceDensity < 0.12
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function countMatches(text: string, pattern: RegExp): number {
|
|
219
|
+
return text.match(pattern)?.length ?? 0
|
|
220
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RowSlice } from './transcript/transcriptViewport.js'
|
|
2
|
+
import type { MessageRow } from './MessageList.js'
|
|
3
|
+
|
|
4
|
+
export function rowsToFullSlices(rows: MessageRow[]): Array<RowSlice<MessageRow>> {
|
|
5
|
+
return rows.map(row => ({ row, clipStart: 0, clipEnd: Number.MAX_SAFE_INTEGER, rowHeight: Number.MAX_SAFE_INTEGER }))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function toggleLatestReasoningRow(rows: MessageRow[]): MessageRow[] {
|
|
9
|
+
return toggleInspectableRow(rows)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function toggleReasoningRow(rows: MessageRow[], rowId?: string): MessageRow[] {
|
|
13
|
+
return toggleInspectableRow(rows, rowId)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function toggleInspectableRow(rows: MessageRow[], rowId?: string): MessageRow[] {
|
|
17
|
+
let index = -1
|
|
18
|
+
if (rowId) {
|
|
19
|
+
index = rows.findIndex(row => row.id === rowId && isInspectableRole(row.role))
|
|
20
|
+
}
|
|
21
|
+
if (index === -1) {
|
|
22
|
+
for (let cursor = rows.length - 1; cursor >= 0; cursor -= 1) {
|
|
23
|
+
const role = rows[cursor]?.role
|
|
24
|
+
if (role && isInspectableRole(role)) {
|
|
25
|
+
index = cursor
|
|
26
|
+
break
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (index === -1) return rows
|
|
31
|
+
const row = rows[index]
|
|
32
|
+
if (!row) return rows
|
|
33
|
+
if (row.role === 'thinking') {
|
|
34
|
+
const next = rows.slice()
|
|
35
|
+
next[index] = { ...row, expanded: !row.expanded }
|
|
36
|
+
return next
|
|
37
|
+
}
|
|
38
|
+
return rows
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isInspectableRole(role: MessageRow['role']): boolean {
|
|
42
|
+
return role === 'thinking'
|
|
43
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { SessionMessage } from '../storage/sessions.js'
|
|
2
|
+
|
|
3
|
+
const MAX_HANDOFF_SUMMARY_CHARS = 12_000
|
|
4
|
+
|
|
5
|
+
export function chatFooterShortcutText(_canScrollTranscript: boolean): string {
|
|
6
|
+
return 'alt+p model · alt+i identity'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildPlanImplementationPrompt(plan: string): string {
|
|
10
|
+
return [
|
|
11
|
+
'Implement the approved plan below.',
|
|
12
|
+
'',
|
|
13
|
+
'Use native ethagent tools directly. Do not translate tool names into shell commands.',
|
|
14
|
+
'For workspace inspection, call list_directory and read_file directly.',
|
|
15
|
+
'For file creation or edits, call edit_file directly.',
|
|
16
|
+
'Use run_bash only for an actual shell command that cannot be performed by a narrower native tool, such as starting a local server after files exist.',
|
|
17
|
+
'Ignore any plan wording that says to execute file work as a Bash script or directly in the terminal; the native tools above are authoritative.',
|
|
18
|
+
'Read the relevant files before editing, make the required changes, and verify the result when possible.',
|
|
19
|
+
'',
|
|
20
|
+
plan,
|
|
21
|
+
].join('\n')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildPlanTransferSeedMessages(args: {
|
|
25
|
+
sourceSessionId: string
|
|
26
|
+
summary: string
|
|
27
|
+
plan: string
|
|
28
|
+
createdAt: string
|
|
29
|
+
}): SessionMessage[] {
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
role: 'user',
|
|
33
|
+
synthetic: true,
|
|
34
|
+
content: [
|
|
35
|
+
`Planning handoff from ${args.sourceSessionId.slice(0, 8)}:`,
|
|
36
|
+
'',
|
|
37
|
+
args.summary.trim(),
|
|
38
|
+
].join('\n'),
|
|
39
|
+
createdAt: args.createdAt,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
role: 'user',
|
|
43
|
+
synthetic: true,
|
|
44
|
+
content: [
|
|
45
|
+
'Approved plan to implement:',
|
|
46
|
+
'',
|
|
47
|
+
args.plan.trim(),
|
|
48
|
+
].join('\n'),
|
|
49
|
+
createdAt: args.createdAt,
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function normalizeHandoffSummary(summary: string): string {
|
|
55
|
+
const trimmed = summary.trim()
|
|
56
|
+
if (trimmed.length <= MAX_HANDOFF_SUMMARY_CHARS) return trimmed
|
|
57
|
+
return [
|
|
58
|
+
trimmed.slice(0, MAX_HANDOFF_SUMMARY_CHARS - 96).trimEnd(),
|
|
59
|
+
'',
|
|
60
|
+
'[handoff truncated to keep the resumed conversation responsive]',
|
|
61
|
+
].join('\n')
|
|
62
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { clearIdentity, getIdentityStatus } from '../storage/identity.js'
|
|
2
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
3
|
+
import { loadLocalHfModels } from '../models/huggingface.js'
|
|
4
|
+
import type { SlashContext, SlashResult } from './commands.js'
|
|
5
|
+
import { formatBytes } from './slashCommandViews.js'
|
|
6
|
+
|
|
7
|
+
export async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
8
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
9
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
10
|
+
|
|
11
|
+
if (!sub || sub === 'installed') {
|
|
12
|
+
const installed = await loadLocalHfModels()
|
|
13
|
+
if (installed.length === 0) {
|
|
14
|
+
return {
|
|
15
|
+
kind: 'note',
|
|
16
|
+
variant: 'dim',
|
|
17
|
+
text: 'No local model files downloaded. Press Alt+P and choose "Add Local Model File".',
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const lines = installed.map(model => {
|
|
21
|
+
const marker = model.id === ctx.config.model && ctx.config.provider === 'llamacpp' ? '*' : ' '
|
|
22
|
+
const displayName = formatModelDisplayName('llamacpp', model.id, { displayName: model.displayName, maxLength: 64 })
|
|
23
|
+
return `${marker} ${displayName} ${formatBytes(model.sizeBytes)} ${model.risk}`
|
|
24
|
+
})
|
|
25
|
+
return { kind: 'note', text: ['installed Hugging Face models:', ...lines].join('\n') }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (sub === 'download' || sub === 'model') {
|
|
29
|
+
const link = tokens.slice(1).join(' ')
|
|
30
|
+
ctx.onModelPickerRequest()
|
|
31
|
+
return {
|
|
32
|
+
kind: 'note',
|
|
33
|
+
variant: 'dim',
|
|
34
|
+
text: link
|
|
35
|
+
? `Alt+P opened. Choose "Add Local Model File" and paste: ${link}`
|
|
36
|
+
: 'Alt+P opened. Choose "Add Local Model File" and paste the model URL or repo ID.',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
kind: 'note',
|
|
42
|
+
variant: 'error',
|
|
43
|
+
text: 'usage: /hf [installed|download <huggingface.co link or repo id>]',
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runMcp(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
48
|
+
if (!ctx.mcp) {
|
|
49
|
+
return { kind: 'note', variant: 'error', text: 'MCP runtime is not available in this session.' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
53
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
54
|
+
if (!sub || sub === 'status' || sub === 'list') {
|
|
55
|
+
return { kind: 'note', text: ctx.mcp.renderStatus(), variant: 'info' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (sub === 'approve') {
|
|
60
|
+
const name = tokens.slice(1).join(' ')
|
|
61
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp approve <server>' }
|
|
62
|
+
return { kind: 'note', text: await ctx.mcp.approveServer(name), variant: 'dim' }
|
|
63
|
+
}
|
|
64
|
+
if (sub === 'reject') {
|
|
65
|
+
const name = tokens.slice(1).join(' ')
|
|
66
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp reject <server>' }
|
|
67
|
+
return { kind: 'note', text: await ctx.mcp.rejectServer(name), variant: 'dim' }
|
|
68
|
+
}
|
|
69
|
+
if (sub === 'reconnect') {
|
|
70
|
+
const name = tokens.slice(1).join(' ') || undefined
|
|
71
|
+
return { kind: 'note', text: await ctx.mcp.reconnect(name), variant: 'dim' }
|
|
72
|
+
}
|
|
73
|
+
if (sub === 'enable' || sub === 'disable') {
|
|
74
|
+
const name = tokens.slice(1).join(' ')
|
|
75
|
+
if (!name) return { kind: 'note', variant: 'error', text: `usage: /mcp ${sub} <server>` }
|
|
76
|
+
return { kind: 'note', text: await ctx.mcp.setEnabled(name, sub === 'enable'), variant: 'dim' }
|
|
77
|
+
}
|
|
78
|
+
if (sub === 'add-json') {
|
|
79
|
+
const project = tokens[1] === '--project'
|
|
80
|
+
const nameIndex = project ? 2 : 1
|
|
81
|
+
const name = tokens[nameIndex]
|
|
82
|
+
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
83
|
+
const jsonStart = nthTokenStart(args, nameIndex + 1)
|
|
84
|
+
const json = jsonStart >= 0 ? args.slice(jsonStart).trim() : ''
|
|
85
|
+
if (!json) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
86
|
+
return { kind: 'note', text: await ctx.mcp.addJson(name, json, project ? 'project' : 'user'), variant: 'dim' }
|
|
87
|
+
}
|
|
88
|
+
} catch (err: unknown) {
|
|
89
|
+
return { kind: 'note', variant: 'error', text: `MCP failed: ${(err as Error).message}` }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
kind: 'note',
|
|
94
|
+
variant: 'error',
|
|
95
|
+
text: 'usage: /mcp [status|approve <server>|reject <server>|reconnect [server]|enable <server>|disable <server>|add-json [--project] <name> <json>]',
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
100
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
101
|
+
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
102
|
+
const rest = tokens.slice(1)
|
|
103
|
+
|
|
104
|
+
if (!sub) {
|
|
105
|
+
ctx.onIdentityRequest('manage')
|
|
106
|
+
return { kind: 'handled' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (sub === 'status') {
|
|
110
|
+
const status = await getIdentityStatus(ctx.config)
|
|
111
|
+
if (!status) {
|
|
112
|
+
return {
|
|
113
|
+
kind: 'note',
|
|
114
|
+
variant: 'dim',
|
|
115
|
+
text: 'No Ethereum identity set. Run /identity create to make one.',
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const lines = [
|
|
119
|
+
`address ${status.address}`,
|
|
120
|
+
`created ${status.createdAt}`,
|
|
121
|
+
`backend ${status.backend}`,
|
|
122
|
+
]
|
|
123
|
+
if (status.source) lines.push(`source ${status.source}`)
|
|
124
|
+
if (status.agentId) lines.push(`token #${status.agentId}`)
|
|
125
|
+
return { kind: 'note', text: lines.join('\n') }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (sub === 'create') {
|
|
129
|
+
ctx.onIdentityRequest('create')
|
|
130
|
+
return { kind: 'handled' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (sub === 'load') {
|
|
134
|
+
ctx.onIdentityRequest('load')
|
|
135
|
+
return { kind: 'handled' }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (sub === 'remove') {
|
|
139
|
+
if (rest[0] !== 'confirm') {
|
|
140
|
+
return {
|
|
141
|
+
kind: 'note',
|
|
142
|
+
variant: 'error',
|
|
143
|
+
text: 'Remove deletes local identity metadata and any legacy stored key. Re-run with: /identity remove confirm',
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const status = await getIdentityStatus(ctx.config)
|
|
147
|
+
if (!status) {
|
|
148
|
+
return { kind: 'note', variant: 'dim', text: 'No Ethereum identity to remove.' }
|
|
149
|
+
}
|
|
150
|
+
const next = await clearIdentity(ctx.config)
|
|
151
|
+
ctx.onReplaceConfig(next)
|
|
152
|
+
return { kind: 'note', text: `Removed identity ${status.address}.`, variant: 'dim' }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
kind: 'note',
|
|
157
|
+
variant: 'error',
|
|
158
|
+
text: 'usage: /identity [status|create|load|remove confirm]',
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nthTokenStart(value: string, tokenIndex: number): number {
|
|
163
|
+
const matches = [...value.matchAll(/\S+/g)]
|
|
164
|
+
return matches[tokenIndex]?.index ?? -1
|
|
165
|
+
}
|