ethagent 3.0.2 → 3.1.1

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 (70) 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/ChatBottomPane.tsx +0 -3
  7. package/src/chat/ChatScreen.tsx +15 -117
  8. package/src/chat/MessageList.tsx +18 -260
  9. package/src/chat/chatEnvironment.ts +16 -0
  10. package/src/chat/chatTurnContext.ts +50 -0
  11. package/src/chat/chatTurnOrchestrator.ts +5 -112
  12. package/src/chat/chatTurnRows.ts +64 -0
  13. package/src/chat/commands.ts +3 -178
  14. package/src/chat/continuityEditReview.ts +42 -0
  15. package/src/chat/input/ChatInput.tsx +10 -146
  16. package/src/chat/input/chatInputHelpers.ts +62 -0
  17. package/src/chat/input/inputRendering.tsx +93 -0
  18. package/src/chat/messageMarkdown.ts +220 -0
  19. package/src/chat/messageRows.ts +43 -0
  20. package/src/chat/planImplementation.ts +62 -0
  21. package/src/chat/slashCommandHandlers.ts +165 -0
  22. package/src/chat/slashCommandViews.ts +120 -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/skills/loadSkills.ts +12 -69
  32. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  33. package/src/identity/continuity/skillsNormalization.ts +119 -0
  34. package/src/identity/continuity/snapshotToken.ts +28 -0
  35. package/src/identity/hub/continuity/completion.ts +67 -0
  36. package/src/identity/hub/continuity/effects.ts +5 -62
  37. package/src/identity/hub/profile/effects.ts +6 -170
  38. package/src/identity/hub/profile/operatorSave.ts +202 -0
  39. package/src/identity/wallet/browserWallet/html.ts +1 -57
  40. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  41. package/src/identity/wallet/page/controller.ts +1 -1
  42. package/src/identity/wallet/page/errorView.ts +122 -0
  43. package/src/identity/wallet/page/view.ts +3 -114
  44. package/src/mcp/manager.ts +8 -66
  45. package/src/mcp/managerHelpers.ts +70 -0
  46. package/src/models/ModelPicker.tsx +69 -889
  47. package/src/models/huggingface.ts +20 -137
  48. package/src/models/huggingfaceStorage.ts +136 -0
  49. package/src/models/llamacpp.ts +37 -303
  50. package/src/models/llamacppCommands.ts +44 -0
  51. package/src/models/llamacppConfig.ts +34 -0
  52. package/src/models/llamacppDiscovery.ts +176 -0
  53. package/src/models/llamacppOutput.ts +65 -0
  54. package/src/models/modelPickerCatalogFlow.ts +56 -0
  55. package/src/models/modelPickerCredentials.ts +166 -0
  56. package/src/models/modelPickerData.ts +41 -0
  57. package/src/models/modelPickerDisplay.tsx +132 -0
  58. package/src/models/modelPickerHfFlow.ts +192 -0
  59. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  60. package/src/models/modelPickerTypes.ts +69 -0
  61. package/src/models/modelPickerUninstallFlow.ts +48 -0
  62. package/src/models/modelPickerViewHelpers.ts +174 -0
  63. package/src/providers/openai-chat.ts +5 -124
  64. package/src/providers/openaiChatWire.ts +124 -0
  65. package/src/runtime/providerTurn.ts +38 -0
  66. package/src/runtime/textToolParser.ts +161 -0
  67. package/src/runtime/toolIntent.ts +1 -1
  68. package/src/runtime/turn.ts +43 -499
  69. package/src/runtime/turnNudges.ts +223 -0
  70. package/src/runtime/turnTypes.ts +86 -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.2",
3
+ "version": "3.1.1",
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
 
@@ -53,7 +53,6 @@ type ChatBottomPaneProps = {
53
53
  modelPickerContextFit: ModelPickerContextFit | null
54
54
  permissionRequest: PermissionRequest | null
55
55
  history: string[]
56
- busy: boolean
57
56
  streaming: boolean
58
57
  streamingStartedAt: number | null
59
58
  activity: BottomPaneActivity
@@ -100,7 +99,6 @@ export function ChatBottomPane({
100
99
  modelPickerContextFit,
101
100
  permissionRequest,
102
101
  history,
103
- busy,
104
102
  streaming,
105
103
  streamingStartedAt,
106
104
  activity,
@@ -265,7 +263,6 @@ export function ChatBottomPane({
265
263
  <ChatInput
266
264
  onSubmit={handleSubmit}
267
265
  history={history}
268
- disabled={busy}
269
266
  placeholderHints={placeholderHints}
270
267
  queuedMessages={queuedInputs}
271
268
  slashSuggestions={slashSuggestions}
@@ -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'
@@ -55,7 +53,6 @@ import type {
55
53
  PermissionRequest,
56
54
  SessionPermissionRule,
57
55
  } from '../tools/contracts.js'
58
- import { splitFileChangeResult } from '../tools/fileDiff.js'
59
56
  import {
60
57
  buildBaseMessages,
61
58
  sessionMessagesToRows,
@@ -74,12 +71,26 @@ import {
74
71
  restoreConversationState,
75
72
  } from './chatSessionState.js'
76
73
  import { runStreamingTurn } from './chatTurnOrchestrator.js'
77
- import { ensureLlamaCppRunnerReady } from '../models/llamacppPreflight.js'
78
74
  import type { PlanApprovalAction } from './views/PlanApprovalView.js'
79
75
  import type { ContextLimitAction } from './views/ContextLimitView.js'
80
76
  import type { ContinuityEditReviewAction, ContinuityEditReviewState } from './views/ContinuityEditReviewView.js'
81
77
  import { openFileInEditor } from '../identity/continuity/editor.js'
82
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'
83
94
 
84
95
  type ChatScreenProps = {
85
96
  config: EthagentConfig
@@ -114,19 +125,6 @@ const nowIso = (): string => new Date().toISOString()
114
125
  const STREAM_FLUSH_MS = 120
115
126
  const CONTEXT_CONFIRM_PERCENT = 90
116
127
  const MAX_PROMPT_HISTORY = 500
117
- const MAX_HANDOFF_SUMMARY_CHARS = 12_000
118
-
119
- function compressHome(cwd: string): string {
120
- const home = os.homedir()
121
- if (cwd === home) return '~'
122
- if (cwd.startsWith(home + path.sep)) return '~' + cwd.slice(home.length).replace(/\\/g, '/')
123
- return cwd.replace(/\\/g, '/')
124
- }
125
-
126
- async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: true } | { ok: false; message: string }> {
127
- if (config.provider === 'llamacpp') return ensureLlamaCppRunnerReady(config)
128
- return { ok: true }
129
- }
130
128
 
131
129
  export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig, updateNotice }) => {
132
130
  useRegisterKeybindingContext('Chat')
@@ -1588,7 +1586,6 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1588
1586
  modelPickerContextFit={modelPickerContextFit}
1589
1587
  permissionRequest={permissionRequest}
1590
1588
  history={history}
1591
- busy={busy}
1592
1589
  streaming={streaming}
1593
1590
  streamingStartedAt={streamingStartedAt}
1594
1591
  activity={null}
@@ -1640,113 +1637,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
1640
1637
  )
1641
1638
  }
1642
1639
 
1643
- export function chatFooterShortcutText(canScrollTranscript: boolean): string {
1644
- return 'alt+p model · alt+i identity'
1645
- }
1646
-
1647
1640
  function formatContextLabel(usage: ContextUsage): string {
1648
1641
  if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
1649
1642
  return `Estimated context: ${usage.percent}% used`
1650
1643
  }
1651
1644
 
1652
- export function buildPlanImplementationPrompt(plan: string): string {
1653
- return [
1654
- 'Implement the approved plan below.',
1655
- '',
1656
- 'Use native ethagent tools directly. Do not translate tool names into shell commands.',
1657
- 'For workspace inspection, call list_directory and read_file directly.',
1658
- 'For file creation or edits, call edit_file directly.',
1659
- '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.',
1660
- '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.',
1661
- 'Read the relevant files before editing, make the required changes, and verify the result when possible.',
1662
- '',
1663
- plan,
1664
- ].join('\n')
1665
- }
1666
-
1667
- export function buildPlanTransferSeedMessages(args: {
1668
- sourceSessionId: string
1669
- summary: string
1670
- plan: string
1671
- createdAt: string
1672
- }): SessionMessage[] {
1673
- return [
1674
- {
1675
- role: 'user',
1676
- synthetic: true,
1677
- content: [
1678
- `Planning handoff from ${args.sourceSessionId.slice(0, 8)}:`,
1679
- '',
1680
- args.summary.trim(),
1681
- ].join('\n'),
1682
- createdAt: args.createdAt,
1683
- },
1684
- {
1685
- role: 'user',
1686
- synthetic: true,
1687
- content: [
1688
- 'Approved plan to implement:',
1689
- '',
1690
- args.plan.trim(),
1691
- ].join('\n'),
1692
- createdAt: args.createdAt,
1693
- },
1694
- ]
1695
- }
1696
-
1697
1645
  function appendPromptHistoryEntry(history: string[], value: string): string[] {
1698
1646
  const prompt = value.trim()
1699
1647
  if (!prompt) return history
1700
1648
  const next = history[history.length - 1] === prompt ? history : [...history, prompt]
1701
1649
  return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
1702
1650
  }
1703
-
1704
- function normalizeHandoffSummary(summary: string): string {
1705
- const trimmed = summary.trim()
1706
- if (trimmed.length <= MAX_HANDOFF_SUMMARY_CHARS) return trimmed
1707
- return [
1708
- trimmed.slice(0, MAX_HANDOFF_SUMMARY_CHARS - 96).trimEnd(),
1709
- '',
1710
- '[handoff truncated to keep the resumed conversation responsive]',
1711
- ].join('\n')
1712
- }
1713
-
1714
- export function privateContinuityEditReviewFromToolResult(
1715
- name: string,
1716
- input: Record<string, unknown>,
1717
- result: { ok: boolean; summary: string; content: string },
1718
- ): ContinuityEditReviewState | null {
1719
- if (name !== 'propose_private_continuity_edit' || !result.ok) return null
1720
- const file = normalizePrivateContinuityFile(input.file)
1721
- if (!file) return null
1722
- const parsed = splitFileChangeResult(result.content)
1723
- const filePath = extractReviewFilePath(parsed.content)
1724
- if (!filePath) return null
1725
- return {
1726
- file,
1727
- filePath,
1728
- summary: result.summary,
1729
- ...(parsed.diff ? { diff: parsed.diff } : {}),
1730
- }
1731
- }
1732
-
1733
- function normalizePrivateContinuityFile(value: unknown): ContinuityEditReviewState['file'] | null {
1734
- if (typeof value !== 'string') return null
1735
- if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
1736
- if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
1737
- return null
1738
- }
1739
-
1740
- function extractReviewFilePath(content: string): string | null {
1741
- for (const line of content.split(/\r?\n/)) {
1742
- const review = line.match(/^(?:[-*]\s+)?review file:\s*(.+)$/i)
1743
- if (review?.[1]?.trim()) return cleanReviewFilePath(review[1])
1744
- const updated = line.match(/^(?:[-*]\s+)?updated local private continuity file\s+(.+)$/i)
1745
- if (updated?.[1]?.trim()) return cleanReviewFilePath(updated[1])
1746
- }
1747
- return null
1748
- }
1749
-
1750
- function cleanReviewFilePath(value: string): string {
1751
- return value.trim().replace(/^`+|`+$/g, '').trim()
1752
- }
@@ -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
+ }