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.
- package/CHANGELOG.md +209 -0
- package/README.md +113 -6
- package/dist/agent-loop.js +67 -0
- package/dist/attachments.js +32 -0
- package/dist/commands.js +57 -20
- package/dist/config.js +3 -42
- package/dist/fsutil.js +18 -0
- package/dist/gitTools.js +37 -0
- package/dist/i18n.js +140 -53
- package/dist/index.js +295 -34
- package/dist/input.js +5 -0
- package/dist/mcp.js +20 -0
- package/dist/self-review.js +44 -6
- package/dist/sessions.js +4 -8
- package/dist/shell.js +72 -0
- package/dist/spinner.js +42 -3
- package/dist/system-prompt.js +13 -0
- package/dist/tools.js +43 -75
- package/dist/transcript.js +14 -4
- package/dist/undo.js +8 -6
- package/dist/web.js +31 -0
- package/package.json +14 -4
- package/scripts/clean-dist.mjs +17 -0
- package/scripts/coverage-gate.mjs +83 -0
- package/dist/confirm.js +0 -105
package/dist/self-review.js
CHANGED
|
@@ -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
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|
package/dist/system-prompt.js
CHANGED
|
@@ -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 {
|
|
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"
|
|
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
|
-
|
|
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 =
|
|
252
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/transcript.js
CHANGED
|
@@ -5,11 +5,13 @@ export class Transcript {
|
|
|
5
5
|
file;
|
|
6
6
|
stream;
|
|
7
7
|
startedAt;
|
|
8
|
-
|
|
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(
|
|
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
|
-
|
|
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: '
|
|
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.
|
|
4
|
-
"description": "Terminal coding agent
|
|
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
|
-
"
|
|
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
|
+
}
|