protoagent 0.1.6 → 0.1.8
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/dist/App.js +275 -192
- package/dist/agentic-loop.js +10 -9
- package/dist/components/CollapsibleBox.js +4 -3
- package/dist/components/ConsolidatedToolMessage.js +9 -8
- package/dist/components/FormattedMessage.js +3 -2
- package/dist/components/LeftBar.js +30 -0
- package/dist/system-prompt.js +12 -9
- package/dist/tools/edit-file.js +129 -5
- package/dist/tools/read-file.js +6 -23
- package/dist/tools/write-file.js +6 -0
- package/dist/utils/file-time.js +20 -11
- package/package.json +2 -2
package/dist/App.js
CHANGED
|
@@ -1,15 +1,65 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
Main UI component — the heart of ProtoAgent's terminal interface.
|
|
4
|
+
|
|
5
|
+
Renders the chat loop, tool call feedback, approval prompts,
|
|
6
|
+
and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
|
|
7
|
+
this file is purely presentation + state wiring.
|
|
8
|
+
|
|
9
|
+
Here's how the terminal UI is laid out (showcasing all options at once for demonstration, but in practice many elements are conditional on state):
|
|
10
|
+
┌─────────────────────────────────────────┐
|
|
11
|
+
│ ProtoAgent (BigText logo) │ static, rendered once (printBanner)
|
|
12
|
+
│ Model: Anthropic / claude-3-5 | Sess.. │ static header (printRuntimeHeader)
|
|
13
|
+
│ Debug logs: /path/to/log │ static, if --log-level set
|
|
14
|
+
├─────────────────────────────────────────┤
|
|
15
|
+
│ │
|
|
16
|
+
│ [System Prompt ▸ collapsed] │ archived (memoized)
|
|
17
|
+
│ │
|
|
18
|
+
│ > user message │ archived (memoized)
|
|
19
|
+
│ │
|
|
20
|
+
│ assistant reply text │ archived (memoized)
|
|
21
|
+
│ │
|
|
22
|
+
│ [tool_name ▸ collapsed] │ archived (memoized)
|
|
23
|
+
│ │
|
|
24
|
+
│ > user message │ archived (memoized)
|
|
25
|
+
│ │
|
|
26
|
+
├ ─ ─ ─ ─ ─ ─ ─ live boundary ─ ─ ─ ─ ─ ─ ┤
|
|
27
|
+
│ │
|
|
28
|
+
│ assistant streaming text... │ live (re-renders, ~50ms debounce)
|
|
29
|
+
│ │
|
|
30
|
+
│ [tool_name ▸ collapsed] │ live (re-renders on tool_result)
|
|
31
|
+
│ │
|
|
32
|
+
│ Thinking... │ live, only if last msg is user
|
|
33
|
+
│ │
|
|
34
|
+
│ ╭─ Approval Required ─────────────────╮ │ live, only when pending approval
|
|
35
|
+
│ │ description / detail │ │
|
|
36
|
+
│ │ ○ Approve once │ │
|
|
37
|
+
│ │ ○ Approve for session │ │
|
|
38
|
+
│ │ ○ Reject │ │
|
|
39
|
+
│ ╰─────────────────────────────────────╯ │
|
|
40
|
+
│ │
|
|
41
|
+
│ [Error: message] │ live, inline thread errors
|
|
42
|
+
│ │
|
|
43
|
+
├─────────────────────────────────────────┤
|
|
44
|
+
│ tokens: 1234↓ 56↑ | ctx: 12% | $0.02 │ static-ish, updates after each turn
|
|
45
|
+
├─────────────────────────────────────────┤
|
|
46
|
+
│ /clear — Clear conversation... │ dynamic, shown when typing /
|
|
47
|
+
│ /quit — Exit ProtoAgent │
|
|
48
|
+
├─────────────────────────────────────────┤
|
|
49
|
+
│ ⠹ Running read_file... │ dynamic, shown while loading
|
|
50
|
+
├─────────────────────────────────────────┤
|
|
51
|
+
│ ╭─────────────────────────────────────╮ │
|
|
52
|
+
│ │ > [text input cursor ] │ │ always visible when initialized
|
|
53
|
+
│ ╰─────────────────────────────────────╯ │
|
|
54
|
+
├─────────────────────────────────────────┤
|
|
55
|
+
│ Session saved. Resume with: │ one-shot, shown on /quit
|
|
56
|
+
│ protoagent --session abc12345 │
|
|
57
|
+
└─────────────────────────────────────────┘
|
|
58
|
+
*/
|
|
59
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
60
|
+
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
|
|
61
|
+
import { LeftBar } from './components/LeftBar.js';
|
|
11
62
|
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
|
|
12
|
-
import BigText from 'ink-big-text';
|
|
13
63
|
import { OpenAI } from 'openai';
|
|
14
64
|
import { readConfig, writeConfig, resolveApiKey } from './config.js';
|
|
15
65
|
import { loadRuntimeConfig } from './runtime-config.js';
|
|
@@ -21,117 +71,105 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
|
|
|
21
71
|
import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
|
|
22
72
|
import { initializeMcp, closeMcp } from './mcp.js';
|
|
23
73
|
import { generateSystemPrompt } from './system-prompt.js';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
|
|
38
|
-
const normalizedContent = normalizeMessageSpacing(displayContent || '');
|
|
39
|
-
const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
|
|
40
|
-
const previousMessage = index > 0 ? allMessages[index - 1] : null;
|
|
41
|
-
const followsToolMessage = previousMessage?.role === 'tool';
|
|
42
|
-
const currentSpeaker = getVisualSpeaker(msg);
|
|
43
|
-
const previousSpeaker = getVisualSpeaker(previousMessage);
|
|
44
|
-
const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
|
|
45
|
-
const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
|
|
46
|
-
const speakerChanged = previousSpeaker !== currentSpeaker;
|
|
47
|
-
// Determine if we need a blank-line spacer above this message.
|
|
48
|
-
// At most one spacer is added per message to avoid doubling.
|
|
49
|
-
const needsSpacer = isFirstSystemMessage ||
|
|
50
|
-
(isConversationTurn && previousWasConversationTurn && speakerChanged) ||
|
|
51
|
-
followsToolMessage ||
|
|
52
|
-
(isToolCall && previousMessage != null);
|
|
53
|
-
if (needsSpacer) {
|
|
54
|
-
rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
|
|
55
|
-
}
|
|
56
|
-
if (msg.role === 'user') {
|
|
57
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (msg.role === 'system') {
|
|
61
|
-
rendered.push(_jsx(CollapsibleBox, { title: "System Prompt", content: displayContent || '', titleColor: "green", dimColor: false, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (isToolCall) {
|
|
65
|
-
if (normalizedContent.length > 0) {
|
|
66
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
|
|
67
|
-
}
|
|
68
|
-
const toolCalls = msgAny.tool_calls.map((tc) => ({
|
|
69
|
-
id: tc.id,
|
|
70
|
-
name: tc.function?.name || 'tool',
|
|
71
|
-
}));
|
|
72
|
-
const toolResults = new Map();
|
|
73
|
-
let nextLocalIndex = localIndex + 1;
|
|
74
|
-
for (const toolCall of toolCalls) {
|
|
75
|
-
if (nextLocalIndex < messagesToRender.length) {
|
|
76
|
-
const nextMsg = messagesToRender[nextLocalIndex];
|
|
77
|
-
if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
|
|
78
|
-
toolResults.set(toolCall.id, {
|
|
79
|
-
content: normalizeMessageSpacing(nextMsg.content || ''),
|
|
80
|
-
name: nextMsg.name || toolCall.name,
|
|
81
|
-
});
|
|
82
|
-
skippedIndices.add(nextLocalIndex);
|
|
83
|
-
nextLocalIndex++;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
rendered.push(_jsx(ConsolidatedToolMessage, { toolCalls: toolCalls, toolResults: toolResults, expanded: expandedMessages.has(index) }, index));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (msg.role === 'tool') {
|
|
91
|
-
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
|
|
95
|
-
});
|
|
96
|
-
return rendered;
|
|
74
|
+
// ─── Scrollback helpers ───
|
|
75
|
+
// These functions append text to the permanent scrollback buffer via the
|
|
76
|
+
// <Static> component. Ink flushes new Static items within its own render
|
|
77
|
+
// cycle, so there are no timing issues with write()/log-update.
|
|
78
|
+
function printBanner(addStatic) {
|
|
79
|
+
const green = '\x1b[38;2;9;164;105m';
|
|
80
|
+
const reset = '\x1b[0m';
|
|
81
|
+
addStatic([
|
|
82
|
+
'',
|
|
83
|
+
`${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
|
|
84
|
+
`${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
|
|
85
|
+
'',
|
|
86
|
+
].join('\n'));
|
|
97
87
|
}
|
|
98
|
-
function
|
|
99
|
-
const
|
|
88
|
+
function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
|
|
89
|
+
const provider = getProvider(config.provider);
|
|
90
|
+
const grey = '\x1b[90m';
|
|
91
|
+
const reset = '\x1b[0m';
|
|
92
|
+
let line = `${grey}Model: ${provider?.name || config.provider} / ${config.model}`;
|
|
93
|
+
if (dangerouslyAcceptAll)
|
|
94
|
+
line += ' (auto-approve all)';
|
|
95
|
+
if (session)
|
|
96
|
+
line += ` | Session: ${session.id.slice(0, 8)}`;
|
|
97
|
+
line += reset;
|
|
98
|
+
let text = `${line}\n`;
|
|
99
|
+
if (logFilePath) {
|
|
100
|
+
text += `${grey}Debug logs: ${logFilePath}${reset}\n`;
|
|
101
|
+
}
|
|
102
|
+
text += '\n';
|
|
103
|
+
addStatic(text);
|
|
104
|
+
}
|
|
105
|
+
function normalizeTranscriptText(text) {
|
|
106
|
+
const normalized = text.replace(/\r\n/g, '\n');
|
|
100
107
|
const lines = normalized.split('\n');
|
|
101
|
-
while (lines.length > 0 && lines[0].trim() === '')
|
|
108
|
+
while (lines.length > 0 && lines[0].trim() === '')
|
|
102
109
|
lines.shift();
|
|
103
|
-
|
|
104
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
110
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
105
111
|
lines.pop();
|
|
106
|
-
}
|
|
107
112
|
return lines.join('\n');
|
|
108
113
|
}
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
114
|
-
if (message.role === 'user' || message.role === 'assistant' || message.role === 'system') {
|
|
115
|
-
return message.role;
|
|
114
|
+
function printMessageToScrollback(addStatic, role, text) {
|
|
115
|
+
const normalized = normalizeTranscriptText(text);
|
|
116
|
+
if (!normalized) {
|
|
117
|
+
addStatic('\n');
|
|
118
|
+
return;
|
|
116
119
|
}
|
|
117
|
-
|
|
120
|
+
if (role === 'user') {
|
|
121
|
+
addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
addStatic(`${normalized}\n\n`);
|
|
125
|
+
}
|
|
126
|
+
function replayMessagesToScrollback(addStatic, messages) {
|
|
127
|
+
for (const message of messages) {
|
|
128
|
+
const msgAny = message;
|
|
129
|
+
if (message.role === 'system')
|
|
130
|
+
continue;
|
|
131
|
+
if (message.role === 'user' && typeof message.content === 'string') {
|
|
132
|
+
printMessageToScrollback(addStatic, 'user', message.content);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (message.role === 'assistant' && typeof message.content === 'string' && message.content.trim().length > 0) {
|
|
136
|
+
printMessageToScrollback(addStatic, 'assistant', message.content);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (message.role === 'tool') {
|
|
140
|
+
const toolName = msgAny.name || 'tool';
|
|
141
|
+
const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
142
|
+
addStatic(`\x1b[2m▶ ${toolName}: ${compact}\x1b[0m\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (messages.length > 0) {
|
|
146
|
+
addStatic('\n');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Returns only the last N displayable lines of text so the live streaming box
|
|
150
|
+
// never grows taller than the terminal, preventing Ink's clearTerminal wipe.
|
|
151
|
+
const STREAMING_RESERVED_ROWS = 3; // usage bar + spinner + input line
|
|
152
|
+
function clipToRows(text, terminalRows) {
|
|
153
|
+
const maxLines = Math.max(1, terminalRows - STREAMING_RESERVED_ROWS);
|
|
154
|
+
const lines = text.split('\n');
|
|
155
|
+
if (lines.length <= maxLines)
|
|
156
|
+
return text;
|
|
157
|
+
return lines.slice(lines.length - maxLines).join('\n');
|
|
118
158
|
}
|
|
119
159
|
// ─── Available slash commands ───
|
|
120
160
|
const SLASH_COMMANDS = [
|
|
121
161
|
{ name: '/clear', description: 'Clear conversation and start fresh' },
|
|
122
|
-
{ name: '/collapse', description: 'Collapse all long messages' },
|
|
123
|
-
{ name: '/expand', description: 'Expand all collapsed messages' },
|
|
124
162
|
{ name: '/help', description: 'Show all available commands' },
|
|
125
163
|
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
164
|
+
{ name: '/exit', description: 'Alias for /quit' },
|
|
126
165
|
];
|
|
127
166
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
128
167
|
const HELP_TEXT = [
|
|
129
168
|
'Commands:',
|
|
130
|
-
' /clear
|
|
131
|
-
' /
|
|
132
|
-
' /
|
|
133
|
-
' /
|
|
134
|
-
' /quit - Exit ProtoAgent',
|
|
169
|
+
' /clear - Clear conversation and start fresh',
|
|
170
|
+
' /help - Show this help',
|
|
171
|
+
' /quit - Exit ProtoAgent',
|
|
172
|
+
' /exit - Alias for /quit',
|
|
135
173
|
].join('\n');
|
|
136
174
|
function buildClient(config) {
|
|
137
175
|
const provider = getProvider(config.provider);
|
|
@@ -194,13 +232,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
|
|
|
194
232
|
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
195
233
|
{ label: 'Reject', value: 'reject' },
|
|
196
234
|
];
|
|
197
|
-
return (_jsxs(
|
|
235
|
+
return (_jsxs(LeftBar, { color: "green", marginTop: 1, marginBottom: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Approval Required" }), _jsx(Text, { children: request.description }), request.detail && (_jsx(Text, { dimColor: true, children: request.detail.length > 200 ? request.detail.slice(0, 200) + '...' : request.detail })), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: items.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => onRespond(value) }) })] }));
|
|
198
236
|
};
|
|
199
237
|
/** Cost/usage display in the status bar. */
|
|
200
238
|
const UsageDisplay = ({ usage, totalCost }) => {
|
|
201
239
|
if (!usage && totalCost === 0)
|
|
202
240
|
return null;
|
|
203
|
-
return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(
|
|
241
|
+
return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(Box, { children: [_jsxs(Box, { backgroundColor: "#042f2e", paddingX: 1, children: [_jsx(Text, { color: "black", children: "tokens: " }), _jsxs(Text, { color: "black", bold: true, children: [usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191"] })] }), _jsxs(Box, { backgroundColor: "#064e3b", paddingX: 1, children: [_jsx(Text, { color: "black", children: "ctx: " }), _jsxs(Text, { color: "black", bold: true, children: [usage.contextPercent.toFixed(0), "%"] })] })] })), _jsxs(Box, { backgroundColor: "#042f2e", paddingX: 1, children: [_jsx(Text, { color: "black", children: "cost: " }), _jsxs(Text, { color: "black", bold: true, children: ["$", totalCost.toFixed(4)] })] })] }));
|
|
204
242
|
};
|
|
205
243
|
/** Inline setup wizard — shown when no config exists. */
|
|
206
244
|
const InlineSetup = ({ onComplete }) => {
|
|
@@ -240,10 +278,35 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
240
278
|
export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
241
279
|
const { exit } = useApp();
|
|
242
280
|
const { stdout } = useStdout();
|
|
281
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
282
|
+
// ─── Static scrollback state ───
|
|
283
|
+
// Each item appended here is rendered once by <Static> and permanently
|
|
284
|
+
// flushed to the terminal scrollback by Ink, within its own render cycle.
|
|
285
|
+
// Using <Static> items is important to avoid re-rendering issues, which hijack
|
|
286
|
+
// scrollback and copying when new AI message streams are coming in.
|
|
287
|
+
//
|
|
288
|
+
// staticCounterRef keeps ID generation local to this component instance,
|
|
289
|
+
// making it immune to Strict Mode double-invoke, HMR counter drift, and
|
|
290
|
+
// collisions if multiple App instances ever coexist.
|
|
291
|
+
const staticCounterRef = useRef(0);
|
|
292
|
+
const [staticItems, setStaticItems] = useState([]);
|
|
293
|
+
const addStatic = useCallback((text) => {
|
|
294
|
+
staticCounterRef.current += 1;
|
|
295
|
+
const id = `s${staticCounterRef.current}`;
|
|
296
|
+
setStaticItems((prev) => [...prev, { id, text }]);
|
|
297
|
+
}, []);
|
|
243
298
|
// Core state
|
|
244
299
|
const [config, setConfig] = useState(null);
|
|
245
300
|
const [completionMessages, setCompletionMessages] = useState([]);
|
|
246
301
|
const [inputText, setInputText] = useState('');
|
|
302
|
+
// isStreaming: true while the assistant is producing tokens.
|
|
303
|
+
// streamingText: the live in-progress token buffer shown in the dynamic Ink
|
|
304
|
+
// frame while the response streams. Cleared to '' at done and flushed to
|
|
305
|
+
// <Static> as a permanent scrollback item. Keeping it in React state (not a
|
|
306
|
+
// ref) is safe because the Ink frame height does NOT change as tokens arrive —
|
|
307
|
+
// the streaming box is always 1+ lines tall while loading=true.
|
|
308
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
309
|
+
const [streamingText, setStreamingText] = useState('');
|
|
247
310
|
const [loading, setLoading] = useState(false);
|
|
248
311
|
const [error, setError] = useState(null);
|
|
249
312
|
const [helpMessage, setHelpMessage] = useState(null);
|
|
@@ -253,18 +316,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
253
316
|
const [logFilePath, setLogFilePath] = useState(null);
|
|
254
317
|
// Input reset key — incremented on submit to force TextInput remount and clear
|
|
255
318
|
const [inputResetKey, setInputResetKey] = useState(0);
|
|
256
|
-
const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
|
|
257
|
-
// Collapsible state — only applies to live (current turn) messages
|
|
258
|
-
const [expandedMessages, setExpandedMessages] = useState(new Set());
|
|
259
|
-
const expandLatestMessage = useCallback((index) => {
|
|
260
|
-
setExpandedMessages((prev) => {
|
|
261
|
-
if (prev.has(index))
|
|
262
|
-
return prev;
|
|
263
|
-
const next = new Set(prev);
|
|
264
|
-
next.add(index);
|
|
265
|
-
return next;
|
|
266
|
-
});
|
|
267
|
-
}, []);
|
|
268
319
|
// Approval state
|
|
269
320
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
270
321
|
// Usage state
|
|
@@ -279,12 +330,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
279
330
|
const [quittingSession, setQuittingSession] = useState(null);
|
|
280
331
|
// OpenAI client ref (stable across renders)
|
|
281
332
|
const clientRef = useRef(null);
|
|
282
|
-
// Track current assistant message being built in the event handler
|
|
283
333
|
const assistantMessageRef = useRef(null);
|
|
284
334
|
// Abort controller for cancelling the current completion
|
|
285
335
|
const abortControllerRef = useRef(null);
|
|
286
|
-
|
|
287
|
-
const
|
|
336
|
+
const didPrintIntroRef = useRef(false);
|
|
337
|
+
const printedThreadErrorIdsRef = useRef(new Set());
|
|
338
|
+
const printedLogPathRef = useRef(null);
|
|
288
339
|
// ─── Post-config initialization (reused after inline setup) ───
|
|
289
340
|
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
290
341
|
setConfig(loadedConfig);
|
|
@@ -301,6 +352,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
301
352
|
setTodosForSession(loadedSession.id, loadedSession.todos);
|
|
302
353
|
setSession(loadedSession);
|
|
303
354
|
setCompletionMessages(loadedSession.completionMessages);
|
|
355
|
+
if (!didPrintIntroRef.current) {
|
|
356
|
+
printBanner(addStatic);
|
|
357
|
+
printRuntimeHeader(addStatic, loadedConfig, loadedSession, logFilePath, dangerouslyAcceptAll);
|
|
358
|
+
replayMessagesToScrollback(addStatic, loadedSession.completionMessages);
|
|
359
|
+
didPrintIntroRef.current = true;
|
|
360
|
+
}
|
|
304
361
|
}
|
|
305
362
|
else {
|
|
306
363
|
setError(`Session "${sessionId}" not found. Starting a new session.`);
|
|
@@ -314,10 +371,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
314
371
|
clearTodos(newSession.id);
|
|
315
372
|
newSession.completionMessages = initialCompletionMessages;
|
|
316
373
|
setSession(newSession);
|
|
374
|
+
if (!didPrintIntroRef.current) {
|
|
375
|
+
printBanner(addStatic);
|
|
376
|
+
printRuntimeHeader(addStatic, loadedConfig, newSession, logFilePath, dangerouslyAcceptAll);
|
|
377
|
+
didPrintIntroRef.current = true;
|
|
378
|
+
}
|
|
317
379
|
}
|
|
318
380
|
setNeedsSetup(false);
|
|
319
381
|
setInitialized(true);
|
|
320
|
-
}, [sessionId]);
|
|
382
|
+
}, [dangerouslyAcceptAll, logFilePath, sessionId, addStatic]);
|
|
321
383
|
// ─── Initialization ───
|
|
322
384
|
useEffect(() => {
|
|
323
385
|
if (!loading) {
|
|
@@ -330,17 +392,26 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
330
392
|
return () => clearInterval(interval);
|
|
331
393
|
}, [loading]);
|
|
332
394
|
useEffect(() => {
|
|
333
|
-
if (
|
|
395
|
+
if (error) {
|
|
396
|
+
addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
|
|
397
|
+
}
|
|
398
|
+
}, [error, addStatic]);
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (!didPrintIntroRef.current || !logFilePath || printedLogPathRef.current === logFilePath) {
|
|
334
401
|
return;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
402
|
+
}
|
|
403
|
+
printedLogPathRef.current = logFilePath;
|
|
404
|
+
addStatic(`Debug logs: ${logFilePath}\n\n`);
|
|
405
|
+
}, [logFilePath, addStatic]);
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
for (const threadError of threadErrors) {
|
|
408
|
+
if (threadError.transient || printedThreadErrorIdsRef.current.has(threadError.id)) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
printedThreadErrorIdsRef.current.add(threadError.id);
|
|
412
|
+
addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
|
|
413
|
+
}
|
|
414
|
+
}, [threadErrors, addStatic]);
|
|
344
415
|
useEffect(() => {
|
|
345
416
|
const init = async () => {
|
|
346
417
|
// Set log level and initialize log file
|
|
@@ -418,7 +489,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
418
489
|
setLastUsage(null);
|
|
419
490
|
setTotalCost(0);
|
|
420
491
|
setThreadErrors([]);
|
|
421
|
-
setExpandedMessages(new Set());
|
|
422
492
|
if (session) {
|
|
423
493
|
const newSession = createSession(config.model, config.provider);
|
|
424
494
|
clearTodos(session.id);
|
|
@@ -429,13 +499,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
429
499
|
});
|
|
430
500
|
return true;
|
|
431
501
|
case '/expand':
|
|
432
|
-
// Expand all collapsed messages
|
|
433
|
-
const allIndices = new Set(completionMessages.map((_, i) => i));
|
|
434
|
-
setExpandedMessages(allIndices);
|
|
435
|
-
return true;
|
|
436
502
|
case '/collapse':
|
|
437
|
-
//
|
|
438
|
-
setExpandedMessages(new Set());
|
|
503
|
+
// expand/collapse removed — transcript lives in scrollback
|
|
439
504
|
return true;
|
|
440
505
|
case '/help':
|
|
441
506
|
setHelpMessage(HELP_TEXT);
|
|
@@ -461,13 +526,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
461
526
|
setInputText('');
|
|
462
527
|
setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
|
|
463
528
|
setLoading(true);
|
|
529
|
+
setIsStreaming(false);
|
|
530
|
+
setStreamingText('');
|
|
464
531
|
setError(null);
|
|
465
532
|
setHelpMessage(null);
|
|
466
533
|
setThreadErrors([]);
|
|
467
|
-
//
|
|
534
|
+
// Reset turn tracking
|
|
535
|
+
assistantMessageRef.current = null;
|
|
536
|
+
// Print the user message directly to scrollback so it is selectable/copyable.
|
|
537
|
+
// We still push it into completionMessages for session saving.
|
|
468
538
|
const userMessage = { role: 'user', content: trimmed };
|
|
539
|
+
printMessageToScrollback(addStatic, 'user', trimmed);
|
|
469
540
|
setCompletionMessages((prev) => [...prev, userMessage]);
|
|
470
|
-
// Reset assistant message tracker
|
|
541
|
+
// Reset assistant message tracker (streamed indices were reset above)
|
|
471
542
|
assistantMessageRef.current = null;
|
|
472
543
|
try {
|
|
473
544
|
const pricing = getModelPricing(config.provider, config.model);
|
|
@@ -476,33 +547,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
476
547
|
abortControllerRef.current = new AbortController();
|
|
477
548
|
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
|
|
478
549
|
switch (event.type) {
|
|
479
|
-
case 'text_delta':
|
|
480
|
-
//
|
|
550
|
+
case 'text_delta': {
|
|
551
|
+
// Accumulate tokens into streamingText React state — shown live in
|
|
552
|
+
// the dynamic Ink frame. The frame height stays constant (spinner +
|
|
553
|
+
// streaming box + input) so setState here does NOT trigger
|
|
554
|
+
// clearTerminal. At 'done' the full text is flushed to <Static>.
|
|
481
555
|
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
482
|
-
// First text delta
|
|
556
|
+
// First text delta of this turn: initialise ref, show streaming indicator.
|
|
483
557
|
const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
558
|
+
const idx = completionMessages.length + 1;
|
|
559
|
+
assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
|
|
560
|
+
setIsStreaming(true);
|
|
561
|
+
setStreamingText(event.content || '');
|
|
562
|
+
setCompletionMessages((prev) => [...prev, assistantMsg]);
|
|
488
563
|
}
|
|
489
564
|
else {
|
|
490
|
-
// Subsequent deltas —
|
|
565
|
+
// Subsequent deltas — append to ref AND to React state for live display.
|
|
491
566
|
assistantMessageRef.current.message.content += event.content || '';
|
|
492
|
-
|
|
493
|
-
textFlushTimerRef.current = setTimeout(() => {
|
|
494
|
-
textFlushTimerRef.current = null;
|
|
495
|
-
setCompletionMessages((prev) => {
|
|
496
|
-
if (!assistantMessageRef.current)
|
|
497
|
-
return prev;
|
|
498
|
-
const updated = [...prev];
|
|
499
|
-
updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
|
|
500
|
-
return updated;
|
|
501
|
-
});
|
|
502
|
-
}, 50);
|
|
503
|
-
}
|
|
567
|
+
setStreamingText((prev) => prev + (event.content || ''));
|
|
504
568
|
}
|
|
505
569
|
break;
|
|
570
|
+
}
|
|
506
571
|
case 'sub_agent_iteration':
|
|
507
572
|
if (event.subAgentTool) {
|
|
508
573
|
const { tool, status } = event.subAgentTool;
|
|
@@ -518,6 +583,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
518
583
|
if (event.toolCall) {
|
|
519
584
|
const toolCall = event.toolCall;
|
|
520
585
|
setActiveTool(toolCall.name);
|
|
586
|
+
// If the model streamed some text before invoking this tool,
|
|
587
|
+
// flush it to <Static> now. Without this, streamingText is
|
|
588
|
+
// never cleared — the 'done' handler only flushes streaming_text
|
|
589
|
+
// when the turn ends with plain text, not with tool calls.
|
|
590
|
+
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
591
|
+
const precedingText = assistantMessageRef.current.message.content || '';
|
|
592
|
+
if (precedingText) {
|
|
593
|
+
addStatic(`${normalizeTranscriptText(precedingText)}\n\n`);
|
|
594
|
+
}
|
|
595
|
+
setIsStreaming(false);
|
|
596
|
+
setStreamingText('');
|
|
597
|
+
assistantMessageRef.current = null;
|
|
598
|
+
}
|
|
521
599
|
// Track the tool call in the ref WITHOUT triggering a render.
|
|
522
600
|
// The render will happen when tool_result arrives.
|
|
523
601
|
const existingRef = assistantMessageRef.current;
|
|
@@ -564,16 +642,20 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
564
642
|
if (event.toolCall) {
|
|
565
643
|
const toolCall = event.toolCall;
|
|
566
644
|
setActiveTool(null);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
645
|
+
// Write the tool summary immediately — at this point loading is
|
|
646
|
+
// still true but the frame height is stable (spinner + input box).
|
|
647
|
+
// The next state change (setActiveTool(null)) doesn't affect
|
|
648
|
+
// frame height so write() restores the correct frame.
|
|
649
|
+
const compactResult = (toolCall.result || '')
|
|
650
|
+
.replace(/\s+/g, ' ')
|
|
651
|
+
.trim()
|
|
652
|
+
.slice(0, 180);
|
|
653
|
+
addStatic(`\x1b[2m▶ ${toolCall.name}: ${compactResult}\x1b[0m\n`);
|
|
654
|
+
// Flush the assistant message + tool result into completionMessages
|
|
655
|
+
// for session saving.
|
|
574
656
|
setCompletionMessages((prev) => {
|
|
575
657
|
const updated = [...prev];
|
|
576
|
-
// Sync assistant message
|
|
658
|
+
// Sync assistant message
|
|
577
659
|
if (assistantMessageRef.current) {
|
|
578
660
|
updated[assistantMessageRef.current.index] = {
|
|
579
661
|
...assistantMessageRef.current.message,
|
|
@@ -628,13 +710,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
628
710
|
}
|
|
629
711
|
break;
|
|
630
712
|
case 'iteration_done':
|
|
631
|
-
assistantMessageRef.current
|
|
713
|
+
if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
|
|
714
|
+
assistantMessageRef.current = null;
|
|
715
|
+
}
|
|
632
716
|
break;
|
|
633
717
|
case 'done':
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
718
|
+
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
719
|
+
const finalRef = assistantMessageRef.current;
|
|
720
|
+
// Flush the complete streamed text to <Static> (permanent scrollback),
|
|
721
|
+
// then clear the live streaming state from the dynamic Ink frame.
|
|
722
|
+
const normalized = normalizeTranscriptText(finalRef.message.content || '');
|
|
723
|
+
if (normalized) {
|
|
724
|
+
addStatic(`${normalized}\n\n`);
|
|
725
|
+
}
|
|
726
|
+
setIsStreaming(false);
|
|
727
|
+
setStreamingText('');
|
|
728
|
+
setCompletionMessages((prev) => {
|
|
729
|
+
const updated = [...prev];
|
|
730
|
+
updated[finalRef.index] = { ...finalRef.message };
|
|
731
|
+
return updated;
|
|
732
|
+
});
|
|
733
|
+
assistantMessageRef.current = null;
|
|
638
734
|
}
|
|
639
735
|
setActiveTool(null);
|
|
640
736
|
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
@@ -662,7 +758,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
662
758
|
finally {
|
|
663
759
|
setLoading(false);
|
|
664
760
|
}
|
|
665
|
-
}, [loading, config, completionMessages, session, handleSlashCommand,
|
|
761
|
+
}, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
|
|
666
762
|
// ─── Keyboard shortcuts ───
|
|
667
763
|
useInput((input, key) => {
|
|
668
764
|
if (key.ctrl && input === 'c') {
|
|
@@ -674,25 +770,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
674
770
|
}
|
|
675
771
|
});
|
|
676
772
|
// ─── Render ───
|
|
677
|
-
|
|
678
|
-
const liveStartIndex = loading
|
|
679
|
-
? (typeof assistantMessageRef.current?.index === 'number'
|
|
680
|
-
? assistantMessageRef.current.index
|
|
681
|
-
: Math.max(completionMessages.length - 1, 0))
|
|
682
|
-
: completionMessages.length;
|
|
683
|
-
const archivedMessages = completionMessages.slice(0, liveStartIndex);
|
|
684
|
-
const liveMessages = completionMessages.slice(liveStartIndex);
|
|
685
|
-
const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
|
|
686
|
-
const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
|
|
687
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }), config && (_jsxs(Text, { dimColor: true, children: ["Model: ", providerInfo?.name || config.provider, " / ", config.model, dangerouslyAcceptAll && _jsx(Text, { color: "red", children: " (auto-approve all)" }), session && _jsxs(Text, { dimColor: true, children: [" | Session: ", session.id.slice(0, 8)] })] })), logFilePath && _jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }), error && _jsx(Text, { color: "red", children: error }), helpMessage && (_jsx(CollapsibleBox, { title: "Help", content: helpMessage, titleColor: "green", dimColor: false, maxPreviewLines: 10, expanded: true })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
773
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => (_jsx(Text, { children: item.text }, item.id)) }), helpMessage && (_jsx(LeftBar, { color: "green", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: helpMessage }) })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
688
774
|
initializeWithConfig(newConfig).catch((err) => {
|
|
689
775
|
setError(`Initialization failed: ${err.message}`);
|
|
690
776
|
});
|
|
691
|
-
} })), _jsxs(
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
pendingApproval.resolve(response);
|
|
696
|
-
setPendingApproval(null);
|
|
697
|
-
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: '>' }) }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, `${inputResetKey}-${inputWidthKey}`) })] }) }, `input-shell-${inputWidthKey}`)), quittingSession && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Session saved. Resume with:" }), _jsxs(Text, { color: "green", children: ["protoagent --session ", quittingSession.id] })] }))] }));
|
|
777
|
+
} })), isStreaming && (_jsxs(Text, { wrap: "wrap", children: [clipToRows(streamingText, terminalRows), _jsx(Text, { dimColor: true, children: "\u258D" })] })), threadErrors.filter((threadError) => threadError.transient).map((threadError) => (_jsx(LeftBar, { color: "red", marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
|
|
778
|
+
pendingApproval.resolve(response);
|
|
779
|
+
setPendingApproval(null);
|
|
780
|
+
} })), initialized && !pendingApproval && loading && !isStreaming && (_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && (_jsx(Box, { children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: '>' }) }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, inputResetKey) })] }) })), quittingSession && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Session saved. Resume with:" }), _jsxs(Text, { color: "green", children: ["protoagent --session ", quittingSession.id] })] }))] }));
|
|
698
781
|
};
|
package/dist/agentic-loop.js
CHANGED
|
@@ -46,21 +46,22 @@ async function sleepWithAbort(delayMs, abortSignal) {
|
|
|
46
46
|
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/** @internal exported for unit testing only */
|
|
50
|
+
export function appendStreamingFragment(current, fragment) {
|
|
50
51
|
if (!fragment)
|
|
51
52
|
return current;
|
|
52
53
|
if (!current)
|
|
53
54
|
return fragment;
|
|
55
|
+
// Some providers resend the full accumulated value instead of a delta.
|
|
56
|
+
// These two guards handle that case without corrupting normal incremental deltas.
|
|
54
57
|
if (current === fragment)
|
|
55
58
|
return current;
|
|
56
59
|
if (fragment.startsWith(current))
|
|
57
60
|
return fragment;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
}
|
|
61
|
+
// Normal case: incremental delta, just append.
|
|
62
|
+
// The previous partial-overlap loop was removed because it caused false-positive
|
|
63
|
+
// deduplication: short JSON tokens (e.g. `", "`) would coincidentally match the
|
|
64
|
+
// tail of `current`, silently stripping characters from valid argument payloads.
|
|
64
65
|
return current + fragment;
|
|
65
66
|
}
|
|
66
67
|
function collapseRepeatedString(value) {
|
|
@@ -347,7 +348,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
347
348
|
if (chunk.usage) {
|
|
348
349
|
actualUsage = chunk.usage;
|
|
349
350
|
}
|
|
350
|
-
// Stream text content
|
|
351
|
+
// Stream text content (and return to UI for immediate display via onEvent)
|
|
351
352
|
if (delta?.content) {
|
|
352
353
|
streamedContent += delta.content;
|
|
353
354
|
assistantMessage.content = streamedContent;
|
|
@@ -355,7 +356,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
355
356
|
onEvent({ type: 'text_delta', content: delta.content });
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
|
-
// Accumulate tool calls
|
|
359
|
+
// Accumulate tool calls across stream chunks
|
|
359
360
|
if (delta?.tool_calls) {
|
|
360
361
|
hasToolCalls = true;
|
|
361
362
|
for (const tc of delta.tool_calls) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { LeftBar } from './LeftBar.js';
|
|
3
4
|
export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, maxPreviewLines = 3, maxPreviewChars = 500, expanded = false, marginBottom = 0, }) => {
|
|
4
5
|
const lines = content.split('\n');
|
|
5
6
|
const isTooManyLines = lines.length > maxPreviewLines;
|
|
@@ -7,7 +8,7 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
7
8
|
const isLong = isTooManyLines || isTooManyChars;
|
|
8
9
|
// If content is short, always show it
|
|
9
10
|
if (!isLong) {
|
|
10
|
-
return (_jsxs(
|
|
11
|
+
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Text, { dimColor: dimColor, children: content })] }));
|
|
11
12
|
}
|
|
12
13
|
// For long content, show preview or full content
|
|
13
14
|
let preview;
|
|
@@ -22,5 +23,5 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
22
23
|
: linesTruncated;
|
|
23
24
|
}
|
|
24
25
|
const hasMore = !expanded;
|
|
25
|
-
return (_jsxs(
|
|
26
|
+
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] }));
|
|
26
27
|
};
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { FormattedMessage } from './FormattedMessage.js';
|
|
4
|
+
import { LeftBar } from './LeftBar.js';
|
|
4
5
|
export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
|
|
5
6
|
const toolNames = toolCalls.map((toolCall) => toolCall.name);
|
|
6
7
|
const title = `Called: ${toolNames.join(', ')}`;
|
|
7
8
|
const containsTodoTool = toolCalls.some((toolCall) => toolCall.name === 'todo_read' || toolCall.name === 'todo_write');
|
|
8
|
-
const titleColor = containsTodoTool ? 'green' : '
|
|
9
|
+
const titleColor = containsTodoTool ? 'green' : 'cyan';
|
|
9
10
|
const isExpanded = expanded || containsTodoTool;
|
|
10
11
|
if (isExpanded) {
|
|
11
|
-
return (_jsxs(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
return (_jsxs(LeftBar, { color: titleColor, children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), toolCalls.map((toolCall, idx) => {
|
|
13
|
+
const result = toolResults.get(toolCall.id);
|
|
14
|
+
if (!result)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
|
|
17
|
+
})] }));
|
|
17
18
|
}
|
|
18
19
|
const compactLines = toolCalls.flatMap((toolCall) => {
|
|
19
20
|
const result = toolResults.get(toolCall.id);
|
|
@@ -29,5 +30,5 @@ export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = fal
|
|
|
29
30
|
const preview = compactPreview.length > previewLimit
|
|
30
31
|
? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
|
|
31
32
|
: compactPreview;
|
|
32
|
-
return (_jsxs(
|
|
33
|
+
return (_jsxs(LeftBar, { color: "white", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Text, { dimColor: true, children: preview })] }));
|
|
33
34
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { formatMessage } from '../utils/format-message.js';
|
|
4
|
+
import { LeftBar } from './LeftBar.js';
|
|
4
5
|
export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
|
|
5
6
|
const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
6
7
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
@@ -156,10 +157,10 @@ export const FormattedMessage = ({ content, deferTables = false }) => {
|
|
|
156
157
|
if (deferTables) {
|
|
157
158
|
return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
|
|
158
159
|
}
|
|
159
|
-
return (_jsx(
|
|
160
|
+
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
|
|
160
161
|
}
|
|
161
162
|
if (block.type === 'code') {
|
|
162
|
-
return (_jsx(
|
|
163
|
+
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
|
|
163
164
|
}
|
|
164
165
|
// Text Block
|
|
165
166
|
if (!block.content.trim())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* LeftBar — renders a bold green vertical bar (│) on the left side of
|
|
4
|
+
* content, like a GitHub "note" callout. The bar stretches to match the
|
|
5
|
+
* full height of the content by measuring the content box after each render
|
|
6
|
+
* and repeating the │ character once per row.
|
|
7
|
+
*
|
|
8
|
+
* Uses Ink's measureElement (available in stock Ink) rather than a Box
|
|
9
|
+
* border, so it adds zero extra border lines and avoids ghosting on resize.
|
|
10
|
+
*/
|
|
11
|
+
import { useRef, useState, useLayoutEffect } from 'react';
|
|
12
|
+
import { Box, Text, measureElement } from 'ink';
|
|
13
|
+
export const LeftBar = ({ color = 'green', children, marginTop = 0, marginBottom = 0, }) => {
|
|
14
|
+
const contentRef = useRef(null);
|
|
15
|
+
const [height, setHeight] = useState(1);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
if (contentRef.current) {
|
|
18
|
+
try {
|
|
19
|
+
const { height: h } = measureElement(contentRef.current);
|
|
20
|
+
if (h > 0)
|
|
21
|
+
setHeight(h);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// measureElement can throw before layout is complete; keep previous height
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const bar = Array.from({ length: height }, () => '│').join('\n');
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: marginTop, marginBottom: marginBottom, children: [_jsx(Box, { flexDirection: "column", marginRight: 1, children: _jsx(Text, { color: color, bold: true, children: bar }) }), _jsx(Box, { ref: contentRef, flexDirection: "column", flexGrow: 1, children: children })] }));
|
|
30
|
+
};
|
package/dist/system-prompt.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function generateSystemPrompt() {
|
|
|
67
67
|
const toolDescriptions = generateToolDescriptions();
|
|
68
68
|
const skillsSection = buildSkillsCatalogSection(skills);
|
|
69
69
|
return `You are ProtoAgent, a coding assistant with file system and shell command capabilities.
|
|
70
|
-
Your job is to help the user complete coding tasks in their project.
|
|
70
|
+
Your job is to help the user complete coding tasks in their project. You must be absolutely careful and diligent in your work, and follow all guidelines to the letter. Always prefer thoroughness and correctness over speed. Never cut corners.
|
|
71
71
|
|
|
72
72
|
PROJECT CONTEXT
|
|
73
73
|
|
|
@@ -85,13 +85,13 @@ GUIDELINES
|
|
|
85
85
|
|
|
86
86
|
OUTPUT FORMAT:
|
|
87
87
|
- You are running in a terminal. Be concise. Optimise for scannability.
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- Use flat
|
|
88
|
+
- Do NOT use Markdown formatting. No **bold**, no *italic*, no # headers, no --- dividers.
|
|
89
|
+
- Do NOT use Markdown code fences (backticks) unless the content is actual code or a command.
|
|
90
|
+
- For structured data, use plain text with aligned columns (spaces, not pipes/dashes).
|
|
91
|
+
- Keep tables compact: narrower columns, minimal padding. Wrap cell content rather than making very wide tables.
|
|
92
|
+
- Use flat plain-text lists with a simple dash or symbol prefix (e.g. - item, or ✅ done, ❌ failed).
|
|
93
93
|
- NEVER use nested indentation. Keep all lists flat — one level only.
|
|
94
|
-
- Markdown links [text](url)
|
|
94
|
+
- Do NOT use Markdown links [text](url) — just write URLs inline.
|
|
95
95
|
|
|
96
96
|
SUBAGENT STRATEGY:
|
|
97
97
|
Delegate work to specialized subagents aggressively. They excel at focused, parallel tasks.
|
|
@@ -121,8 +121,11 @@ FILE OPERATIONS:
|
|
|
121
121
|
- ALWAYS use read_file before editing to get exact content.
|
|
122
122
|
- NEVER write over existing files unless explicitly asked — use edit_file instead.
|
|
123
123
|
- Create parent directories before creating files in them.
|
|
124
|
-
-
|
|
125
|
-
-
|
|
124
|
+
- INDENTATION: when writing new_string for edit_file, preserve the exact indentation of every line. Copy the indent character-for-character from the file. A single dropped space is a bug.
|
|
125
|
+
- STRICT TYPO PREVENTION: You have a tendency to drop characters or misspell words (e.g., "commands" vs "comands") when generating long code blocks. Before submitting a tool call, perform a character-by-character mental audit.
|
|
126
|
+
- VERIFICATION STEP: After generating a new_string, compare it against the old_string. Ensure that only the intended changes are present and that no existing words have been accidentally altered or truncated.
|
|
127
|
+
- NO TRUNCATION: Never truncate code or leave "..." in your tool calls. Every string must be literal and complete.
|
|
128
|
+
- IF edit_file FAILS: do NOT retry by guessing or reconstructing old_string from memory. Call read_file on the file first, then copy the exact text verbatim for old_string. The error output shows exactly which lines differ between your old_string and the file — read those carefully before retrying.
|
|
126
129
|
|
|
127
130
|
IMPLEMENTATION STANDARDS:
|
|
128
131
|
- **Thorough investigation**: Before implementing, understand the existing codebase, patterns, and related systems.
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from 'node:fs/promises';
|
|
9
9
|
import path from 'node:path';
|
|
10
|
-
import { validatePath } from '../utils/path-validation.js';
|
|
10
|
+
import { validatePath, getWorkingDirectory } from '../utils/path-validation.js';
|
|
11
11
|
import { requestApproval } from '../utils/approval.js';
|
|
12
|
-
import {
|
|
12
|
+
import { checkReadBefore, recordRead } from '../utils/file-time.js';
|
|
13
13
|
export const editFileTool = {
|
|
14
14
|
type: 'function',
|
|
15
15
|
function: {
|
|
@@ -32,6 +32,54 @@ export const editFileTool = {
|
|
|
32
32
|
},
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
|
+
// ─── Path suggestion helper (mirrors read_file behaviour) ───
|
|
36
|
+
async function findSimilarPaths(requestedPath) {
|
|
37
|
+
const cwd = getWorkingDirectory();
|
|
38
|
+
const segments = requestedPath.split('/').filter(Boolean);
|
|
39
|
+
const MAX_DEPTH = 6;
|
|
40
|
+
const MAX_ENTRIES = 200;
|
|
41
|
+
const MAX_SUGGESTIONS = 3;
|
|
42
|
+
const candidates = [];
|
|
43
|
+
async function walkSegments(dir, segIndex, currentPath) {
|
|
44
|
+
if (segIndex >= segments.length || segIndex >= MAX_DEPTH || candidates.length >= MAX_SUGGESTIONS)
|
|
45
|
+
return;
|
|
46
|
+
const targetSegment = segments[segIndex].toLowerCase();
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = (await fs.readdir(dir, { withFileTypes: true })).slice(0, MAX_ENTRIES).map(e => e.name);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const isLastSegment = segIndex === segments.length - 1;
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
if (candidates.length >= MAX_SUGGESTIONS)
|
|
57
|
+
break;
|
|
58
|
+
const entryLower = entry.toLowerCase();
|
|
59
|
+
if (!entryLower.includes(targetSegment) && !targetSegment.includes(entryLower))
|
|
60
|
+
continue;
|
|
61
|
+
const entryPath = path.join(currentPath, entry);
|
|
62
|
+
const fullPath = path.join(dir, entry);
|
|
63
|
+
if (isLastSegment) {
|
|
64
|
+
try {
|
|
65
|
+
await fs.stat(fullPath);
|
|
66
|
+
candidates.push(entryPath);
|
|
67
|
+
}
|
|
68
|
+
catch { /* skip */ }
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
try {
|
|
72
|
+
const stat = await fs.stat(fullPath);
|
|
73
|
+
if (stat.isDirectory())
|
|
74
|
+
await walkSegments(fullPath, segIndex + 1, entryPath);
|
|
75
|
+
}
|
|
76
|
+
catch { /* skip */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await walkSegments(cwd, 0, '');
|
|
81
|
+
return candidates;
|
|
82
|
+
}
|
|
35
83
|
/** Strategy 1: Exact verbatim match (current behavior). */
|
|
36
84
|
const exactReplacer = {
|
|
37
85
|
name: 'exact',
|
|
@@ -245,10 +293,26 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
|
|
|
245
293
|
if (oldString.length === 0) {
|
|
246
294
|
return 'Error: old_string cannot be empty.';
|
|
247
295
|
}
|
|
248
|
-
|
|
296
|
+
let validated;
|
|
297
|
+
try {
|
|
298
|
+
validated = await validatePath(filePath);
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
if (err.message?.includes('does not exist') || err.code === 'ENOENT') {
|
|
302
|
+
const suggestions = await findSimilarPaths(filePath);
|
|
303
|
+
let msg = `File not found: '${filePath}'.`;
|
|
304
|
+
if (suggestions.length > 0) {
|
|
305
|
+
msg += ' Did you mean one of these?\n' + suggestions.map(s => ` ${s}`).join('\n');
|
|
306
|
+
}
|
|
307
|
+
return msg;
|
|
308
|
+
}
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
249
311
|
// Staleness guard: must have read file before editing
|
|
250
312
|
if (sessionId) {
|
|
251
|
-
|
|
313
|
+
const staleError = checkReadBefore(sessionId, validated);
|
|
314
|
+
if (staleError)
|
|
315
|
+
return staleError;
|
|
252
316
|
}
|
|
253
317
|
const content = await fs.readFile(validated, 'utf8');
|
|
254
318
|
// Use fuzzy match cascade
|
|
@@ -262,7 +326,67 @@ export async function editFile(filePath, oldString, newString, expectedReplaceme
|
|
|
262
326
|
return `Error: found ${count} occurrence(s) of old_string (via ${strategy.name} match), but expected ${expectedReplacements}. Be more specific or set expected_replacements=${count}.`;
|
|
263
327
|
}
|
|
264
328
|
}
|
|
265
|
-
|
|
329
|
+
// Build a per-strategy diagnostic to help the model self-correct without
|
|
330
|
+
// requiring a full re-read. For each strategy, find the closest partial
|
|
331
|
+
// match and report ALL lines where it diverges (not just the first).
|
|
332
|
+
const searchLines = oldString.split('\n');
|
|
333
|
+
const contentLines = content.split('\n');
|
|
334
|
+
const diagnostics = [];
|
|
335
|
+
for (const strategy of STRATEGIES) {
|
|
336
|
+
// Find the window in the file that shares the most lines with oldString
|
|
337
|
+
let bestWindowStart = -1;
|
|
338
|
+
let bestMatchedLines = 0;
|
|
339
|
+
for (let i = 0; i <= contentLines.length - searchLines.length; i++) {
|
|
340
|
+
let matched = 0;
|
|
341
|
+
for (let j = 0; j < searchLines.length; j++) {
|
|
342
|
+
const fileLine = strategy.name === 'whitespace-normalized'
|
|
343
|
+
? contentLines[i + j].replace(/\s+/g, ' ').trim()
|
|
344
|
+
: contentLines[i + j].trim();
|
|
345
|
+
const searchLine = strategy.name === 'whitespace-normalized'
|
|
346
|
+
? searchLines[j].replace(/\s+/g, ' ').trim()
|
|
347
|
+
: searchLines[j].trim();
|
|
348
|
+
if (fileLine === searchLine)
|
|
349
|
+
matched++;
|
|
350
|
+
}
|
|
351
|
+
if (matched > bestMatchedLines) {
|
|
352
|
+
bestMatchedLines = matched;
|
|
353
|
+
bestWindowStart = i;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (bestWindowStart >= 0 && bestMatchedLines > 0 && bestMatchedLines < searchLines.length) {
|
|
357
|
+
// Collect all diverging lines, not just the first
|
|
358
|
+
const diffLines = [];
|
|
359
|
+
const MAX_DIFFS = 6;
|
|
360
|
+
for (let j = 0; j < searchLines.length && diffLines.length < MAX_DIFFS; j++) {
|
|
361
|
+
const fileLine = contentLines[bestWindowStart + j] ?? '(end of file)';
|
|
362
|
+
const searchLine = searchLines[j];
|
|
363
|
+
const fileNorm = strategy.name === 'whitespace-normalized'
|
|
364
|
+
? fileLine.replace(/\s+/g, ' ').trim()
|
|
365
|
+
: fileLine.trim();
|
|
366
|
+
const searchNorm = strategy.name === 'whitespace-normalized'
|
|
367
|
+
? searchLine.replace(/\s+/g, ' ').trim()
|
|
368
|
+
: searchLine.trim();
|
|
369
|
+
if (fileNorm !== searchNorm) {
|
|
370
|
+
diffLines.push(` line ${bestWindowStart + j + 1}:\n` +
|
|
371
|
+
` yours: ${searchLine.trim().slice(0, 120)}\n` +
|
|
372
|
+
` file: ${fileLine.trim().slice(0, 120)}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const truncNote = diffLines.length === MAX_DIFFS ? `\n ... (more diffs not shown)` : '';
|
|
376
|
+
diagnostics.push(` ${strategy.name}: ${bestMatchedLines}/${searchLines.length} lines match, ${searchLines.length - bestMatchedLines} differ:\n` +
|
|
377
|
+
diffLines.join('\n') + truncNote);
|
|
378
|
+
}
|
|
379
|
+
else if (bestMatchedLines === 0) {
|
|
380
|
+
diagnostics.push(` ${strategy.name}: no lines matched — old_string may be from a different file or heavily rewritten`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
diagnostics.push(` ${strategy.name}: no partial match found`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const hint = diagnostics.length > 0
|
|
387
|
+
? '\nDiagnostics per strategy:\n' + diagnostics.join('\n')
|
|
388
|
+
: '';
|
|
389
|
+
return `Error: old_string not found in ${filePath}.${hint}\nDo NOT retry with a guess. Call read_file on ${filePath} first to get the exact current content, then construct old_string by copying verbatim from the file.`;
|
|
266
390
|
}
|
|
267
391
|
const { actual, strategy, count } = match;
|
|
268
392
|
// Request approval
|
package/dist/tools/read-file.js
CHANGED
|
@@ -108,46 +108,29 @@ export async function readFile(filePath, offset = 0, limit = 2000, sessionId) {
|
|
|
108
108
|
const start = Math.max(0, offset);
|
|
109
109
|
const maxLines = Math.max(0, limit);
|
|
110
110
|
const lines = [];
|
|
111
|
-
let totalLines = 0;
|
|
112
111
|
const stream = createReadStream(validated, { encoding: 'utf8' });
|
|
113
112
|
const lineReader = readline.createInterface({
|
|
114
113
|
input: stream,
|
|
115
114
|
crlfDelay: Infinity,
|
|
116
115
|
});
|
|
117
116
|
try {
|
|
117
|
+
let lineIndex = 0;
|
|
118
118
|
for await (const line of lineReader) {
|
|
119
|
-
if (
|
|
119
|
+
if (lineIndex >= start && lines.length < maxLines) {
|
|
120
120
|
lines.push(line);
|
|
121
121
|
}
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
const stats = await fs.stat(validated);
|
|
125
|
-
if (stats.size === 0) {
|
|
126
|
-
totalLines = 0;
|
|
127
|
-
}
|
|
128
|
-
else if (lines.length === 0 && totalLines === 0) {
|
|
129
|
-
totalLines = 1;
|
|
122
|
+
lineIndex++;
|
|
130
123
|
}
|
|
131
124
|
}
|
|
132
125
|
finally {
|
|
133
126
|
lineReader.close();
|
|
134
127
|
stream.destroy();
|
|
135
128
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const numbered = lines.map((line, i) => {
|
|
139
|
-
const lineNum = String(start + i + 1).padStart(5, ' ');
|
|
140
|
-
// Truncate very long lines
|
|
141
|
-
const truncated = line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line;
|
|
142
|
-
return `${lineNum} | ${truncated}`;
|
|
143
|
-
});
|
|
129
|
+
// Truncate very long individual lines but don't reformat content
|
|
130
|
+
const slice = lines.map(line => line.length > 2000 ? line.slice(0, 2000) + '... (truncated)' : line);
|
|
144
131
|
// Record successful read for staleness tracking
|
|
145
132
|
if (sessionId) {
|
|
146
133
|
recordRead(sessionId, validated);
|
|
147
134
|
}
|
|
148
|
-
|
|
149
|
-
? 'none'
|
|
150
|
-
: `${Math.min(start + 1, totalLines)}-${end}`;
|
|
151
|
-
const header = `File: ${filePath} (${totalLines} lines total, showing ${rangeLabel})`;
|
|
152
|
-
return `${header}\n${numbered.join('\n')}`;
|
|
135
|
+
return slice.join('\n');
|
|
153
136
|
}
|
package/dist/tools/write-file.js
CHANGED
|
@@ -5,6 +5,7 @@ import fs from 'node:fs/promises';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { validatePath } from '../utils/path-validation.js';
|
|
7
7
|
import { requestApproval } from '../utils/approval.js';
|
|
8
|
+
import { recordRead } from '../utils/file-time.js';
|
|
8
9
|
export const writeFileTool = {
|
|
9
10
|
type: 'function',
|
|
10
11
|
function: {
|
|
@@ -49,5 +50,10 @@ export async function writeFile(filePath, content, sessionId) {
|
|
|
49
50
|
await fs.rm(tmpPath, { force: true }).catch(() => undefined);
|
|
50
51
|
}
|
|
51
52
|
const lines = content.split('\n').length;
|
|
53
|
+
// Record the write as a read so a subsequent edit_file on this file doesn't
|
|
54
|
+
// immediately fail the staleness guard with "you must read first".
|
|
55
|
+
if (sessionId) {
|
|
56
|
+
recordRead(sessionId, validated);
|
|
57
|
+
}
|
|
52
58
|
return `Successfully wrote ${lines} lines to ${filePath}`;
|
|
53
59
|
}
|
package/dist/utils/file-time.js
CHANGED
|
@@ -13,34 +13,43 @@ export function recordRead(sessionId, absolutePath) {
|
|
|
13
13
|
readTimes.set(`${sessionId}:${absolutePath}`, Date.now());
|
|
14
14
|
}
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Check that a file was previously read and hasn't changed on disk since.
|
|
17
|
+
* Returns an error string if the check fails, or null if all is well.
|
|
18
|
+
* Use this instead of assertReadBefore so staleness errors surface as normal
|
|
19
|
+
* tool return values rather than exceptions that get swallowed into a generic
|
|
20
|
+
* "Error executing edit_file: ..." message.
|
|
18
21
|
*/
|
|
19
|
-
export function
|
|
22
|
+
export function checkReadBefore(sessionId, absolutePath) {
|
|
20
23
|
const key = `${sessionId}:${absolutePath}`;
|
|
21
24
|
const lastRead = readTimes.get(key);
|
|
22
25
|
if (!lastRead) {
|
|
23
|
-
|
|
26
|
+
return `You must read '${absolutePath}' before editing it. Call read_file first.`;
|
|
24
27
|
}
|
|
25
28
|
try {
|
|
26
29
|
const mtime = fs.statSync(absolutePath).mtimeMs;
|
|
27
30
|
if (mtime > lastRead + 100) {
|
|
28
|
-
// Clear stale entry so the error message stays accurate
|
|
31
|
+
// Clear stale entry so the error message stays accurate on retry
|
|
29
32
|
readTimes.delete(key);
|
|
30
|
-
|
|
33
|
+
return `'${absolutePath}' has changed on disk since you last read it. Re-read it before editing.`;
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
catch (err) {
|
|
34
37
|
if (err.code === 'ENOENT') {
|
|
35
38
|
readTimes.delete(key);
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
// Re-throw our own errors
|
|
39
|
-
if (err.message.includes('has changed on disk') || err.message.includes('must read')) {
|
|
40
|
-
throw err;
|
|
39
|
+
return `'${absolutePath}' no longer exists on disk.`;
|
|
41
40
|
}
|
|
42
41
|
// Ignore other stat errors — don't block edits on stat failures
|
|
43
42
|
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* @deprecated Use checkReadBefore instead — it returns a string rather than
|
|
47
|
+
* throwing, so the error surfaces cleanly as a tool result.
|
|
48
|
+
*/
|
|
49
|
+
export function assertReadBefore(sessionId, absolutePath) {
|
|
50
|
+
const err = checkReadBefore(sessionId, absolutePath);
|
|
51
|
+
if (err)
|
|
52
|
+
throw new Error(err);
|
|
44
53
|
}
|
|
45
54
|
/**
|
|
46
55
|
* Clear all read-time entries for a session (e.g. on session end).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protoagent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"commander": "^14.0.1",
|
|
29
29
|
"he": "^1.2.0",
|
|
30
30
|
"html-to-text": "^9.0.5",
|
|
31
|
-
"ink": "^6.
|
|
31
|
+
"ink": "^6.8.0",
|
|
32
32
|
"ink-big-text": "^2.0.0",
|
|
33
33
|
"jsonc-parser": "^3.3.1",
|
|
34
34
|
"openai": "^5.23.1",
|