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.
- 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/ChatBottomPane.tsx +0 -3
- package/src/chat/ChatScreen.tsx +15 -117
- 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 -146
- 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
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` |
|
|
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.
|
|
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"
|
package/src/app/FirstRun.tsx
CHANGED
|
@@ -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 =
|
|
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}
|
package/src/chat/ChatScreen.tsx
CHANGED
|
@@ -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
|
-
}
|
package/src/chat/MessageList.tsx
CHANGED
|
@@ -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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|