zames_pro 2.54.0 → 2.58.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.
@@ -61,6 +61,45 @@ async function copyDirJsFiles(from, to) {
61
61
  }
62
62
  return copied;
63
63
  }
64
+ // Root files and folders a review often needs but that are NOT part of the
65
+ // editable src/ set (scripts, lint/ts configs). They are copied into a
66
+ // read-only `_context/` subfolder so the reviewer can READ them while the
67
+ // .ts-only diff/apply logic stays untouched (a stray edit here would never be
68
+ // applied back and would only confuse the report).
69
+ const CONTEXT_FILES = [
70
+ 'package.json',
71
+ 'eslint.config.js',
72
+ '.prettierrc',
73
+ 'tsconfig.json',
74
+ 'tsconfig.build.json',
75
+ ];
76
+ async function copyContextFiles(srcDir, snapDir) {
77
+ const repoRoot = path.resolve(srcDir, '..');
78
+ const ctxDir = path.join(snapDir, '_context');
79
+ for (const rel of CONTEXT_FILES) {
80
+ const data = await fs
81
+ .readFile(path.join(repoRoot, rel), 'utf-8')
82
+ .catch(() => null);
83
+ if (data === null)
84
+ continue;
85
+ await ensureDir(ctxDir);
86
+ await fs.writeFile(path.join(ctxDir, rel), data, 'utf-8');
87
+ }
88
+ const scripts = await fs
89
+ .readdir(path.join(repoRoot, 'scripts'))
90
+ .catch(() => []);
91
+ if (scripts.length) {
92
+ await ensureDir(path.join(ctxDir, 'scripts'));
93
+ for (const f of scripts) {
94
+ const data = await fs
95
+ .readFile(path.join(repoRoot, 'scripts', f), 'utf-8')
96
+ .catch(() => null);
97
+ if (data === null)
98
+ continue;
99
+ await fs.writeFile(path.join(ctxDir, 'scripts', f), data, 'utf-8');
100
+ }
101
+ }
102
+ }
64
103
  // ---------- /self-review ----------
65
104
  export async function selfReview({ browser, focus, transcript, locale, }) {
66
105
  const t = translator(locale);
@@ -74,12 +113,9 @@ export async function selfReview({ browser, focus, transcript, locale, }) {
74
113
  console.error(theme.error('\n✖ ' + t('self.no_src', { dir: SRC_DIR })));
75
114
  throw new Error(t('self.no_src_err'));
76
115
  }
77
- // Copy package.json for context — so the agent sees the dependencies
78
- try {
79
- const pkg = await fs.readFile(path.resolve(SRC_DIR, '..', 'package.json'), 'utf-8');
80
- await fs.writeFile(path.join(snapDir, 'package.json'), pkg, 'utf-8');
81
- }
82
- catch { }
116
+ // Copy root configs/scripts into a read-only _context/ subfolder so the
117
+ // reviewer can read them (the editable set stays .ts-only).
118
+ await copyContextFiles(SRC_DIR, snapDir);
83
119
  console.log(theme.system('\n' + t('self.snapshot', { dir: snapDir })));
84
120
  console.log(theme.system(t('self.files', { n: copied.length, list: copied.join(', ') })));
85
121
  console.log(theme.system(t('self.starting') + '\n'));
@@ -248,6 +284,8 @@ function buildReviewPrompt({ focus, snapDir, }) {
248
284
  return `You are a senior JavaScript/Node.js engineer. You are reviewing your own source code — a Playwright-based coding agent that talks to the DeepSeek web chat as an LLM backend.
249
285
 
250
286
  All source files are in your working directory: ${snapDir}
287
+ Root configs and scripts (package.json, tsconfig, eslint, scripts/) are copied
288
+ into the read-only _context/ subfolder there — read them when relevant.
251
289
  ${focusLine}
252
290
  ## Your job
253
291
 
package/dist/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { writeJsonAtomic } from './fsutil.js';
4
5
  // We store sessions (DeepSeek chats) in a separate folder so they aren't lost
5
6
  // after a process restart. Each session is a separate JSON file
6
7
  // <id>.json in ~/.zames/.sessions. last.json points to the last
@@ -8,11 +9,6 @@ import os from 'os';
8
9
  const SESSIONS_DIR = path.join(os.homedir(), '.zames', '.sessions');
9
10
  const INDEX_FILE = path.join(SESSIONS_DIR, 'last.json');
10
11
  export const SESSIONS_FORMAT_VERSION = 1;
11
- function atomicWriteJson(file, value) {
12
- const tmp = file + '.tmp-' + process.pid;
13
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf-8');
14
- fs.renameSync(tmp, file);
15
- }
16
12
  function ensureDir() {
17
13
  fs.mkdirSync(SESSIONS_DIR, { recursive: true });
18
14
  }
@@ -46,7 +42,7 @@ export function saveSession({ id, title = '', workdir = '', todos, }) {
46
42
  const nextTodos = todos !== undefined ? todos : prev.todos;
47
43
  if (nextTodos !== undefined)
48
44
  data.todos = nextTodos;
49
- atomicWriteJson(file, data);
45
+ writeJsonAtomic(file, data);
50
46
  writeIndex({ id, workdir: data.workdir });
51
47
  return data;
52
48
  }
@@ -71,7 +67,7 @@ function writeIndex({ id, workdir }) {
71
67
  index.byWorkdir[key] = id;
72
68
  index.last = id;
73
69
  index.updatedAt = new Date().toISOString();
74
- atomicWriteJson(INDEX_FILE, index);
70
+ writeJsonAtomic(INDEX_FILE, index);
75
71
  }
76
72
  catch { }
77
73
  }
@@ -154,7 +150,7 @@ export function saveHistory(history) {
154
150
  try {
155
151
  ensureDir();
156
152
  const trimmed = history.slice(-HISTORY_LIMIT);
157
- atomicWriteJson(HISTORY_FILE, trimmed);
153
+ writeJsonAtomic(HISTORY_FILE, trimmed);
158
154
  }
159
155
  catch { }
160
156
  }
package/dist/shell.js ADDED
@@ -0,0 +1,72 @@
1
+ import { exec } from 'child_process';
2
+ import path from 'path';
3
+ // Shared shell runner for the Bash tool and the operator's `!command` escape.
4
+ // Kept in its own module so both call sites use the SAME sandbox guard and the
5
+ // SAME abort wiring; a second, ad-hoc runner would drift from the real one.
6
+ // Sandbox (option A): don't let a shell command go above the project root.
7
+ // This is a protective barrier, not full OS isolation.
8
+ export function assertCommandInsideRoot(root, command) {
9
+ const cmd = String(command || '');
10
+ const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi;
11
+ let m;
12
+ while ((m = cdRe.exec(cmd))) {
13
+ const raw = m[1].trim();
14
+ if (!raw || raw === '-')
15
+ continue;
16
+ const target = path.resolve(root, raw);
17
+ const rel = path.relative(root, target);
18
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
19
+ throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
20
+ }
21
+ }
22
+ }
23
+ export function runShell(workdir, command, timeout = 30_000, signal) {
24
+ return new Promise((resolve) => {
25
+ const options = {
26
+ cwd: workdir,
27
+ timeout,
28
+ maxBuffer: 1024 * 1024 * 8,
29
+ windowsHide: true,
30
+ env: { ...process.env },
31
+ // Abort the child process when the operator pressed Esc/Ctrl+C.
32
+ // Without it a long `npm test` kept running even after the stop.
33
+ signal,
34
+ };
35
+ if (process.platform === 'win32') {
36
+ options.shell = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
37
+ }
38
+ else {
39
+ options.shell = '/bin/sh';
40
+ }
41
+ exec(command, options, (err, stdout, stderr) => {
42
+ const out = (stdout || '').toString();
43
+ const errStr = (stderr || '').toString();
44
+ if (!err) {
45
+ const combined = (out + errStr).trim();
46
+ resolve(combined || '(command produced no output)');
47
+ return;
48
+ }
49
+ const parts = [];
50
+ if (err.killed) {
51
+ parts.push('\u23f1 Timeout after ' + timeout + 'ms \u2014 process killed.');
52
+ }
53
+ else if (err.code === 'ABORT_ERR') {
54
+ parts.push('(aborted by the operator)');
55
+ }
56
+ else if (err.code !== undefined && err.code !== null) {
57
+ parts.push('Exit code: ' + err.code);
58
+ }
59
+ else if (err.signal) {
60
+ parts.push('Killed by signal: ' + err.signal);
61
+ }
62
+ else {
63
+ parts.push('Error: ' + err.message);
64
+ }
65
+ if (out.trim())
66
+ parts.push('stdout:\n' + out.trim());
67
+ if (errStr.trim())
68
+ parts.push('stderr:\n' + errStr.trim());
69
+ resolve(parts.join('\n'));
70
+ });
71
+ });
72
+ }
package/dist/spinner.js CHANGED
@@ -27,6 +27,9 @@ export function renderDots(n) {
27
27
  return theme.brown(d + DOTS_PAD.slice(d.length));
28
28
  }
29
29
  export function createSpinner(locale = 'ru') {
30
+ // Set by the caller (index.ts) to the same todos summary the LineEditor
31
+ // shows, so the non-TTY path has parity.
32
+ let onTasksQuery = null;
30
33
  let spinner = null;
31
34
  let dotTimer = null;
32
35
  let dotPhase = 0;
@@ -37,6 +40,10 @@ export function createSpinner(locale = 'ru') {
37
40
  // zero every second and look frozen on a single dot.
38
41
  let animBase = '';
39
42
  let animating = false;
43
+ // When the current animated phase began, so a long reasoning turn can show
44
+ // "12s" / "2m 05s" instead of looking identical to a hung one. Parity with
45
+ // the LineEditor status line (src/input.ts).
46
+ let animStart = 0;
40
47
  const start = (text) => {
41
48
  if (!spinner)
42
49
  spinner = ora(text).start();
@@ -59,9 +66,34 @@ export function createSpinner(locale = 'ru') {
59
66
  // "running" dot sequence. Shared by the thinking spinner and the send-pause
60
67
  // indicator, so the pause is animated too (before, it was a static line and
61
68
  // the dots did not move — the operator saw a frozen spinner).
69
+ // Elapsed label of the current animated phase ("0s", "45s", "2m 05s").
70
+ // Empty when nothing is animating. Localized units come from the catalog.
71
+ const elapsedLabel = () => {
72
+ if (!animating || !animStart)
73
+ return '';
74
+ const sec = Math.floor((Date.now() - animStart) / 1000);
75
+ const m = Math.floor(sec / 60);
76
+ const s = sec % 60;
77
+ const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
78
+ return theme.dim(' · ' + text);
79
+ };
80
+ // Task-list badge, refreshed from onTasksQuery on every animation tick.
81
+ // Mirrors the LineEditor so the non-TTY spinner shows the same "tasks: 2/5".
82
+ const tasksBadge = () => {
83
+ if (!onTasksQuery)
84
+ return '';
85
+ try {
86
+ const s = onTasksQuery();
87
+ return s ? theme.system(s) + ' ' : '';
88
+ }
89
+ catch {
90
+ return '';
91
+ }
92
+ };
62
93
  const startAnimated = (baseText) => {
63
94
  animBase = theme.brown(stripEllipsis(baseText));
64
- start(animBase + renderDots(0) + HINT);
95
+ animStart = Date.now();
96
+ start(animBase + renderDots(0) + elapsedLabel() + tasksBadge() + HINT);
65
97
  dotPhase = 0;
66
98
  if (dotTimer)
67
99
  clearInterval(dotTimer);
@@ -69,7 +101,8 @@ export function createSpinner(locale = 'ru') {
69
101
  if (!spinner)
70
102
  return;
71
103
  dotPhase = (dotPhase + 1) % DOTS.length;
72
- spinner.text = animBase + renderDots(dotPhase) + HINT;
104
+ spinner.text =
105
+ animBase + renderDots(dotPhase) + elapsedLabel() + tasksBadge() + HINT;
73
106
  }, 400);
74
107
  animating = true;
75
108
  if (dotTimer.unref)
@@ -93,6 +126,12 @@ export function createSpinner(locale = 'ru') {
93
126
  spinner.text = theme.prompt('✎ ') + pending + HINT;
94
127
  };
95
128
  return {
129
+ get onTasksQuery() {
130
+ return onTasksQuery;
131
+ },
132
+ set onTasksQuery(fn) {
133
+ onTasksQuery = fn;
134
+ },
96
135
  thinking: () => {
97
136
  if (pending) {
98
137
  showPending();
@@ -112,7 +151,7 @@ export function createSpinner(locale = 'ru') {
112
151
  // so the countdown refreshes without resetting the dots.
113
152
  if (animating && dotTimer && spinner) {
114
153
  animBase = theme.brown(stripEllipsis(label));
115
- spinner.text = animBase + renderDots(dotPhase) + HINT;
154
+ spinner.text = animBase + renderDots(dotPhase) + elapsedLabel() + HINT;
116
155
  return;
117
156
  }
118
157
  startAnimated(label);
@@ -128,6 +128,19 @@ You have access to the following tools:
128
128
 
129
129
  ${toolDescriptions}
130
130
  ${gitSection}${attachSection}${contextSection}
131
+ ## Choosing the right tool
132
+
133
+ Prefer the most specific tool; a wrong choice wastes a turn:
134
+
135
+ - Several small edits in ONE file -> MultiEdit (applied atomically), not
136
+ several Edit calls.
137
+ - Changes across SEVERAL files -> ApplyPatch (V4A), not a series of writes.
138
+ - One exact replacement -> Edit. Creating or overwriting a whole file -> Write.
139
+ - Searching file CONTENT -> Grep; finding files by NAME -> Glob; listing a
140
+ directory -> LS.
141
+ - Git operations -> the Git* tools, not Bash.
142
+ - Reading a file whose path you already know -> Read, not Bash cat.
143
+
131
144
  ## MCP tools
132
145
 
133
146
  Some tools are provided by external MCP servers and their names look
package/dist/tools.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
- import { exec } from 'child_process';
3
+ import { assertCommandInsideRoot, runShell } from './shell.js';
4
4
  import { createGitTools } from './gitTools.js';
5
5
  import { createWebTools } from './web.js';
6
6
  import { createExtraTools } from './extraTools.js';
@@ -8,7 +8,7 @@ import { createExtraTools } from './extraTools.js';
8
8
  // user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
9
9
  // strings the OPERATOR sees (help, spinner, service messages) are localized —
10
10
  // those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
11
- export function createTools(workdir, { undo, todos } = {}) {
11
+ export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
12
12
  const root = path.resolve(workdir);
13
13
  const safe = (p) => {
14
14
  const resolved = path.resolve(root, p);
@@ -20,71 +20,6 @@ export function createTools(workdir, { undo, todos } = {}) {
20
20
  }
21
21
  return resolved;
22
22
  };
23
- // Sandbox (option A): don't let the command go above root.
24
- // This is a protective barrier, not full OS isolation.
25
- const assertCommandInsideRoot = (command) => {
26
- const cmd = String(command || '');
27
- const cdRe = /(?:^|[;&|]|\s)(?:cd|pushd)\s+([^;&|]+)/gi;
28
- let m;
29
- while ((m = cdRe.exec(cmd))) {
30
- const raw = m[1].trim();
31
- if (!raw || raw === '-')
32
- continue;
33
- const target = path.resolve(root, raw);
34
- const rel = path.relative(root, target);
35
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
36
- throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
37
- }
38
- }
39
- };
40
- const runShell = (command, timeout = 30_000, signal) => new Promise((resolve) => {
41
- const options = {
42
- cwd: workdir,
43
- timeout,
44
- maxBuffer: 1024 * 1024 * 8,
45
- windowsHide: true,
46
- env: { ...process.env },
47
- // Abort the child process when the operator pressed Esc/Ctrl+C.
48
- // Without it a long `npm test` kept running even after the stop.
49
- signal,
50
- };
51
- if (process.platform === 'win32') {
52
- options.shell = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
53
- }
54
- else {
55
- options.shell = '/bin/sh';
56
- }
57
- exec(command, options, (err, stdout, stderr) => {
58
- const out = (stdout || '').toString();
59
- const errStr = (stderr || '').toString();
60
- if (!err) {
61
- const combined = (out + errStr).trim();
62
- resolve(combined || '(command produced no output)');
63
- return;
64
- }
65
- const parts = [];
66
- if (err.killed) {
67
- parts.push(`⏱ Timeout after ${timeout}ms — process killed.`);
68
- }
69
- else if (err.code === 'ABORT_ERR') {
70
- parts.push('(aborted by the operator)');
71
- }
72
- else if (err.code !== undefined && err.code !== null) {
73
- parts.push(`Exit code: ${err.code}`);
74
- }
75
- else if (err.signal) {
76
- parts.push(`Killed by signal: ${err.signal}`);
77
- }
78
- else {
79
- parts.push(`Error: ${err.message}`);
80
- }
81
- if (out.trim())
82
- parts.push(`stdout:\n${out.trim()}`);
83
- if (errStr.trim())
84
- parts.push(`stderr:\n${errStr.trim()}`);
85
- resolve(parts.join('\n'));
86
- });
87
- });
88
23
  // Extracts text from content/content_base64. base64 is needed because the
89
24
  // channel that delivers the model's answer may corrupt characters ($,
90
25
  // backslashes, newlines). base64 consists only of [A-Za-z0-9+/=] and is not
@@ -202,8 +137,8 @@ export function createTools(workdir, { undo, todos } = {}) {
202
137
  'For git use the Git* tools. For the web use WebFetch / WebSearch.',
203
138
  parameters: { command: 'string', timeout: 'number?' },
204
139
  fn: async ({ command, timeout }, ctx) => {
205
- assertCommandInsideRoot(req(command, 'command'));
206
- return runShell(req(command, 'command'), timeout, ctx?.signal);
140
+ assertCommandInsideRoot(root, req(command, 'command'));
141
+ return runShell(workdir, req(command, 'command'), timeout, ctx?.signal);
207
142
  },
208
143
  },
209
144
  {
@@ -229,7 +164,8 @@ export function createTools(workdir, { undo, todos } = {}) {
229
164
  {
230
165
  name: 'Grep',
231
166
  description: 'Search file contents (regular expression). include — file name ' +
232
- 'filter (e.g. "*.ts"). output: content (default, matching lines with ' +
167
+ 'filter, a single glob or a comma-separated list (e.g. "*.ts" or ' +
168
+ '"*.ts,*.tsx"). output: content (default, matching lines with ' +
233
169
  'numbers), files_only (paths only), count (match count per file).',
234
170
  parameters: {
235
171
  pattern: 'string',
@@ -240,16 +176,22 @@ export function createTools(workdir, { undo, todos } = {}) {
240
176
  fn: async ({ pattern, path: searchPath, include, output }) => {
241
177
  const pat = req(pattern, 'pattern');
242
178
  const target = searchPath ? safe(String(searchPath)) : workdir;
243
- const inc = include ? String(include).trim() : '';
179
+ // include may be a single glob or a comma-separated list.
180
+ const incs = (include ? String(include) : '')
181
+ .split(',')
182
+ .map((s) => s.trim())
183
+ .filter(Boolean);
244
184
  const mode = String(output || 'content').toLowerCase();
245
185
  if (process.platform === 'win32') {
246
186
  const escaped = pat.replace(/"/g, '\\"');
247
187
  const scope = searchPath ? '"' + target + '\\*' : '*';
248
- return runShell(`findstr /s /n /r /c:"${escaped}" ` + scope);
188
+ return runShell(workdir, `findstr /s /n /r /c:"${escaped}" ` + scope);
249
189
  }
250
190
  const flags = mode === 'files_only' ? '-rlE' : mode === 'count' ? '-rcE' : '-rnE';
251
- const includeArg = inc ? ' --include=' + JSON.stringify(inc) : '';
252
- return runShell(`grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
191
+ const includeArg = incs
192
+ .map((g) => ' --include=' + JSON.stringify(g))
193
+ .join('');
194
+ return runShell(workdir, `grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
253
195
  `${JSON.stringify(target)} || true`);
254
196
  },
255
197
  },
@@ -263,5 +205,31 @@ export function createTools(workdir, { undo, todos } = {}) {
263
205
  parameters: { message: 'string' },
264
206
  fn: async ({ message }) => message,
265
207
  };
266
- return [...baseTools, ...extraTools, ...gitTools, ...webTools, respondTool];
208
+ const all = [
209
+ ...baseTools,
210
+ ...extraTools,
211
+ ...gitTools,
212
+ ...webTools,
213
+ respondTool,
214
+ ];
215
+ return readOnly ? filterToolsForReadOnly(all) : all;
216
+ }
217
+ // Tools that mutate the working tree or run arbitrary commands. In "plan"
218
+ // (read-only) mode they are REMOVED from the tool set entirely, so the model
219
+ // cannot write by accident while it is still investigating. Removing (rather
220
+ // than rejecting at call time) is the honest signal: the agent never sees a
221
+ // tool it is not allowed to use, so it does not waste a turn trying.
222
+ export const MUTATING_TOOLS = new Set([
223
+ 'Write',
224
+ 'Edit',
225
+ 'MultiEdit',
226
+ 'ApplyPatch',
227
+ 'Bash',
228
+ 'GitAdd',
229
+ 'GitCommit',
230
+ 'GitPush',
231
+ ]);
232
+ /** Drop mutating tools for read-only / plan mode. Pure; unit-tested. */
233
+ export function filterToolsForReadOnly(tools) {
234
+ return tools.filter((t) => !MUTATING_TOOLS.has(t.name));
267
235
  }
@@ -5,11 +5,13 @@ export class Transcript {
5
5
  file;
6
6
  stream;
7
7
  startedAt;
8
- constructor({ dir, enabled = true, sessionName = 'session', }) {
8
+ onLine;
9
+ constructor({ dir, enabled = true, sessionName = 'session', onLine = null, }) {
9
10
  this.enabled = enabled;
10
11
  this.file = null;
11
12
  this.stream = null;
12
13
  this.startedAt = Date.now();
14
+ this.onLine = onLine;
13
15
  if (!enabled)
14
16
  return;
15
17
  try {
@@ -30,16 +32,24 @@ export class Transcript {
30
32
  }
31
33
  }
32
34
  log(type, data = {}) {
33
- if (!this.enabled || !this.stream)
34
- return;
35
35
  const entry = {
36
36
  ts: new Date().toISOString(),
37
37
  elapsed: Date.now() - this.startedAt,
38
38
  type,
39
39
  ...data,
40
40
  };
41
+ const line = JSON.stringify(entry) + String.fromCharCode(10);
42
+ // The mirror runs even when the file is disabled (jsonl mode).
43
+ if (this.onLine) {
44
+ try {
45
+ this.onLine(line);
46
+ }
47
+ catch { }
48
+ }
49
+ if (!this.enabled || !this.stream)
50
+ return;
41
51
  try {
42
- this.stream.write(JSON.stringify(entry) + String.fromCharCode(10));
52
+ this.stream.write(line);
43
53
  }
44
54
  catch { }
45
55
  }
package/dist/undo.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs/promises';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { writeJsonAtomic } from './fsutil.js';
4
5
  const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo');
5
6
  const INDEX = path.join(UNDO_DIR, 'index.json');
6
7
  export class UndoStore {
@@ -23,9 +24,7 @@ export class UndoStore {
23
24
  }
24
25
  async _writeIndex(history) {
25
26
  // Atomic write: a crash mid-write must not corrupt the undo history.
26
- const tmp = `${INDEX}.tmp-${process.pid}`;
27
- await fs.writeFile(tmp, JSON.stringify(history, null, 2));
28
- await fs.rename(tmp, INDEX);
27
+ writeJsonAtomic(INDEX, history);
29
28
  }
30
29
  async backup(filePath) {
31
30
  if (!this.enabled)
@@ -63,15 +62,18 @@ export class UndoStore {
63
62
  return { originalPath: filePath, existed, stamp };
64
63
  }
65
64
  async undoLast() {
65
+ // Machine codes, NOT prose: the caller (index.ts) localizes them through
66
+ // the i18n catalog. Hardcoding a Russian reason here leaked Russian into
67
+ // an English UI (`/undo` with an empty history).
66
68
  if (!this.enabled)
67
- return { ok: false, reason: 'undo отключён в конфиге' };
69
+ return { ok: false, reason: 'disabled' };
68
70
  await this._ensure();
69
71
  const history = await this._readIndex();
70
72
  if (!history.length)
71
- return { ok: false, reason: 'история пуста' };
73
+ return { ok: false, reason: 'empty' };
72
74
  const record = history.pop();
73
75
  if (!record)
74
- return { ok: false, reason: 'история пуста' };
76
+ return { ok: false, reason: 'empty' };
75
77
  const { originalPath, existed, backupFile } = record;
76
78
  try {
77
79
  if (existed && backupFile) {
package/dist/web.js CHANGED
@@ -118,6 +118,30 @@ async function assertPublicUrl(rawUrl) {
118
118
  }
119
119
  }
120
120
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
121
+ // True for content types whose bodies are not meant to be shown as text.
122
+ // WebFetch dumps the body into the context, so a PDF or an image would flood
123
+ // the model with garbage; the caller returns a short human note instead.
124
+ export function isBinaryContentType(contentType) {
125
+ const ct = String(contentType || '')
126
+ .toLowerCase()
127
+ .split(';')[0]
128
+ .trim();
129
+ if (!ct)
130
+ return false;
131
+ if (ct.startsWith('text/'))
132
+ return false;
133
+ // Textual application/* types must stay readable.
134
+ if (/^(application\/(json|xml|xhtml\+xml|javascript|ecmascript|x-httpd-php|rtf|sql|graphql|x-sh|x-yaml|yaml|toml|ld\+json|x-ndjson))$/.test(ct)) {
135
+ return false;
136
+ }
137
+ if (/\+(json|xml)$/.test(ct))
138
+ return false;
139
+ if (/^(image|audio|video|font|model)\//.test(ct) ||
140
+ /^application\/(pdf|zip|gzip|x-gzip|x-tar|octet-stream|msword|vnd\.|x-7z|x-rar|x-bzip|x-protobuf|wasm)/.test(ct)) {
141
+ return true;
142
+ }
143
+ return false;
144
+ }
121
145
  // fetch with retries for transient network errors and 5xx responses.
122
146
  async function httpFetchWithRetry(url, opts = {}, retries = 2) {
123
147
  let lastErr;
@@ -231,6 +255,13 @@ export function createWebTools() {
231
255
  return formatResult(r.status, r.url, text, limit);
232
256
  }
233
257
  const r = await httpFetchWithRetry(String(url));
258
+ // Binary content (PDF, images, archives, ...) must NOT be dumped
259
+ // into the context as raw bytes — tell the model what it is instead.
260
+ if (isBinaryContentType(r.contentType)) {
261
+ return (`HTTP ${r.status} ${r.url}\n` +
262
+ `Content-Type: ${r.contentType}\n\n` +
263
+ `(binary content is not returned as text; content-type: ${r.contentType})`);
264
+ }
234
265
  // If it's JSON/plain text — return as is
235
266
  if (/application\/json|text\/plain|text\/markdown/i.test(r.contentType)) {
236
267
  const trimmed = r.body.slice(0, limit);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.54.0",
4
- "description": "Terminal coding agent over chat.deepseek.com via Playwright",
3
+ "version": "2.58.0",
4
+ "description": "Terminal coding agent that drives chat.deepseek.com through Playwright: reads and edits files, runs commands, commits to git.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "zames": "dist/index.js"
@@ -10,13 +10,16 @@
10
10
  "dist",
11
11
  "scripts",
12
12
  "README.md",
13
+ "CHANGELOG.md",
13
14
  "LICENSE",
14
15
  "logo.jpg"
15
16
  ],
16
17
  "scripts": {
17
18
  "start": "node ./dist/index.js",
18
19
  "dev": "ZAMES_NET_DEBUG=1 tsx ./src/index.ts --dev --headed",
19
- "build": "tsc -p tsconfig.build.json",
20
+ "clean": "node scripts/clean-dist.mjs",
21
+ "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
22
+ "coverage:gate": "node scripts/coverage-gate.mjs",
20
23
  "typecheck": "tsc --noEmit",
21
24
  "lint": "node scripts/eslint-ts6.mjs",
22
25
  "lint:fix": "node scripts/eslint-ts6.mjs --fix",
@@ -34,12 +37,19 @@
34
37
  "ai",
35
38
  "deepseek",
36
39
  "cli",
37
- "coding-agent"
40
+ "coding-agent",
41
+ "claude-code",
42
+ "codex",
43
+ "terminal",
44
+ "playwright",
45
+ "llm",
46
+ "automation"
38
47
  ],
39
48
  "license": "MIT",
40
49
  "engines": {
41
50
  "node": ">=20"
42
51
  },
52
+ "packageManager": "npm@11.19.0",
43
53
  "dependencies": {
44
54
  "@modelcontextprotocol/sdk": "^1.30.1",
45
55
  "chalk": "^5.3.0",
@@ -0,0 +1,17 @@
1
+ import { rmSync, existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // tsc emits into dist/ without clearing it, so a source file DELETED from
6
+ // src/ leaves an orphan .js in the published tree (this really happened:
7
+ // dist/confirm.js survived after src/confirm.ts was removed). Wipe dist/ first
8
+ // so the build output mirrors src/ exactly.
9
+ //
10
+ // Cross-platform: use node's fs.rm instead of a shell `rm -rf` (Windows has no
11
+ // rm) and never touch anything outside this repo.
12
+ const here = path.dirname(fileURLToPath(import.meta.url))
13
+ const dist = path.resolve(here, '..', 'dist')
14
+ if (existsSync(dist)) {
15
+ rmSync(dist, { recursive: true, force: true })
16
+ console.log('clean-dist: removed', dist)
17
+ }