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 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`
@@ -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
- 'останавливаюсь. Ответа модели нет — проверьте чат DeepSeek вручную.');
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
- 'ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после, ' +
267
- 'например: {"tool": "Bash", "args": {"command": "..."}}. ' +
268
- 'Если задача действительно выполнена — вызови respond с итоговым сообщением.';
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('внимание: tool-call распознан нестрогим парсером');
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('внимание: ответ похож на tool-call, но не распознан (попытка ' +
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
- 'Ответь РОВНО одним JSON-объектом вызова инструмента, без текста до и после. ' +
316
- 'НЕ используй XML/DSML-теги — только JSON. ' +
317
- 'Например: {"tool": "Read", "args": {"path": "src/index.js"}}';
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
- 'одним JSON-объектом вызова: {"tool": "...", "args": {...}}. ' +
348
- 'Если задача выполнена — вызови инструмент respond с итоговым сообщением.';
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
- 'Если задача ещё не выполнена — ответь РОВНО одним JSON-объектом ' +
373
- 'вызова инструмента, без текста до и после. ' +
374
- 'Если задача действительно выполнена — вызови respond с итоговым ' +
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
- 'Задача ещё не завершена. Ответь РОВНО одним JSON-объектом вызова ' +
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
- 'Если задача действительно выполнена — вызови respond с итоговым сообщением.';
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
- 'Последний шаг: вызови инструмент respond с итоговым сообщением ' +
408
- 'оператору. Не пиши обычный текст — только вызов respond, например: ' +
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('внимание: пустой respond, прошу продолжить (попытка ' +
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
- 'Ты вызвал respond с пустым message. Если задача выполнена — ' +
461
- 'вызови respond с итоговым сообщением оператору. Если нет — ' +
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('Модель вызвала respond без текста, и лимит повторов исчерпан. ' +
473
- 'Проверьте чат DeepSeek вручную.');
474
- return 'Модель не сформировала итоговое сообщение (пустой respond).';
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 = `Неизвестный инструмент: ${call.tool}`;
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 = `Ошибка: ${e.message}`;
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
+ }