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.
- package/README.md +6 -1
- package/package.json +3 -1
- package/src/app/FirstRun.tsx +1 -24
- package/src/app/firstRunConfig.ts +26 -0
- package/src/auth/openaiOAuth/landingPage.ts +2 -11
- package/src/chat/ChatScreen.tsx +32 -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 -144
- package/src/chat/input/chatInputHelpers.ts +62 -0
- package/src/chat/input/inputRendering.tsx +93 -0
- package/src/chat/messageMarkdown.ts +220 -0
- package/src/chat/messageRows.ts +43 -0
- package/src/chat/planImplementation.ts +62 -0
- package/src/chat/slashCommandHandlers.ts +165 -0
- package/src/chat/slashCommandViews.ts +120 -0
- package/src/cli/main.tsx +7 -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/publicSkills.ts +5 -5
- 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/registry/erc8004/metadata.ts +31 -23
- 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/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` |
|
|
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
|
|
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"
|
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
|
|
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'
|
|
@@ -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
|
-
}
|
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
|
+
}
|