protoagent 0.1.7 → 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 +89 -16
- package/dist/agentic-loop.js +10 -9
- package/dist/system-prompt.js +6 -3
- 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 +1 -1
package/dist/App.js
CHANGED
|
@@ -1,11 +1,61 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
/**
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
*/
|
|
9
59
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
60
|
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
|
|
11
61
|
import { LeftBar } from './components/LeftBar.js';
|
|
@@ -25,14 +75,11 @@ import { generateSystemPrompt } from './system-prompt.js';
|
|
|
25
75
|
// These functions append text to the permanent scrollback buffer via the
|
|
26
76
|
// <Static> component. Ink flushes new Static items within its own render
|
|
27
77
|
// cycle, so there are no timing issues with write()/log-update.
|
|
28
|
-
let _staticCounter = 0;
|
|
29
|
-
function makeStaticId() {
|
|
30
|
-
return `s${++_staticCounter}`;
|
|
31
|
-
}
|
|
32
78
|
function printBanner(addStatic) {
|
|
33
79
|
const green = '\x1b[38;2;9;164;105m';
|
|
34
80
|
const reset = '\x1b[0m';
|
|
35
81
|
addStatic([
|
|
82
|
+
'',
|
|
36
83
|
`${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
|
|
37
84
|
`${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
|
|
38
85
|
'',
|
|
@@ -40,14 +87,17 @@ function printBanner(addStatic) {
|
|
|
40
87
|
}
|
|
41
88
|
function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
|
|
42
89
|
const provider = getProvider(config.provider);
|
|
43
|
-
|
|
90
|
+
const grey = '\x1b[90m';
|
|
91
|
+
const reset = '\x1b[0m';
|
|
92
|
+
let line = `${grey}Model: ${provider?.name || config.provider} / ${config.model}`;
|
|
44
93
|
if (dangerouslyAcceptAll)
|
|
45
94
|
line += ' (auto-approve all)';
|
|
46
95
|
if (session)
|
|
47
96
|
line += ` | Session: ${session.id.slice(0, 8)}`;
|
|
97
|
+
line += reset;
|
|
48
98
|
let text = `${line}\n`;
|
|
49
99
|
if (logFilePath) {
|
|
50
|
-
text +=
|
|
100
|
+
text += `${grey}Debug logs: ${logFilePath}${reset}\n`;
|
|
51
101
|
}
|
|
52
102
|
text += '\n';
|
|
53
103
|
addStatic(text);
|
|
@@ -111,6 +161,7 @@ const SLASH_COMMANDS = [
|
|
|
111
161
|
{ name: '/clear', description: 'Clear conversation and start fresh' },
|
|
112
162
|
{ name: '/help', description: 'Show all available commands' },
|
|
113
163
|
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
164
|
+
{ name: '/exit', description: 'Alias for /quit' },
|
|
114
165
|
];
|
|
115
166
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
116
167
|
const HELP_TEXT = [
|
|
@@ -118,6 +169,7 @@ const HELP_TEXT = [
|
|
|
118
169
|
' /clear - Clear conversation and start fresh',
|
|
119
170
|
' /help - Show this help',
|
|
120
171
|
' /quit - Exit ProtoAgent',
|
|
172
|
+
' /exit - Alias for /quit',
|
|
121
173
|
].join('\n');
|
|
122
174
|
function buildClient(config) {
|
|
123
175
|
const provider = getProvider(config.provider);
|
|
@@ -186,7 +238,7 @@ const ApprovalPrompt = ({ request, onRespond }) => {
|
|
|
186
238
|
const UsageDisplay = ({ usage, totalCost }) => {
|
|
187
239
|
if (!usage && totalCost === 0)
|
|
188
240
|
return null;
|
|
189
|
-
return (_jsxs(Box, { 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)] })] })] }));
|
|
190
242
|
};
|
|
191
243
|
/** Inline setup wizard — shown when no config exists. */
|
|
192
244
|
const InlineSetup = ({ onComplete }) => {
|
|
@@ -230,10 +282,18 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
230
282
|
// ─── Static scrollback state ───
|
|
231
283
|
// Each item appended here is rendered once by <Static> and permanently
|
|
232
284
|
// flushed to the terminal scrollback by Ink, within its own render cycle.
|
|
233
|
-
//
|
|
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);
|
|
234
292
|
const [staticItems, setStaticItems] = useState([]);
|
|
235
293
|
const addStatic = useCallback((text) => {
|
|
236
|
-
|
|
294
|
+
staticCounterRef.current += 1;
|
|
295
|
+
const id = `s${staticCounterRef.current}`;
|
|
296
|
+
setStaticItems((prev) => [...prev, { id, text }]);
|
|
237
297
|
}, []);
|
|
238
298
|
// Core state
|
|
239
299
|
const [config, setConfig] = useState(null);
|
|
@@ -523,6 +583,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
523
583
|
if (event.toolCall) {
|
|
524
584
|
const toolCall = event.toolCall;
|
|
525
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
|
+
}
|
|
526
599
|
// Track the tool call in the ref WITHOUT triggering a render.
|
|
527
600
|
// The render will happen when tool_result arrives.
|
|
528
601
|
const existingRef = assistantMessageRef.current;
|
|
@@ -704,5 +777,5 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
704
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) => {
|
|
705
778
|
pendingApproval.resolve(response);
|
|
706
779
|
setPendingApproval(null);
|
|
707
|
-
} })),
|
|
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] })] }))] }));
|
|
708
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) {
|
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
|
|
|
@@ -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).
|