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.
Files changed (69) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +15 -116
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/identity/continuity/challenges.ts +123 -0
  23. package/src/identity/continuity/envelope.ts +49 -1484
  24. package/src/identity/continuity/envelopeCreate.ts +322 -0
  25. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  26. package/src/identity/continuity/envelopeParse.ts +441 -0
  27. package/src/identity/continuity/envelopeTypes.ts +204 -0
  28. package/src/identity/continuity/envelopeVersion.ts +1 -0
  29. package/src/identity/continuity/payloadNormalization.ts +183 -0
  30. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  31. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  32. package/src/identity/continuity/skillsNormalization.ts +119 -0
  33. package/src/identity/continuity/snapshotToken.ts +28 -0
  34. package/src/identity/hub/continuity/completion.ts +67 -0
  35. package/src/identity/hub/continuity/effects.ts +5 -62
  36. package/src/identity/hub/profile/effects.ts +6 -170
  37. package/src/identity/hub/profile/operatorSave.ts +202 -0
  38. package/src/identity/wallet/browserWallet/html.ts +1 -57
  39. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  40. package/src/identity/wallet/page/controller.ts +1 -1
  41. package/src/identity/wallet/page/errorView.ts +122 -0
  42. package/src/identity/wallet/page/view.ts +3 -114
  43. package/src/mcp/manager.ts +8 -66
  44. package/src/mcp/managerHelpers.ts +70 -0
  45. package/src/models/ModelPicker.tsx +69 -889
  46. package/src/models/huggingface.ts +20 -137
  47. package/src/models/huggingfaceStorage.ts +136 -0
  48. package/src/models/llamacpp.ts +37 -303
  49. package/src/models/llamacppCommands.ts +44 -0
  50. package/src/models/llamacppConfig.ts +34 -0
  51. package/src/models/llamacppDiscovery.ts +176 -0
  52. package/src/models/llamacppOutput.ts +65 -0
  53. package/src/models/modelPickerCatalogFlow.ts +56 -0
  54. package/src/models/modelPickerCredentials.ts +166 -0
  55. package/src/models/modelPickerData.ts +41 -0
  56. package/src/models/modelPickerDisplay.tsx +132 -0
  57. package/src/models/modelPickerHfFlow.ts +192 -0
  58. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  59. package/src/models/modelPickerTypes.ts +69 -0
  60. package/src/models/modelPickerUninstallFlow.ts +48 -0
  61. package/src/models/modelPickerViewHelpers.ts +174 -0
  62. package/src/providers/openai-chat.ts +5 -124
  63. package/src/providers/openaiChatWire.ts +124 -0
  64. package/src/runtime/providerTurn.ts +38 -0
  65. package/src/runtime/textToolParser.ts +161 -0
  66. package/src/runtime/toolIntent.ts +1 -1
  67. package/src/runtime/turn.ts +43 -499
  68. package/src/runtime/turnNudges.ts +223 -0
  69. 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
+ }