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 CHANGED
@@ -1,11 +1,61 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
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
- */
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
- let line = `Model: ${provider?.name || config.provider} / ${config.model}`;
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 += `Debug logs: ${logFilePath}\n`;
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(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
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
- // This eliminates all write()/log-update timing issues.
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
- setStaticItems((prev) => [...prev, { id: makeStaticId(), text }]);
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
- } })), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && !isStreaming && (_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), 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] })] }))] }));
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
  };
@@ -46,21 +46,22 @@ async function sleepWithAbort(delayMs, abortSignal) {
46
46
  abortSignal.addEventListener('abort', onAbort, { once: true });
47
47
  });
48
48
  }
49
- function appendStreamingFragment(current, fragment) {
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
- const maxOverlap = Math.min(current.length, fragment.length);
59
- for (let overlap = maxOverlap; overlap > 0; overlap--) {
60
- if (current.endsWith(fragment.slice(0, overlap))) {
61
- return current + fragment.slice(overlap);
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) {
@@ -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
- - Use bash for package management, git, building, testing, etc.
125
- - When running interactive commands, add flags to avoid prompts (--yes, --template, etc.)
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.
@@ -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 { assertReadBefore, recordRead } from '../utils/file-time.js';
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
- const validated = await validatePath(filePath);
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
- assertReadBefore(sessionId, validated);
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
- return `Error: old_string not found in ${filePath}. Strategies exhausted (exact, line-trimmed, indent-flexible, whitespace-normalized, trimmed-boundary). Re-read the file and try again.`;
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
@@ -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 (totalLines >= start && lines.length < maxLines) {
119
+ if (lineIndex >= start && lines.length < maxLines) {
120
120
  lines.push(line);
121
121
  }
122
- totalLines++;
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
- const end = Math.min(totalLines, start + lines.length);
137
- // Add line numbers (1-based)
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
- const rangeLabel = lines.length === 0
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
  }
@@ -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
  }
@@ -13,34 +13,43 @@ export function recordRead(sessionId, absolutePath) {
13
13
  readTimes.set(`${sessionId}:${absolutePath}`, Date.now());
14
14
  }
15
15
  /**
16
- * Assert that a file was previously read and hasn't changed on disk since.
17
- * Throws if the file was never read or has been modified.
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 assertReadBefore(sessionId, absolutePath) {
22
+ export function checkReadBefore(sessionId, absolutePath) {
20
23
  const key = `${sessionId}:${absolutePath}`;
21
24
  const lastRead = readTimes.get(key);
22
25
  if (!lastRead) {
23
- throw new Error(`You must read '${absolutePath}' before editing it. Call read_file first.`);
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
- throw new Error(`'${absolutePath}' has changed on disk since you last read it. Re-read it before editing.`);
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
- throw new Error(`'${absolutePath}' no longer exists on disk.`);
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.7",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",