ethagent 3.0.1 → 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 (73) 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 +32 -117
  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/cli/main.tsx +7 -0
  23. package/src/identity/continuity/challenges.ts +123 -0
  24. package/src/identity/continuity/envelope.ts +49 -1484
  25. package/src/identity/continuity/envelopeCreate.ts +322 -0
  26. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  27. package/src/identity/continuity/envelopeParse.ts +441 -0
  28. package/src/identity/continuity/envelopeTypes.ts +204 -0
  29. package/src/identity/continuity/envelopeVersion.ts +1 -0
  30. package/src/identity/continuity/payloadNormalization.ts +183 -0
  31. package/src/identity/continuity/publicSkills.ts +5 -5
  32. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  33. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  34. package/src/identity/continuity/skillsNormalization.ts +119 -0
  35. package/src/identity/continuity/snapshotToken.ts +28 -0
  36. package/src/identity/hub/continuity/completion.ts +67 -0
  37. package/src/identity/hub/continuity/effects.ts +5 -62
  38. package/src/identity/hub/profile/effects.ts +6 -170
  39. package/src/identity/hub/profile/operatorSave.ts +202 -0
  40. package/src/identity/registry/erc8004/metadata.ts +31 -23
  41. package/src/identity/wallet/browserWallet/html.ts +1 -57
  42. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  43. package/src/identity/wallet/page/controller.ts +1 -1
  44. package/src/identity/wallet/page/errorView.ts +122 -0
  45. package/src/identity/wallet/page/view.ts +3 -114
  46. package/src/mcp/manager.ts +8 -66
  47. package/src/mcp/managerHelpers.ts +70 -0
  48. package/src/models/ModelPicker.tsx +69 -889
  49. package/src/models/huggingface.ts +20 -137
  50. package/src/models/huggingfaceStorage.ts +136 -0
  51. package/src/models/llamacpp.ts +37 -303
  52. package/src/models/llamacppCommands.ts +44 -0
  53. package/src/models/llamacppConfig.ts +34 -0
  54. package/src/models/llamacppDiscovery.ts +176 -0
  55. package/src/models/llamacppOutput.ts +65 -0
  56. package/src/models/modelPickerCatalogFlow.ts +56 -0
  57. package/src/models/modelPickerCredentials.ts +166 -0
  58. package/src/models/modelPickerData.ts +41 -0
  59. package/src/models/modelPickerDisplay.tsx +132 -0
  60. package/src/models/modelPickerHfFlow.ts +192 -0
  61. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  62. package/src/models/modelPickerTypes.ts +69 -0
  63. package/src/models/modelPickerUninstallFlow.ts +48 -0
  64. package/src/models/modelPickerViewHelpers.ts +174 -0
  65. package/src/providers/openai-chat.ts +5 -124
  66. package/src/providers/openaiChatWire.ts +124 -0
  67. package/src/runtime/providerTurn.ts +38 -0
  68. package/src/runtime/textToolParser.ts +161 -0
  69. package/src/runtime/toolIntent.ts +1 -1
  70. package/src/runtime/turn.ts +43 -499
  71. package/src/runtime/turnNudges.ts +223 -0
  72. package/src/runtime/turnTypes.ts +86 -0
  73. package/src/ui/terminalTitle.ts +30 -0
package/README.md CHANGED
@@ -163,11 +163,16 @@ cd ethagent && npm install
163
163
  npm start
164
164
  ```
165
165
 
166
+ The published CLI is source-distributed: `bin/ethagent.js` launches `src/cli/main.tsx` through `tsx`. `npm run build` is therefore a validation build; it checks the shipped TypeScript without producing a separate `dist/` directory.
167
+
168
+ Repository structure and refactoring rules are documented in `ARCHITECTURE.md` and `CONTRIBUTING.md`. Stable facades keep public import paths intact while focused sibling modules hold private implementation details.
169
+
166
170
  | Command | What it does |
167
171
  | --- | --- |
168
172
  | `npm start` | Run from source. |
173
+ | `npm run build` | Validate the shipped TypeScript source package. |
169
174
  | `npm test` | Test suite. |
170
- | `npm run typecheck` | Types. |
175
+ | `npm run typecheck` | Run the same TypeScript check directly. |
171
176
  | `npm run contracts:test` | Foundry tests. |
172
177
 
173
178
  Foundry is only needed for `contracts/` changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ethagent",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "A privacy-first AI agent with a portable Ethereum identity",
5
5
  "type": "module",
6
6
  "main": "bin/ethagent.js",
@@ -15,8 +15,10 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "start": "node bin/ethagent.js",
18
+ "build": "tsc --noEmit",
18
19
  "typecheck": "tsc --noEmit",
19
20
  "test": "node test/run-tests.mjs",
21
+ "prepack": "npm run build",
20
22
  "contracts:build": "cd contracts && forge build",
21
23
  "contracts:test": "cd contracts && forge test -vv",
22
24
  "contracts:fmt": "cd contracts && forge fmt"
@@ -26,6 +26,7 @@ import {
26
26
  import { setKey } from '../storage/secrets.js'
27
27
  import { IdentityHub, type IdentityHubResult } from '../identity/hub/IdentityHub.js'
28
28
  import { FirstRunTimeline, firstRunStageNumber, type FirstRunStepKind } from './FirstRunTimeline.js'
29
+ import { configFromModelPickerSelection, formatGB } from './firstRunConfig.js'
29
30
 
30
31
  type Step =
31
32
  | { kind: 'detecting' }
@@ -568,27 +569,3 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
568
569
  return null
569
570
  }
570
571
 
571
- function configFromModelPickerSelection(selection: ModelPickerSelection, base: EthagentConfig): EthagentConfig {
572
- if (selection.kind === 'llamacpp') {
573
- return {
574
- ...base,
575
- provider: 'llamacpp',
576
- model: selection.model,
577
- baseUrl: defaultBaseUrlFor('llamacpp'),
578
- localMmprojPath: selection.mmprojPath,
579
- }
580
- }
581
- return {
582
- ...base,
583
- provider: selection.provider,
584
- model: selection.model,
585
- baseUrl: undefined,
586
- localMmprojPath: undefined,
587
- }
588
- }
589
-
590
- function formatGB(bytes: number): string {
591
- const gb = bytes / (1024 * 1024 * 1024)
592
- return gb < 10 ? `${gb.toFixed(1)}GB` : `${Math.round(gb)}GB`
593
- }
594
-
@@ -0,0 +1,26 @@
1
+ import { defaultBaseUrlFor, type EthagentConfig } from '../storage/config.js'
2
+ import type { ModelPickerSelection } from '../models/ModelPicker.js'
3
+
4
+ export function configFromModelPickerSelection(selection: ModelPickerSelection, base: EthagentConfig): EthagentConfig {
5
+ if (selection.kind === 'llamacpp') {
6
+ return {
7
+ ...base,
8
+ provider: 'llamacpp',
9
+ model: selection.model,
10
+ baseUrl: defaultBaseUrlFor('llamacpp'),
11
+ localMmprojPath: selection.mmprojPath,
12
+ }
13
+ }
14
+ return {
15
+ ...base,
16
+ provider: selection.provider,
17
+ model: selection.model,
18
+ baseUrl: undefined,
19
+ localMmprojPath: undefined,
20
+ }
21
+ }
22
+
23
+ export function formatGB(bytes: number): string {
24
+ const gb = bytes / (1024 * 1024 * 1024)
25
+ return gb < 10 ? `${gb.toFixed(1)}GB` : `${Math.round(gb)}GB`
26
+ }
@@ -1,22 +1,13 @@
1
1
  import { readFileSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
2
  import { transformSync } from 'esbuild'
5
3
  import { WALLET_CSS } from '../../identity/wallet/page/styles/index.js'
6
4
  import { glyphs } from '../../identity/wallet/page/html.js'
5
+ import { walletPageSourceFile } from '../../identity/wallet/browserWallet/walletPageSource.js'
7
6
  import { escapeHtml } from './shared.js'
8
7
 
9
8
  export type LandingTone = 'success' | 'error' | 'cancelled'
10
9
 
11
- const GRAINIENT_SOURCE_FILE = join(
12
- dirname(fileURLToPath(import.meta.url)),
13
- '..',
14
- '..',
15
- 'identity',
16
- 'wallet',
17
- 'page',
18
- 'grainient.ts',
19
- )
10
+ const GRAINIENT_SOURCE_FILE = walletPageSourceFile('page/grainient.ts')
20
11
 
21
12
  const COMPILED_GRAINIENT = compileGrainientModule()
22
13
 
@@ -1,6 +1,4 @@
1
1
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
2
  import { Box, Text, useApp } from 'ink'
5
3
  import type { EthagentConfig } from '../storage/config.js'
6
4
  import type { Provider, Message } from '../providers/contracts.js'
@@ -23,6 +21,7 @@ import type { ModelPickerSelection } from '../models/ModelPicker.js'
23
21
  import type { ModelPickerContextFit } from '../models/modelPickerOptions.js'
24
22
  import type { CopyResult } from '../utils/clipboard.js'
25
23
  import { useKeybinding, useRegisterKeybindingContext } from '../app/keybindings/KeybindingProvider.js'
24
+ import { TITLE_ANIMATION_FRAMES, TITLE_ANIMATION_INTERVAL_MS, TITLE_STATIC, setTerminalTitle } from '../ui/terminalTitle.js'
26
25
  import { useCancelRequest } from '../app/hooks/useCancelRequest.js'
27
26
  import { useExitOnCtrlC } from '../app/hooks/useExitOnCtrlC.js'
28
27
  import {
@@ -54,7 +53,6 @@ import type {
54
53
  PermissionRequest,
55
54
  SessionPermissionRule,
56
55
  } from '../tools/contracts.js'
57
- import { splitFileChangeResult } from '../tools/fileDiff.js'
58
56
  import {
59
57
  buildBaseMessages,
60
58
  sessionMessagesToRows,
@@ -73,12 +71,26 @@ import {
73
71
  restoreConversationState,
74
72
  } from './chatSessionState.js'
75
73
  import { runStreamingTurn } from './chatTurnOrchestrator.js'
76
- import { ensureLlamaCppRunnerReady } from '../models/llamacppPreflight.js'
77
74
  import type { PlanApprovalAction } from './views/PlanApprovalView.js'
78
75
  import type { ContextLimitAction } from './views/ContextLimitView.js'
79
76
  import type { ContinuityEditReviewAction, ContinuityEditReviewState } from './views/ContinuityEditReviewView.js'
80
77
  import { openFileInEditor } from '../identity/continuity/editor.js'
81
78
  import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager.js'
79
+ import { compressHome, ensureLocalProviderReady } from './chatEnvironment.js'
80
+ import {
81
+ buildPlanImplementationPrompt,
82
+ buildPlanTransferSeedMessages,
83
+ chatFooterShortcutText,
84
+ normalizeHandoffSummary,
85
+ } from './planImplementation.js'
86
+ import { privateContinuityEditReviewFromToolResult } from './continuityEditReview.js'
87
+
88
+ export {
89
+ buildPlanImplementationPrompt,
90
+ buildPlanTransferSeedMessages,
91
+ chatFooterShortcutText,
92
+ } from './planImplementation.js'
93
+ export { privateContinuityEditReviewFromToolResult } from './continuityEditReview.js'
82
94
 
83
95
  type ChatScreenProps = {
84
96
  config: EthagentConfig
@@ -113,19 +125,6 @@ const nowIso = (): string => new Date().toISOString()
113
125
  const STREAM_FLUSH_MS = 120
114
126
  const CONTEXT_CONFIRM_PERCENT = 90
115
127
  const MAX_PROMPT_HISTORY = 500
116
- const MAX_HANDOFF_SUMMARY_CHARS = 12_000
117
-
118
- function compressHome(cwd: string): string {
119
- const home = os.homedir()
120
- if (cwd === home) return '~'
121
- if (cwd.startsWith(home + path.sep)) return '~' + cwd.slice(home.length).replace(/\\/g, '/')
122
- return cwd.replace(/\\/g, '/')
123
- }
124
-
125
- async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: true } | { ok: false; message: string }> {
126
- if (config.provider === 'llamacpp') return ensureLlamaCppRunnerReady(config)
127
- return { ok: true }
128
- }
129
128
 
130
129
  export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig, updateNotice }) => {
131
130
  useRegisterKeybindingContext('Chat')
@@ -1475,7 +1474,22 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1475
1474
  ],
1476
1475
  )
1477
1476
 
1478
- const busy = pullInFlight || Boolean(compactionUi)
1477
+ const busy = streaming || pullInFlight || Boolean(compactionUi)
1478
+
1479
+ useEffect(() => {
1480
+ if (!busy) {
1481
+ setTerminalTitle(TITLE_STATIC)
1482
+ return
1483
+ }
1484
+ let i = 0
1485
+ setTerminalTitle(TITLE_ANIMATION_FRAMES[0])
1486
+ const id = setInterval(() => {
1487
+ i = (i + 1) % TITLE_ANIMATION_FRAMES.length
1488
+ setTerminalTitle(TITLE_ANIMATION_FRAMES[i] ?? TITLE_STATIC)
1489
+ }, TITLE_ANIMATION_INTERVAL_MS)
1490
+ return () => clearInterval(id)
1491
+ }, [busy])
1492
+
1479
1493
  const slashSuggestions = useMemo(
1480
1494
  () => getSlashSuggestions(mcpManagerRef.current?.getPromptSuggestions() ?? []),
1481
1495
  [mcpSnapshot],
@@ -1624,113 +1638,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1624
1638
  )
1625
1639
  }
1626
1640
 
1627
- export function chatFooterShortcutText(canScrollTranscript: boolean): string {
1628
- return 'alt+p model · alt+i identity'
1629
- }
1630
-
1631
1641
  function formatContextLabel(usage: ContextUsage): string {
1632
1642
  if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
1633
1643
  return `Estimated context: ${usage.percent}% used`
1634
1644
  }
1635
1645
 
1636
- export function buildPlanImplementationPrompt(plan: string): string {
1637
- return [
1638
- 'Implement the approved plan below.',
1639
- '',
1640
- 'Use native ethagent tools directly. Do not translate tool names into shell commands.',
1641
- 'For workspace inspection, call list_directory and read_file directly.',
1642
- 'For file creation or edits, call edit_file directly.',
1643
- '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.',
1644
- '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.',
1645
- 'Read the relevant files before editing, make the required changes, and verify the result when possible.',
1646
- '',
1647
- plan,
1648
- ].join('\n')
1649
- }
1650
-
1651
- export function buildPlanTransferSeedMessages(args: {
1652
- sourceSessionId: string
1653
- summary: string
1654
- plan: string
1655
- createdAt: string
1656
- }): SessionMessage[] {
1657
- return [
1658
- {
1659
- role: 'user',
1660
- synthetic: true,
1661
- content: [
1662
- `Planning handoff from ${args.sourceSessionId.slice(0, 8)}:`,
1663
- '',
1664
- args.summary.trim(),
1665
- ].join('\n'),
1666
- createdAt: args.createdAt,
1667
- },
1668
- {
1669
- role: 'user',
1670
- synthetic: true,
1671
- content: [
1672
- 'Approved plan to implement:',
1673
- '',
1674
- args.plan.trim(),
1675
- ].join('\n'),
1676
- createdAt: args.createdAt,
1677
- },
1678
- ]
1679
- }
1680
-
1681
1646
  function appendPromptHistoryEntry(history: string[], value: string): string[] {
1682
1647
  const prompt = value.trim()
1683
1648
  if (!prompt) return history
1684
1649
  const next = history[history.length - 1] === prompt ? history : [...history, prompt]
1685
1650
  return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
1686
1651
  }
1687
-
1688
- function normalizeHandoffSummary(summary: string): string {
1689
- const trimmed = summary.trim()
1690
- if (trimmed.length <= MAX_HANDOFF_SUMMARY_CHARS) return trimmed
1691
- return [
1692
- trimmed.slice(0, MAX_HANDOFF_SUMMARY_CHARS - 96).trimEnd(),
1693
- '',
1694
- '[handoff truncated to keep the resumed conversation responsive]',
1695
- ].join('\n')
1696
- }
1697
-
1698
- export function privateContinuityEditReviewFromToolResult(
1699
- name: string,
1700
- input: Record<string, unknown>,
1701
- result: { ok: boolean; summary: string; content: string },
1702
- ): ContinuityEditReviewState | null {
1703
- if (name !== 'propose_private_continuity_edit' || !result.ok) return null
1704
- const file = normalizePrivateContinuityFile(input.file)
1705
- if (!file) return null
1706
- const parsed = splitFileChangeResult(result.content)
1707
- const filePath = extractReviewFilePath(parsed.content)
1708
- if (!filePath) return null
1709
- return {
1710
- file,
1711
- filePath,
1712
- summary: result.summary,
1713
- ...(parsed.diff ? { diff: parsed.diff } : {}),
1714
- }
1715
- }
1716
-
1717
- function normalizePrivateContinuityFile(value: unknown): ContinuityEditReviewState['file'] | null {
1718
- if (typeof value !== 'string') return null
1719
- if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
1720
- if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
1721
- return null
1722
- }
1723
-
1724
- function extractReviewFilePath(content: string): string | null {
1725
- for (const line of content.split(/\r?\n/)) {
1726
- const review = line.match(/^(?:[-*]\s+)?review file:\s*(.+)$/i)
1727
- if (review?.[1]?.trim()) return cleanReviewFilePath(review[1])
1728
- const updated = line.match(/^(?:[-*]\s+)?updated local private continuity file\s+(.+)$/i)
1729
- if (updated?.[1]?.trim()) return cleanReviewFilePath(updated[1])
1730
- }
1731
- return null
1732
- }
1733
-
1734
- function cleanReviewFilePath(value: string): string {
1735
- return value.trim().replace(/^`+|`+$/g, '').trim()
1736
- }
@@ -8,6 +8,18 @@ import { SyntaxLine } from './display/SyntaxText.js'
8
8
  import { formatToolCall } from './display/toolCallDisplay.js'
9
9
  import { BrandSplash } from '../ui/BrandSplash.js'
10
10
  import type { RowSlice } from './transcript/transcriptViewport.js'
11
+ import {
12
+ blockContentWidth,
13
+ clipTextForDisplay,
14
+ parseInlineTokens,
15
+ parseMarkdownBlocks,
16
+ sanitizeReasoningForDisplay,
17
+ summarizeThinking,
18
+ type InlineToken,
19
+ type MarkdownBlock,
20
+ } from './messageMarkdown.js'
21
+
22
+ export { sanitizeReasoningForDisplay } from './messageMarkdown.js'
11
23
 
12
24
  export type ToolCallResult = {
13
25
  content: string
@@ -52,28 +64,17 @@ type MessageListProps = {
52
64
  slices: Array<RowSlice<MessageRow>>
53
65
  }
54
66
 
55
- export function rowsToFullSlices(rows: MessageRow[]): Array<RowSlice<MessageRow>> {
56
- return rows.map(row => ({ row, clipStart: 0, clipEnd: Number.MAX_SAFE_INTEGER, rowHeight: Number.MAX_SAFE_INTEGER }))
57
- }
58
-
59
- type MarkdownBlock =
60
- | { kind: 'heading'; level: 1 | 2 | 3 | 4 | 5 | 6; text: string }
61
- | { kind: 'paragraph'; text: string }
62
- | { kind: 'quote'; lines: string[] }
63
- | { kind: 'list'; ordered: boolean; items: string[] }
64
- | { kind: 'code'; lang: string | null; code: string; open?: boolean }
65
-
66
- type InlineToken =
67
- | { kind: 'text'; text: string }
68
- | { kind: 'bold'; text: string }
69
- | { kind: 'italic'; text: string }
70
- | { kind: 'code'; text: string }
67
+ export {
68
+ rowsToFullSlices,
69
+ toggleInspectableRow,
70
+ toggleLatestReasoningRow,
71
+ toggleReasoningRow,
72
+ } from './messageRows.js'
71
73
 
72
74
  const MAX_RENDERED_MESSAGE_CHARS = 12_000
73
75
  const MAX_RENDERED_REASONING_CHARS = 10_000
74
76
  const ASSISTANT_ACCENT = theme.accentPeriwinkle
75
77
  const ASSISTANT_MARKER = '• '
76
- const UNREADABLE_REASONING_TEXT = 'reasoning output was not readable text'
77
78
 
78
79
  const MessageListInner: React.FC<MessageListProps> = ({ slices }) => (
79
80
  <Box flexDirection="column">
@@ -89,43 +90,6 @@ const MessageListInner: React.FC<MessageListProps> = ({ slices }) => (
89
90
 
90
91
  export const MessageList = React.memo(MessageListInner)
91
92
 
92
- function isInspectableRole(role: MessageRow['role']): boolean {
93
- return role === 'thinking'
94
- }
95
-
96
- export function toggleLatestReasoningRow(rows: MessageRow[]): MessageRow[] {
97
- return toggleInspectableRow(rows)
98
- }
99
-
100
- export function toggleReasoningRow(rows: MessageRow[], rowId?: string): MessageRow[] {
101
- return toggleInspectableRow(rows, rowId)
102
- }
103
-
104
- export function toggleInspectableRow(rows: MessageRow[], rowId?: string): MessageRow[] {
105
- let index = -1
106
- if (rowId) {
107
- index = rows.findIndex(row => row.id === rowId && isInspectableRole(row.role))
108
- }
109
- if (index === -1) {
110
- for (let cursor = rows.length - 1; cursor >= 0; cursor -= 1) {
111
- const role = rows[cursor]?.role
112
- if (role && isInspectableRole(role)) {
113
- index = cursor
114
- break
115
- }
116
- }
117
- }
118
- if (index === -1) return rows
119
- const row = rows[index]
120
- if (!row) return rows
121
- if (row.role === 'thinking') {
122
- const next = rows.slice()
123
- next[index] = { ...row, expanded: !row.expanded }
124
- return next
125
- }
126
- return rows
127
- }
128
-
129
93
  const RowViewInner: React.FC<{ slice: RowSlice<MessageRow>; tightTop?: boolean }> = ({ slice, tightTop }) => {
130
94
  const { row, clipStart, clipEnd, rowHeight } = slice
131
95
  if (row.role === 'user') {
@@ -669,212 +633,6 @@ const StreamCursor: React.FC<{ active: boolean }> = ({ active }) => {
669
633
  return <>{visible ? '|' : ' '}</>
670
634
  }
671
635
 
672
- function blockContentWidth(lines: string[]): number {
673
- return Math.max(1, ...lines.map(displayWidth))
674
- }
675
-
676
- function displayWidth(line: string): number {
677
- return (line || ' ').replace(/\t/g, ' ').length
678
- }
679
-
680
- function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
681
- const text = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
682
- if (!text.trim()) return []
683
-
684
- const blocks: MarkdownBlock[] = []
685
- const lines = text.split('\n')
686
- let index = 0
687
-
688
- while (index < lines.length) {
689
- const line = lines[index] ?? ''
690
- const trimmed = line.trim()
691
-
692
- if (!trimmed) {
693
- index += 1
694
- continue
695
- }
696
-
697
- const fence = trimmed.match(/^```([\w+-]*)\s*$/)
698
- if (fence) {
699
- const lang = fence[1] && fence[1].length > 0 ? fence[1] : null
700
- index += 1
701
- const body: string[] = []
702
- let closed = false
703
- while (index < lines.length) {
704
- const nextLine = lines[index] ?? ''
705
- if (nextLine.trim().match(/^```\s*$/)) {
706
- closed = true
707
- index += 1
708
- break
709
- }
710
- body.push(nextLine)
711
- index += 1
712
- }
713
- blocks.push({ kind: 'code', lang, code: body.join('\n'), open: !closed })
714
- continue
715
- }
716
-
717
- const heading = line.match(/^(#{1,6})\s+(.*)$/)
718
- if (heading) {
719
- const [, hashes = '#', headingText = ''] = heading
720
- blocks.push({
721
- kind: 'heading',
722
- level: hashes.length as 1 | 2 | 3 | 4 | 5 | 6,
723
- text: headingText.trim(),
724
- })
725
- index += 1
726
- continue
727
- }
728
-
729
- if (/^>\s?/.test(trimmed)) {
730
- const quoteLines: string[] = []
731
- while (index < lines.length) {
732
- const nextLine = lines[index] ?? ''
733
- if (!/^>\s?/.test(nextLine.trim())) break
734
- quoteLines.push(nextLine.trim().replace(/^>\s?/, ''))
735
- index += 1
736
- }
737
- blocks.push({ kind: 'quote', lines: quoteLines })
738
- continue
739
- }
740
-
741
- const ordered = trimmed.match(/^\d+\.\s+(.*)$/)
742
- const unordered = trimmed.match(/^[-*+]\s+(.*)$/)
743
- if (ordered || unordered) {
744
- const items: string[] = []
745
- const orderedList = Boolean(ordered)
746
- while (index < lines.length) {
747
- const nextLine = lines[index] ?? ''
748
- const match = orderedList
749
- ? nextLine.trim().match(/^\d+\.\s+(.*)$/)
750
- : nextLine.trim().match(/^[-*+]\s+(.*)$/)
751
- if (!match) break
752
- items.push(match[1] ?? '')
753
- index += 1
754
- }
755
- blocks.push({ kind: 'list', ordered: orderedList, items })
756
- continue
757
- }
758
-
759
- const paragraph: string[] = []
760
- while (index < lines.length) {
761
- const nextLine = lines[index] ?? ''
762
- const nextTrimmed = nextLine.trim()
763
- if (!nextTrimmed) break
764
- if (nextTrimmed.match(/^```([\w+-]*)\s*$/)) break
765
- if (nextLine.match(/^(#{1,6})\s+(.*)$/)) break
766
- if (/^>\s?/.test(nextTrimmed)) break
767
- if (nextTrimmed.match(/^\d+\.\s+(.*)$/) || nextTrimmed.match(/^[-*+]\s+(.*)$/)) break
768
- paragraph.push(nextLine)
769
- index += 1
770
- }
771
- blocks.push({ kind: 'paragraph', text: paragraph.join('\n').trim() })
772
- }
773
-
774
- return blocks
775
- }
776
-
777
- function parseInlineTokens(text: string): InlineToken[] {
778
- const tokens: InlineToken[] = []
779
- const source = normalizeInlineDisplayText(text)
780
- const pattern = /(`[^`\n]+`|\*\*[^*\n]+?\*\*|__[^_\n]+?__|\*[^*\n]+?\*|_[^_\n]+?_)/g
781
- let lastIndex = 0
782
- let match: RegExpExecArray | null
783
-
784
- while ((match = pattern.exec(source)) !== null) {
785
- if (match.index > lastIndex) {
786
- tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex, match.index)) })
787
- }
788
-
789
- const token = match[0]
790
- if ((token.startsWith('**') && token.endsWith('**')) || (token.startsWith('__') && token.endsWith('__'))) {
791
- tokens.push({ kind: 'bold', text: cleanPlainInlineText(token.slice(2, -2)) })
792
- } else if ((token.startsWith('*') && token.endsWith('*')) || (token.startsWith('_') && token.endsWith('_'))) {
793
- tokens.push({ kind: 'italic', text: cleanPlainInlineText(token.slice(1, -1)) })
794
- } else if (token.startsWith('`') && token.endsWith('`')) {
795
- tokens.push({ kind: 'code', text: token.slice(1, -1) })
796
- }
797
-
798
- lastIndex = match.index + token.length
799
- }
800
-
801
- if (lastIndex < source.length || tokens.length === 0) {
802
- tokens.push({ kind: 'text', text: cleanPlainInlineText(source.slice(lastIndex)) })
803
- }
804
-
805
- return tokens.filter(token => token.text.length > 0)
806
- }
807
-
808
- function normalizeInlineDisplayText(text: string): string {
809
- return text
810
- .replace(/\\\(/g, '')
811
- .replace(/\\\)/g, '')
812
- .replace(/\\\[/g, '')
813
- .replace(/\\\]/g, '')
814
- .replace(/\$\$([^$]+)\$\$/g, '$1')
815
- .replace(/\$([^$\n]+)\$/g, '$1')
816
- .replace(/\\([{}[\]()])/g, '$1')
817
- .replace(/\/([{}])/g, '$1')
818
- }
819
-
820
- function cleanPlainInlineText(text: string): string {
821
- return text.replace(/\*+/g, '')
822
- }
823
-
824
- function summarizeThinking(text: string): string {
825
- const sample = text.length > 1000 ? text.slice(-1000) : text
826
- const normalized = sample.replace(/\s+/g, ' ').trim()
827
- if (!normalized) return ''
828
- const prefix = text.length > sample.length ? '...' : ''
829
- if (normalized.length + prefix.length <= 120) return `${prefix}${normalized}`
830
- return `${prefix}${normalized.slice(Math.max(0, normalized.length - (120 - prefix.length)))}`
831
- }
832
-
833
- function clipTextForDisplay(text: string, maxChars: number): { text: string; omittedChars: number } {
834
- if (text.length <= maxChars) return { text, omittedChars: 0 }
835
- const rawStart = Math.max(0, text.length - maxChars)
836
- const newline = text.indexOf('\n', rawStart)
837
- const start = newline >= 0 && newline - rawStart <= 240 ? newline + 1 : rawStart
838
- return {
839
- text: text.slice(start),
840
- omittedChars: start,
841
- }
842
- }
843
-
844
636
  function reasoningText(row: Extract<MessageRow, { role: 'thinking' }>): string {
845
637
  return row.liveTail ? row.content + row.liveTail : row.content
846
638
  }
847
-
848
- export function sanitizeReasoningForDisplay(text: string): string {
849
- const normalized = text
850
- .replace(/\r\n/g, '\n')
851
- .replace(/\r/g, '\n')
852
- .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
853
- const controlCount = countMatches(normalized, /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g)
854
- const cleaned = normalized
855
- .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFD]/g, '')
856
- .replace(/\t/g, ' ')
857
- const visibleLength = cleaned.replace(/\s/g, '').length
858
- if (visibleLength === 0) return ''
859
- if (controlCount > 0 && controlCount / Math.max(1, text.length) > 0.05) return UNREADABLE_REASONING_TEXT
860
- if (looksLikeUnreadableReasoning(cleaned)) return UNREADABLE_REASONING_TEXT
861
- return cleaned
862
- }
863
-
864
- function looksLikeUnreadableReasoning(text: string): boolean {
865
- const visible = text.replace(/\s/g, '')
866
- if (visible.length < 120) return false
867
- const letters = countMatches(visible, /[A-Za-z]/g)
868
- const digits = countMatches(visible, /\d/g)
869
- const words = text.match(/[A-Za-z]{3,}/g) ?? []
870
- const wordChars = words.reduce((sum, word) => sum + word.length, 0)
871
- const whitespace = countMatches(text, /\s/g)
872
- const symbolDensity = (visible.length - letters - digits) / visible.length
873
- const wordDensity = wordChars / visible.length
874
- const whitespaceDensity = whitespace / Math.max(1, text.length)
875
- return symbolDensity > 0.38 && wordDensity < 0.32 && whitespaceDensity < 0.12
876
- }
877
-
878
- function countMatches(text: string, pattern: RegExp): number {
879
- return text.match(pattern)?.length ?? 0
880
- }
@@ -0,0 +1,16 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import type { EthagentConfig } from '../storage/config.js'
4
+ import { ensureLlamaCppRunnerReady } from '../models/llamacppPreflight.js'
5
+
6
+ export function compressHome(cwd: string): string {
7
+ const home = os.homedir()
8
+ if (cwd === home) return '~'
9
+ if (cwd.startsWith(home + path.sep)) return '~' + cwd.slice(home.length).replace(/\\/g, '/')
10
+ return cwd.replace(/\\/g, '/')
11
+ }
12
+
13
+ export async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: true } | { ok: false; message: string }> {
14
+ if (config.provider === 'llamacpp') return ensureLlamaCppRunnerReady(config)
15
+ return { ok: true }
16
+ }