ethagent 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +15 -116
- 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/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.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'
|
|
@@ -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')
|
|
@@ -1640,113 +1638,14 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1640
1638
|
)
|
|
1641
1639
|
}
|
|
1642
1640
|
|
|
1643
|
-
export function chatFooterShortcutText(canScrollTranscript: boolean): string {
|
|
1644
|
-
return 'alt+p model · alt+i identity'
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
1641
|
function formatContextLabel(usage: ContextUsage): string {
|
|
1648
1642
|
if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
|
|
1649
1643
|
return `Estimated context: ${usage.percent}% used`
|
|
1650
1644
|
}
|
|
1651
1645
|
|
|
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
1646
|
function appendPromptHistoryEntry(history: string[], value: string): string[] {
|
|
1698
1647
|
const prompt = value.trim()
|
|
1699
1648
|
if (!prompt) return history
|
|
1700
1649
|
const next = history[history.length - 1] === prompt ? history : [...history, prompt]
|
|
1701
1650
|
return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
|
|
1702
1651
|
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import type { Message } from '../providers/contracts.js'
|
|
4
|
+
|
|
5
|
+
export async function buildFileMentionContextMessages(
|
|
6
|
+
userText: string,
|
|
7
|
+
cwd: string,
|
|
8
|
+
): Promise<Message[]> {
|
|
9
|
+
const mentions = extractFileMentions(userText)
|
|
10
|
+
if (mentions.length === 0) return []
|
|
11
|
+
|
|
12
|
+
const lines: string[] = []
|
|
13
|
+
for (const mention of mentions) {
|
|
14
|
+
const resolved = path.resolve(cwd, mention)
|
|
15
|
+
const rel = path.relative(cwd, resolved)
|
|
16
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
17
|
+
lines.push(
|
|
18
|
+
`@${mention} -> outside current workspace; do not use unless the user changes directory or names an allowed path.`,
|
|
19
|
+
)
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const stats = await fs.stat(resolved)
|
|
24
|
+
lines.push(`@${mention} -> ${mention} (${stats.isDirectory() ? 'directory' : 'file'})`)
|
|
25
|
+
} catch {
|
|
26
|
+
lines.push(`@${mention} -> unresolved`)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
role: 'user',
|
|
33
|
+
content: [
|
|
34
|
+
'Resolved file mentions for this request:',
|
|
35
|
+
...lines,
|
|
36
|
+
'Treat these mentions as authoritative filenames from the user request. Read referenced context files when needed, and edit only the file requested by the user or the target file you have inspected.',
|
|
37
|
+
].join('\n'),
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractFileMentions(text: string): string[] {
|
|
43
|
+
const mentions = new Set<string>()
|
|
44
|
+
for (const match of text.matchAll(/@([^\s]+)/g)) {
|
|
45
|
+
const raw = match[1]?.replace(/[),.;:!?]+$/g, '')
|
|
46
|
+
if (!raw || raw.length === 0) continue
|
|
47
|
+
mentions.add(raw.replace(/\\/g, '/'))
|
|
48
|
+
}
|
|
49
|
+
return [...mentions]
|
|
50
|
+
}
|