zames_pro 2.12.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/dist/agent-loop.js +43 -43
- package/dist/extraTools.js +352 -0
- package/dist/gitTools.js +13 -13
- package/dist/i18n.js +10 -0
- package/dist/index.js +55 -1
- package/dist/mcp.js +327 -0
- package/dist/system-prompt.js +10 -1
- package/dist/tools.js +100 -46
- package/dist/web.js +15 -15
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -76,6 +76,23 @@ zames --version
|
|
|
76
76
|
zames --help
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
## Tools
|
|
80
|
+
|
|
81
|
+
The agent has the same style of tools as Claude Code / Codex CLI:
|
|
82
|
+
|
|
83
|
+
- **File tools** — `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`. `Read`
|
|
84
|
+
returns raw content by default; pass `numbered=true` to get `cat -n`-style
|
|
85
|
+
line numbers (for reference only — do not paste them into `Edit`).
|
|
86
|
+
- **Extra tools** — `LS` (list a directory), `MultiEdit` (several edits to one
|
|
87
|
+
file applied atomically), `TodoWrite` (session task checklist), `ApplyPatch`
|
|
88
|
+
(multi-file patch in Codex's V4A format: `*** Begin Patch` … `*** End Patch`).
|
|
89
|
+
- **Git** — `GitStatus`, `GitDiff`, `GitLog`, `GitAdd`, `GitCommit`, `GitPush`.
|
|
90
|
+
- **Web** — `WebFetch`, `WebSearch`.
|
|
91
|
+
- **Service** — `respond` (final answer to the operator, ends the task).
|
|
92
|
+
|
|
93
|
+
All file tools stay inside the working directory (sandbox). `Write`/`Edit` and
|
|
94
|
+
`MultiEdit`/`ApplyPatch` make a backup (undo) before touching a file.
|
|
95
|
+
|
|
79
96
|
## Project context, skills and memory
|
|
80
97
|
|
|
81
98
|
Like Codex / Claude Code, zames reads project instructions and reusable
|
|
@@ -106,6 +123,42 @@ Skills and custom commands show up in the «/» completion list and in `/help`.
|
|
|
106
123
|
/init [--force] analyze the project and create AGENTS.md
|
|
107
124
|
```
|
|
108
125
|
|
|
126
|
+
## MCP (external tools)
|
|
127
|
+
|
|
128
|
+
zames can use tools from [MCP](https://modelcontextprotocol.io) servers.
|
|
129
|
+
The flagship example is @playwright/mcp: it gives the agent a real browser
|
|
130
|
+
(navigate, click, snapshot, type, ...) on top of the one zames already uses
|
|
131
|
+
for the DeepSeek chat.
|
|
132
|
+
|
|
133
|
+
Drop a config file (same shape as Claude Code / Cursor):
|
|
134
|
+
|
|
135
|
+
- `~/.zames/mcp.json` - global
|
|
136
|
+
- `<project>/.zames/mcp.json` - project-scoped (later files win)
|
|
137
|
+
- `<project>/.mcp.json` - the common MCP name
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"mcpServers": {
|
|
142
|
+
"playwright": {
|
|
143
|
+
"command": "npx",
|
|
144
|
+
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
IMPORTANT: keep the MCP browser isolated. @playwright/mcp defaults to the
|
|
151
|
+
SAME profile directory as zames (~/.zames/profile). If it is launched
|
|
152
|
+
without --isolated (or without its own --user-data-dir), the MCP browser
|
|
153
|
+
and the agent browser fight over one profile and the DeepSeek chat shows
|
|
154
|
+
"Something went wrong when opening your profile". Always pass --isolated
|
|
155
|
+
as in the example above.
|
|
156
|
+
Servers can also be remote ("url": "https://...", "transport": "sse").
|
|
157
|
+
Their tools show up in the agent as `server__tool` (e.g.
|
|
158
|
+
`playwright__browser_navigate`) and are listed with `/mcp` and in `/status`.
|
|
159
|
+
A server that fails to connect is skipped with a warning and never breaks the
|
|
160
|
+
agent.
|
|
161
|
+
|
|
109
162
|
## Configuration
|
|
110
163
|
|
|
111
164
|
Global config: `~/.zames/config.json`
|
package/dist/agent-loop.js
CHANGED
|
@@ -194,20 +194,20 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
194
194
|
attempt: afterToolRetries,
|
|
195
195
|
error: e.message,
|
|
196
196
|
});
|
|
197
|
-
safeWarning('browser.ask()
|
|
197
|
+
safeWarning('browser.ask() did not return an answer within ' +
|
|
198
198
|
Math.round(askDeadlineMs / 1000) +
|
|
199
|
-
'
|
|
199
|
+
's — retrying the request.');
|
|
200
200
|
if (afterToolRetries < MAX_AFTER_TOOL_RETRIES) {
|
|
201
201
|
afterToolRetries++;
|
|
202
202
|
await new Promise((r) => setTimeout(r, 1500));
|
|
203
203
|
continue;
|
|
204
204
|
}
|
|
205
205
|
transcript?.log('ask_timeout_exhausted', {
|
|
206
|
-
message: 'ask()
|
|
206
|
+
message: 'ask() did not return an answer and the retry limit is exhausted',
|
|
207
207
|
});
|
|
208
|
-
safeWarning('browser.ask()
|
|
209
|
-
'
|
|
210
|
-
return 'ask() watchdog:
|
|
208
|
+
safeWarning('browser.ask() stopped responding; retry limit exhausted, ' +
|
|
209
|
+
'stopping. No model answer — check the DeepSeek chat manually.');
|
|
210
|
+
return 'ask() watchdog: no model answer received';
|
|
211
211
|
}
|
|
212
212
|
await reportChat();
|
|
213
213
|
transcript?.log('assistant_raw', { response: rawResponse });
|
|
@@ -262,10 +262,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
262
262
|
response: String(rawResponse || '').slice(0, 200),
|
|
263
263
|
});
|
|
264
264
|
message =
|
|
265
|
-
'
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
265
|
+
'You stopped after a tool result. Continue the work: ' +
|
|
266
|
+
'reply with EXACTLY one JSON tool-call object, no text before or after, ' +
|
|
267
|
+
'for example: {"tool": "Bash", "args": {"command": "..."}}. ' +
|
|
268
|
+
'If the task is really done — call respond with the final message.';
|
|
269
269
|
await new Promise((r) => setTimeout(r, 1500));
|
|
270
270
|
continue;
|
|
271
271
|
}
|
|
@@ -287,7 +287,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
287
287
|
if (parsedCalls.some((p) => p && p._permissive)) {
|
|
288
288
|
transcript?.log('permissive_parse', { response: rawResponse });
|
|
289
289
|
if (debugLog) {
|
|
290
|
-
console.error('
|
|
290
|
+
console.error('warning: tool-call recognized by the permissive parser');
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
if (!parsed) {
|
|
@@ -304,17 +304,17 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
304
304
|
response: rawResponse,
|
|
305
305
|
});
|
|
306
306
|
if (debugLog) {
|
|
307
|
-
console.error('
|
|
307
|
+
console.error('warning: the answer looks like a tool-call but was not recognized (attempt ' +
|
|
308
308
|
malformedRetries +
|
|
309
309
|
'/' +
|
|
310
310
|
MAX_MALFORMED_RETRIES +
|
|
311
311
|
')');
|
|
312
312
|
}
|
|
313
313
|
message =
|
|
314
|
-
'
|
|
315
|
-
'
|
|
316
|
-
'
|
|
317
|
-
'
|
|
314
|
+
'Your previous answer was not recognized as a tool call. ' +
|
|
315
|
+
'Reply with EXACTLY one JSON tool-call object, no text before or after. ' +
|
|
316
|
+
'Do NOT use XML/DSML tags — plain JSON only. ' +
|
|
317
|
+
'For example: {"tool": "Read", "args": {"path": "src/index.js"}}';
|
|
318
318
|
continue;
|
|
319
319
|
}
|
|
320
320
|
const trimmed = (rawResponse || '').trim();
|
|
@@ -336,16 +336,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
336
336
|
response: rawResponse,
|
|
337
337
|
});
|
|
338
338
|
if (debugLog) {
|
|
339
|
-
console.error('
|
|
339
|
+
console.error('warning: empty/service answer, asking to continue (attempt ' +
|
|
340
340
|
stallRetries +
|
|
341
341
|
'/' +
|
|
342
342
|
MAX_STALL_RETRIES +
|
|
343
343
|
')');
|
|
344
344
|
}
|
|
345
345
|
message =
|
|
346
|
-
'
|
|
347
|
-
'
|
|
348
|
-
'
|
|
346
|
+
'Continue the task. If you need a tool — reply with EXACTLY ' +
|
|
347
|
+
'one JSON tool-call object: {"tool": "...", "args": {...}}. ' +
|
|
348
|
+
'If the task is done — call the respond tool with the final message.';
|
|
349
349
|
continue;
|
|
350
350
|
}
|
|
351
351
|
// The answer looks like "I'll call a tool now", but contains no call.
|
|
@@ -361,18 +361,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
361
361
|
response: rawResponse,
|
|
362
362
|
});
|
|
363
363
|
if (debugLog) {
|
|
364
|
-
console.error('
|
|
364
|
+
console.error('warning: the answer looks like unfinished work, asking to continue (attempt ' +
|
|
365
365
|
looksDoneRetries +
|
|
366
366
|
'/' +
|
|
367
367
|
MAX_LOOKSDONE_RETRIES +
|
|
368
368
|
')');
|
|
369
369
|
}
|
|
370
370
|
message =
|
|
371
|
-
'
|
|
372
|
-
'
|
|
373
|
-
'
|
|
374
|
-
'
|
|
375
|
-
'
|
|
371
|
+
'It looks like you meant to call a tool but did not. ' +
|
|
372
|
+
'If the task is not finished — reply with EXACTLY one JSON ' +
|
|
373
|
+
'tool-call object, no text before or after. ' +
|
|
374
|
+
'If the task is really done — call respond with the final ' +
|
|
375
|
+
'message to the operator.';
|
|
376
376
|
continue;
|
|
377
377
|
}
|
|
378
378
|
// STRICT: only tool calls and respond reach the operator. Plain text is
|
|
@@ -387,12 +387,12 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
387
387
|
});
|
|
388
388
|
message =
|
|
389
389
|
(justRanTool
|
|
390
|
-
? '
|
|
391
|
-
: '
|
|
392
|
-
'
|
|
393
|
-
'
|
|
390
|
+
? 'You stopped after a tool call and wrote plain text. '
|
|
391
|
+
: 'You wrote plain text without a tool call. ') +
|
|
392
|
+
'The task is not finished. Reply with EXACTLY one JSON tool-call ' +
|
|
393
|
+
'object, no text before or after, for example: ' +
|
|
394
394
|
'{\"tool\": \"Bash\", \"args\": {\"command\": \"...\"}}. ' +
|
|
395
|
-
'
|
|
395
|
+
'If the task is really done — call respond with the final message.';
|
|
396
396
|
continue;
|
|
397
397
|
}
|
|
398
398
|
// Budget exhausted. Ask for respond EXACTLY once more; if the model
|
|
@@ -404,8 +404,8 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
404
404
|
response: rawResponse.slice(0, 500),
|
|
405
405
|
});
|
|
406
406
|
message =
|
|
407
|
-
'
|
|
408
|
-
'
|
|
407
|
+
'Last step: call the respond tool with the final message ' +
|
|
408
|
+
'to the operator. Do not write plain text — only a respond call, for example: ' +
|
|
409
409
|
'{\"tool\": \"respond\", \"args\": {\"message\": \"...\"}}';
|
|
410
410
|
continue;
|
|
411
411
|
}
|
|
@@ -450,16 +450,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
450
450
|
stallRetries++;
|
|
451
451
|
transcript?.log('empty_respond', { attempt: stallRetries });
|
|
452
452
|
if (debugLog) {
|
|
453
|
-
console.error('
|
|
453
|
+
console.error('warning: empty respond, asking to continue (attempt ' +
|
|
454
454
|
stallRetries +
|
|
455
455
|
'/' +
|
|
456
456
|
MAX_STALL_RETRIES +
|
|
457
457
|
')');
|
|
458
458
|
}
|
|
459
459
|
message =
|
|
460
|
-
'
|
|
461
|
-
'
|
|
462
|
-
'
|
|
460
|
+
'You called respond with an empty message. If the task is done — ' +
|
|
461
|
+
'call respond with the final message to the operator. If not — ' +
|
|
462
|
+
'continue the work with a tool call.';
|
|
463
463
|
continue;
|
|
464
464
|
}
|
|
465
465
|
// An EMPTY respond must NEVER be a silent final: the operator would see
|
|
@@ -469,9 +469,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
469
469
|
// the task normally.
|
|
470
470
|
if (!isMeaningfulRespond(msg)) {
|
|
471
471
|
transcript?.log('empty_respond_exhausted', { response: rawResponse });
|
|
472
|
-
safeWarning('
|
|
473
|
-
'
|
|
474
|
-
return '
|
|
472
|
+
safeWarning('The model called respond without text, and the retry limit is exhausted. ' +
|
|
473
|
+
'Check the DeepSeek chat manually.');
|
|
474
|
+
return 'The model produced no final message (empty respond).';
|
|
475
475
|
}
|
|
476
476
|
safeAssistantMessage(msg);
|
|
477
477
|
transcript?.log('assistant_final', { message: msg });
|
|
@@ -485,7 +485,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
485
485
|
for (const call of callsToRun) {
|
|
486
486
|
const tool = tools.find((t) => t.name === call.tool);
|
|
487
487
|
if (!tool) {
|
|
488
|
-
const err =
|
|
488
|
+
const err = `Unknown tool: ${call.tool}`;
|
|
489
489
|
safeToolResult(err);
|
|
490
490
|
transcript?.log('tool_error', { tool: call.tool, error: err });
|
|
491
491
|
results.push({ tool: call.tool, result: err });
|
|
@@ -498,7 +498,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
498
498
|
result = await tool.fn(call.args);
|
|
499
499
|
}
|
|
500
500
|
catch (e) {
|
|
501
|
-
result =
|
|
501
|
+
result = `Error: ${e.message}`;
|
|
502
502
|
}
|
|
503
503
|
safeToolResult(result);
|
|
504
504
|
transcript?.log('tool_result', {
|
|
@@ -536,7 +536,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
536
536
|
.join('\n\n');
|
|
537
537
|
}
|
|
538
538
|
}
|
|
539
|
-
return '
|
|
539
|
+
return 'Iteration limit reached.';
|
|
540
540
|
}
|
|
541
541
|
// The answer looks like a tool call, but parseToolCall() did not recognize it.
|
|
542
542
|
// Used as a safeguard against "the agent called a tool and stopped": in that
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
// Extra tools that bring zames closer to Claude Code / Codex CLI:
|
|
4
|
+
// * LS - list a directory (Claude Code has LS; Codex has list_dir)
|
|
5
|
+
// * MultiEdit - several edits to ONE file applied atomically (Claude Code)
|
|
6
|
+
// * ApplyPatch - multi-file V4A-style patch (Codex apply_patch)
|
|
7
|
+
// * TodoWrite - session task checklist (Claude Code TodoWrite)
|
|
8
|
+
//
|
|
9
|
+
// The core file tools (Read/Write/Edit/Bash/Glob/Grep) live in tools.ts.
|
|
10
|
+
// These are additive: they do not change the existing tool behaviour.
|
|
11
|
+
const NL = String.fromCharCode(10);
|
|
12
|
+
function decodeContent(content, contentBase64) {
|
|
13
|
+
if (typeof contentBase64 === 'string' && contentBase64.length) {
|
|
14
|
+
return Buffer.from(contentBase64, 'base64').toString('utf-8');
|
|
15
|
+
}
|
|
16
|
+
return String(content ?? '');
|
|
17
|
+
}
|
|
18
|
+
// The todo list is per-process session state. It is deliberately NOT
|
|
19
|
+
// persisted: a fresh run starts with an empty list, like Claude Code's
|
|
20
|
+
// session checklist.
|
|
21
|
+
const todoList = [];
|
|
22
|
+
export function getTodos() {
|
|
23
|
+
return todoList.map((t) => ({ ...t }));
|
|
24
|
+
}
|
|
25
|
+
export function resetTodos() {
|
|
26
|
+
todoList.length = 0;
|
|
27
|
+
}
|
|
28
|
+
export function renderTodos(items) {
|
|
29
|
+
if (!items.length)
|
|
30
|
+
return 'Todo list is empty.';
|
|
31
|
+
const mark = (s) => s === 'completed' ? '[x]' : s === 'in_progress' ? '[~]' : '[ ]';
|
|
32
|
+
const lines = items.map((t, i) => `${i + 1}. ${mark(t.status)} ${t.content}`);
|
|
33
|
+
const done = items.filter((t) => t.status === 'completed').length;
|
|
34
|
+
return `Todo list (${done}/${items.length} done):${NL}${lines.join(NL)}`;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeTodos(list) {
|
|
37
|
+
const items = [];
|
|
38
|
+
for (const raw of list) {
|
|
39
|
+
const it = (raw || {});
|
|
40
|
+
const content = String(it.content ?? it.text ?? '').trim();
|
|
41
|
+
if (!content)
|
|
42
|
+
continue;
|
|
43
|
+
const s = String(it.status ?? 'pending');
|
|
44
|
+
const status = s === 'completed' || s === 'done'
|
|
45
|
+
? 'completed'
|
|
46
|
+
: s === 'in_progress' || s === 'active'
|
|
47
|
+
? 'in_progress'
|
|
48
|
+
: 'pending';
|
|
49
|
+
items.push({ content, status });
|
|
50
|
+
}
|
|
51
|
+
return items;
|
|
52
|
+
}
|
|
53
|
+
/** Parse a V4A-style patch body into operations. Exported for tests. */
|
|
54
|
+
export function parsePatch(text) {
|
|
55
|
+
const raw = String(text ?? '').replace(/\r\n/g, NL);
|
|
56
|
+
if (!/^\s*\*\*\*\s*Begin Patch/m.test(raw)) {
|
|
57
|
+
return { ops: [], error: 'Patch must start with "*** Begin Patch".' };
|
|
58
|
+
}
|
|
59
|
+
if (!/\*\*\*\s*End Patch/.test(raw)) {
|
|
60
|
+
return { ops: [], error: 'Patch must end with "*** End Patch".' };
|
|
61
|
+
}
|
|
62
|
+
const beginIdx = raw.indexOf('*** Begin Patch');
|
|
63
|
+
const endIdx = raw.lastIndexOf('*** End Patch');
|
|
64
|
+
const beginLineEnd = raw.indexOf(NL, beginIdx);
|
|
65
|
+
const body = raw.slice(beginLineEnd === -1 ? beginIdx : beginLineEnd + 1, endIdx);
|
|
66
|
+
const ops = [];
|
|
67
|
+
let current = null;
|
|
68
|
+
for (const line of body.split(NL)) {
|
|
69
|
+
const header = line.match(/^\*\*\*\s*(Add|Update|Delete) File:\s*(.+?)\s*$/);
|
|
70
|
+
if (header) {
|
|
71
|
+
const kind = header[1].toLowerCase();
|
|
72
|
+
const file = header[2].trim();
|
|
73
|
+
if (!file)
|
|
74
|
+
return { ops: [], error: 'Patch header has an empty path.' };
|
|
75
|
+
if (path.isAbsolute(file) || /(^|[\\/])\.\.([\\/]|$)/.test(file)) {
|
|
76
|
+
return { ops: [], error: 'Patch path must stay inside the project: ' + file };
|
|
77
|
+
}
|
|
78
|
+
current = { op: kind, file, lines: [] };
|
|
79
|
+
ops.push(current);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (current)
|
|
83
|
+
current.lines.push(line);
|
|
84
|
+
}
|
|
85
|
+
if (!ops.length)
|
|
86
|
+
return { ops: [], error: 'Patch contains no file operations.' };
|
|
87
|
+
return { ops };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Apply an update hunk to existing content using context matching.
|
|
91
|
+
* The hunk is a list of lines prefixed with ' ' (context), '-' (remove),
|
|
92
|
+
* '+' (add). We locate the context block by exact match, then by ignoring
|
|
93
|
+
* trailing whitespace, then by ignoring all whitespace (like Codex).
|
|
94
|
+
*/
|
|
95
|
+
export function applyUpdateHunk(content, hunkLines) {
|
|
96
|
+
const hasCRLF = content.includes('\r\n');
|
|
97
|
+
const fileLines = content.split(/\r?\n/);
|
|
98
|
+
const oldLines = [];
|
|
99
|
+
const newLines = [];
|
|
100
|
+
for (const l of hunkLines) {
|
|
101
|
+
if (l.startsWith('@@'))
|
|
102
|
+
continue;
|
|
103
|
+
if (l.startsWith('+')) {
|
|
104
|
+
newLines.push(l.slice(1));
|
|
105
|
+
}
|
|
106
|
+
else if (l.startsWith('-')) {
|
|
107
|
+
oldLines.push(l.slice(1));
|
|
108
|
+
}
|
|
109
|
+
else if (l.startsWith(' ')) {
|
|
110
|
+
oldLines.push(l.slice(1));
|
|
111
|
+
newLines.push(l.slice(1));
|
|
112
|
+
}
|
|
113
|
+
else if (l === '') {
|
|
114
|
+
oldLines.push('');
|
|
115
|
+
newLines.push('');
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
oldLines.push(l);
|
|
119
|
+
newLines.push(l);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!oldLines.length) {
|
|
123
|
+
return { ok: false, error: 'Hunk has no context or removed lines to anchor on.' };
|
|
124
|
+
}
|
|
125
|
+
const norm = (s, mode) => {
|
|
126
|
+
if (mode === 'exact')
|
|
127
|
+
return s;
|
|
128
|
+
if (mode === 'trim')
|
|
129
|
+
return s.replace(/[ \t]+$/, '');
|
|
130
|
+
return s.replace(/\s+/g, '');
|
|
131
|
+
};
|
|
132
|
+
for (const mode of ['exact', 'trim', 'ws']) {
|
|
133
|
+
const anchor = oldLines.map((l) => norm(l, mode));
|
|
134
|
+
for (let i = 0; i + anchor.length <= fileLines.length; i++) {
|
|
135
|
+
let match = true;
|
|
136
|
+
for (let j = 0; j < anchor.length; j++) {
|
|
137
|
+
if (norm(fileLines[i + j], mode) !== anchor[j]) {
|
|
138
|
+
match = false;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!match)
|
|
143
|
+
continue;
|
|
144
|
+
const before = fileLines.slice(0, i);
|
|
145
|
+
const after = fileLines.slice(i + anchor.length);
|
|
146
|
+
const joined = [...before, ...newLines, ...after].join(hasCRLF ? '\r\n' : NL);
|
|
147
|
+
return { ok: true, content: joined };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: 'Context not found in the file (hunk did not match): ' +
|
|
153
|
+
oldLines[0].slice(0, 60),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// ---------- factory ----------
|
|
157
|
+
export function createExtraTools(workdir, { undo } = {}) {
|
|
158
|
+
const root = path.resolve(workdir);
|
|
159
|
+
const safe = (p) => {
|
|
160
|
+
const resolved = path.resolve(root, p);
|
|
161
|
+
const rel = path.relative(root, resolved);
|
|
162
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
163
|
+
throw new Error('Access outside the working directory is forbidden: ' + p);
|
|
164
|
+
}
|
|
165
|
+
return resolved;
|
|
166
|
+
};
|
|
167
|
+
// Guard against missing/placeholder required args: a missing `path` becomes
|
|
168
|
+
// String(undefined) === "undefined" and would create a file literally named
|
|
169
|
+
// "undefined" in the working directory.
|
|
170
|
+
const req = (v, name) => {
|
|
171
|
+
if (v === undefined || v === null) {
|
|
172
|
+
throw new Error(`Missing required argument: ${name}`);
|
|
173
|
+
}
|
|
174
|
+
const s = String(v);
|
|
175
|
+
if (s === '' || s === 'undefined' || s === 'null') {
|
|
176
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}`);
|
|
177
|
+
}
|
|
178
|
+
return s;
|
|
179
|
+
};
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
name: 'LS',
|
|
183
|
+
description: 'List the contents of a directory (files and subfolders). ' +
|
|
184
|
+
'path defaults to the working directory. Folders are marked with a slash.',
|
|
185
|
+
parameters: { path: 'string?', ignore: 'string?' },
|
|
186
|
+
fn: async ({ path: p, ignore }) => {
|
|
187
|
+
const target = p ? safe(req(p, 'path')) : root;
|
|
188
|
+
const stat = await fs.stat(target).catch(() => null);
|
|
189
|
+
if (!stat)
|
|
190
|
+
throw new Error('No such directory: ' + p);
|
|
191
|
+
if (!stat.isDirectory())
|
|
192
|
+
throw new Error('Not a directory: ' + p);
|
|
193
|
+
const ignoreList = (ignore ? String(ignore) : '')
|
|
194
|
+
.split(',')
|
|
195
|
+
.map((s) => s.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
const entries = await fs.readdir(target, { withFileTypes: true });
|
|
198
|
+
const rows = [];
|
|
199
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
200
|
+
if (ignoreList.includes(e.name))
|
|
201
|
+
continue;
|
|
202
|
+
if (e.name === 'node_modules' || e.name === '.git') {
|
|
203
|
+
rows.push(e.name + '/ (skipped)');
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (e.isDirectory()) {
|
|
207
|
+
rows.push(e.name + '/');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const full = path.join(target, e.name);
|
|
211
|
+
const st = await fs.stat(full).catch(() => null);
|
|
212
|
+
rows.push(e.name + (st ? ` (${st.size} bytes)` : ''));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return rows.length ? rows.join(NL) : '(empty)';
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: 'MultiEdit',
|
|
220
|
+
description: 'Several targeted edits to ONE file in a single call. edits is an array of ' +
|
|
221
|
+
'{old_string, new_string, replace_all?}. Edits are applied ' +
|
|
222
|
+
'in order; if any is not found — none are applied. ' +
|
|
223
|
+
'For a single edit use Edit.',
|
|
224
|
+
parameters: {
|
|
225
|
+
path: 'string',
|
|
226
|
+
edits: 'array',
|
|
227
|
+
edits_base64: 'string?',
|
|
228
|
+
},
|
|
229
|
+
fn: async ({ path: p, edits, edits_base64 }) => {
|
|
230
|
+
const file = safe(req(p, 'path'));
|
|
231
|
+
let list;
|
|
232
|
+
if (typeof edits_base64 === 'string' && edits_base64.length) {
|
|
233
|
+
list = JSON.parse(Buffer.from(edits_base64, 'base64').toString('utf-8'));
|
|
234
|
+
}
|
|
235
|
+
else if (Array.isArray(edits)) {
|
|
236
|
+
list = edits;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
throw new Error('edits must be an array of edits.');
|
|
240
|
+
}
|
|
241
|
+
if (!list.length)
|
|
242
|
+
throw new Error('edits is empty — nothing to apply.');
|
|
243
|
+
let content = await fs.readFile(file, 'utf-8');
|
|
244
|
+
for (let i = 0; i < list.length; i++) {
|
|
245
|
+
const e = list[i];
|
|
246
|
+
const oldStr = String(e.old_string ?? '');
|
|
247
|
+
const newStr = String(e.new_string ?? '');
|
|
248
|
+
if (oldStr === '') {
|
|
249
|
+
throw new Error(`edits[${i}]: old_string is empty.`);
|
|
250
|
+
}
|
|
251
|
+
const occurrences = content.split(oldStr).length - 1;
|
|
252
|
+
if (occurrences === 0) {
|
|
253
|
+
throw new Error(`edits[${i}]: string not found: "${oldStr.slice(0, 60)}..."`);
|
|
254
|
+
}
|
|
255
|
+
if (occurrences > 1 && !e.replace_all) {
|
|
256
|
+
throw new Error(`edits[${i}]: string occurs ${occurrences} times. ` +
|
|
257
|
+
'Make old_string more specific or pass replace_all=true.');
|
|
258
|
+
}
|
|
259
|
+
content = e.replace_all
|
|
260
|
+
? content.split(oldStr).join(newStr)
|
|
261
|
+
: content.replace(oldStr, newStr);
|
|
262
|
+
}
|
|
263
|
+
if (undo)
|
|
264
|
+
await undo.backup(file);
|
|
265
|
+
await fs.writeFile(file, content, 'utf-8');
|
|
266
|
+
return `Edited (${list.length} edits): ${p}`;
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'TodoWrite',
|
|
271
|
+
description: 'Create/update the session task list. Accepts todos — an array of ' +
|
|
272
|
+
'{content, status}, where status: pending | in_progress | completed. ' +
|
|
273
|
+
'Helps track multi-step tasks. Replaces the whole list.',
|
|
274
|
+
parameters: { todos: 'array', todos_base64: 'string?' },
|
|
275
|
+
fn: async ({ todos, todos_base64 }) => {
|
|
276
|
+
let list;
|
|
277
|
+
if (typeof todos_base64 === 'string' && todos_base64.length) {
|
|
278
|
+
list = JSON.parse(Buffer.from(todos_base64, 'base64').toString('utf-8'));
|
|
279
|
+
}
|
|
280
|
+
else if (Array.isArray(todos)) {
|
|
281
|
+
list = todos;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
throw new Error('todos must be an array.');
|
|
285
|
+
}
|
|
286
|
+
const items = normalizeTodos(list);
|
|
287
|
+
todoList.length = 0;
|
|
288
|
+
todoList.push(...items);
|
|
289
|
+
return renderTodos(todoList);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'ApplyPatch',
|
|
294
|
+
description: 'Apply a patch to several files in a single call (Codex format). ' +
|
|
295
|
+
'Body: "*** Begin Patch", operations "*** Add File: <path>" (lines with +), ' +
|
|
296
|
+
'"*** Update File: <path>" (context with a space, removals with -, additions ' +
|
|
297
|
+
'with +, @@ anchors optional), "*** Delete File: <path>", then ' +
|
|
298
|
+
'"*** End Patch". Paths must be relative and inside the project.',
|
|
299
|
+
parameters: { patch: 'string?', patch_base64: 'string?' },
|
|
300
|
+
fn: async ({ patch, patch_base64 }) => {
|
|
301
|
+
const text = decodeContent(patch, patch_base64);
|
|
302
|
+
const { ops, error } = parsePatch(text);
|
|
303
|
+
if (error)
|
|
304
|
+
throw new Error(error);
|
|
305
|
+
// Stage every change first, then write.
|
|
306
|
+
const writes = [];
|
|
307
|
+
for (const op of ops) {
|
|
308
|
+
const file = safe(op.file);
|
|
309
|
+
if (op.op === 'delete') {
|
|
310
|
+
writes.push({ file, content: null });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (op.op === 'add') {
|
|
314
|
+
const body = op.lines
|
|
315
|
+
.filter((l) => l.startsWith('+'))
|
|
316
|
+
.map((l) => l.slice(1))
|
|
317
|
+
.join(NL);
|
|
318
|
+
writes.push({ file, content: body + NL });
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
let current;
|
|
322
|
+
try {
|
|
323
|
+
current = await fs.readFile(file, 'utf-8');
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
throw new Error('Update File: no such file ' + op.file);
|
|
327
|
+
}
|
|
328
|
+
const res = applyUpdateHunk(current, op.lines);
|
|
329
|
+
if (!res.ok) {
|
|
330
|
+
throw new Error('Update File ' + op.file + ': ' + res.error);
|
|
331
|
+
}
|
|
332
|
+
writes.push({ file, content: res.content });
|
|
333
|
+
}
|
|
334
|
+
const summary = [];
|
|
335
|
+
for (const w of writes) {
|
|
336
|
+
if (undo)
|
|
337
|
+
await undo.backup(w.file);
|
|
338
|
+
if (w.content === null) {
|
|
339
|
+
await fs.unlink(w.file).catch(() => { });
|
|
340
|
+
summary.push('deleted ' + path.relative(root, w.file));
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
await fs.mkdir(path.dirname(w.file), { recursive: true });
|
|
344
|
+
await fs.writeFile(w.file, w.content, 'utf-8');
|
|
345
|
+
summary.push('written ' + path.relative(root, w.file));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return 'Patch applied:' + NL + summary.join(NL);
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
}
|