zames_pro 2.13.0 → 2.15.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 +39 -0
- package/dist/agent-loop.js +43 -43
- package/dist/commands.js +201 -0
- package/dist/extraTools.js +352 -0
- package/dist/gitTools.js +13 -13
- package/dist/i18n.js +12 -0
- package/dist/index.js +161 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools.js +100 -46
- package/dist/web.js +15 -15
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/gitTools.js
CHANGED
|
@@ -18,16 +18,16 @@ export function runGit(cmd, cwd, timeout = 15_000) {
|
|
|
18
18
|
const errStr = (stderr || '').toString();
|
|
19
19
|
if (!err) {
|
|
20
20
|
const combined = (out + errStr).trim();
|
|
21
|
-
resolve(combined || '(
|
|
21
|
+
resolve(combined || '(command produced no output)');
|
|
22
22
|
return;
|
|
23
23
|
}
|
|
24
24
|
const parts = [];
|
|
25
25
|
if (err.killed)
|
|
26
|
-
parts.push(`⏱
|
|
26
|
+
parts.push(`⏱ Timeout ${timeout}ms`);
|
|
27
27
|
else if (err.code !== undefined && err.code !== null)
|
|
28
28
|
parts.push(`Exit code: ${err.code}`);
|
|
29
29
|
else
|
|
30
|
-
parts.push(
|
|
30
|
+
parts.push(`Error: ${err.message}`);
|
|
31
31
|
if (out.trim())
|
|
32
32
|
parts.push(out.trim());
|
|
33
33
|
if (errStr.trim())
|
|
@@ -114,7 +114,7 @@ export function createGitTools(workdir) {
|
|
|
114
114
|
return [
|
|
115
115
|
{
|
|
116
116
|
name: 'GitStatus',
|
|
117
|
-
description: '
|
|
117
|
+
description: 'Show the git status of the working directory. Returns an error if this is not a git repository.',
|
|
118
118
|
parameters: {},
|
|
119
119
|
fn: async () => {
|
|
120
120
|
const err = await ensureRepo();
|
|
@@ -125,7 +125,7 @@ export function createGitTools(workdir) {
|
|
|
125
125
|
},
|
|
126
126
|
{
|
|
127
127
|
name: 'GitDiff',
|
|
128
|
-
description: '
|
|
128
|
+
description: 'Show git diff. By default unstaged changes. Can be limited with path and staged=true.',
|
|
129
129
|
parameters: { path: 'string?', staged: 'boolean?' },
|
|
130
130
|
fn: async ({ path: p, staged }) => {
|
|
131
131
|
const err = await ensureRepo();
|
|
@@ -141,7 +141,7 @@ export function createGitTools(workdir) {
|
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
name: 'GitLog',
|
|
144
|
-
description: '
|
|
144
|
+
description: 'Show the last N commits (default 10).',
|
|
145
145
|
parameters: { count: 'number?' },
|
|
146
146
|
fn: async ({ count }) => {
|
|
147
147
|
const err = await ensureRepo();
|
|
@@ -152,7 +152,7 @@ export function createGitTools(workdir) {
|
|
|
152
152
|
},
|
|
153
153
|
{
|
|
154
154
|
name: 'GitAdd',
|
|
155
|
-
description: '
|
|
155
|
+
description: 'Stage files. If paths is not given — git add -A (everything).',
|
|
156
156
|
parameters: { paths: 'string?' },
|
|
157
157
|
fn: async ({ paths }) => {
|
|
158
158
|
const err = await ensureRepo();
|
|
@@ -165,7 +165,7 @@ export function createGitTools(workdir) {
|
|
|
165
165
|
},
|
|
166
166
|
{
|
|
167
167
|
name: 'GitCommit',
|
|
168
|
-
description: '
|
|
168
|
+
description: 'Commit staged changes (if nothing is staged — git add -A first). Does not push.',
|
|
169
169
|
parameters: { message: 'string' },
|
|
170
170
|
fn: async ({ message }) => {
|
|
171
171
|
const err = await ensureRepo();
|
|
@@ -173,14 +173,14 @@ export function createGitTools(workdir) {
|
|
|
173
173
|
return err;
|
|
174
174
|
const msg = String(message || '');
|
|
175
175
|
if (!msg.trim()) {
|
|
176
|
-
return '
|
|
176
|
+
return 'Error: message is empty.';
|
|
177
177
|
}
|
|
178
178
|
// We stage everything only if the index is empty (as the description promises).
|
|
179
179
|
const staged = await runGit('git diff --cached --name-only', workdir, 10_000);
|
|
180
180
|
const hasStaged = !/^\s*/.test(staged) &&
|
|
181
|
-
staged.trim() !== '(
|
|
181
|
+
staged.trim() !== '(command produced no output)';
|
|
182
182
|
const addResult = hasStaged
|
|
183
|
-
? '(
|
|
183
|
+
? '(index is not empty — add -A skipped)'
|
|
184
184
|
: await runGit('git add -A', workdir, 20_000);
|
|
185
185
|
const fs = await import('fs/promises');
|
|
186
186
|
const path = await import('path');
|
|
@@ -198,7 +198,7 @@ export function createGitTools(workdir) {
|
|
|
198
198
|
},
|
|
199
199
|
{
|
|
200
200
|
name: 'GitPush',
|
|
201
|
-
description: '
|
|
201
|
+
description: 'Push a branch to origin. Requires configured credentials (SSH or Windows Credential Manager).',
|
|
202
202
|
parameters: { branch: 'string?', setUpstream: 'boolean?' },
|
|
203
203
|
fn: async ({ branch, setUpstream }) => {
|
|
204
204
|
const err = await ensureRepo();
|
|
@@ -208,7 +208,7 @@ export function createGitTools(workdir) {
|
|
|
208
208
|
? String(branch).trim()
|
|
209
209
|
: (await runGit('git branch --show-current', workdir, 5000)).trim();
|
|
210
210
|
if (!b)
|
|
211
|
-
return '
|
|
211
|
+
return 'Error: could not determine the branch to push.';
|
|
212
212
|
const flag = setUpstream ? '-u ' : '';
|
|
213
213
|
const target = quoteArgs(b);
|
|
214
214
|
return runGit(`git push ${flag}origin ${target}`, workdir, 120_000);
|
package/dist/i18n.js
CHANGED
|
@@ -78,6 +78,18 @@ const CATALOG = {
|
|
|
78
78
|
'help.cmd.undo': { ru: '/undo откатить последнюю запись/правку', en: '/undo revert the last write/edit' },
|
|
79
79
|
'help.cmd.undo_list': { ru: '/undo-list список того, что можно откатить', en: '/undo-list list revertable changes' },
|
|
80
80
|
'help.cmd.transcript': { ru: '/transcript путь к файлу транскрипта', en: '/transcript transcript file path' },
|
|
81
|
+
'help.cmd.diff': { ru: '/diff [--staged] показать git diff', en: '/diff [--staged] show working tree git diff' },
|
|
82
|
+
'help.cmd.cost': { ru: '/cost статистика сессии', en: '/cost session stats' },
|
|
83
|
+
'help.cmd.export': { ru: '/export [file] выгрузить сессию в Markdown', en: '/export [file] export the session to Markdown' },
|
|
84
|
+
'help.cmd.doctor': { ru: '/doctor диагностика установки и конфига', en: '/doctor diagnose install and config' },
|
|
85
|
+
'help.cmd.permissions': { ru: '/permissions настройки подтверждений', en: '/permissions confirmation settings' },
|
|
86
|
+
'help.cmd.add_dir': { ru: '/add-dir <path> проверить директорию', en: '/add-dir <path> validate a directory' },
|
|
87
|
+
'help.cmd.review': { ru: '/review [focus] ревью незакоммиченных изменений', en: '/review [focus] review uncommitted changes' },
|
|
88
|
+
'diff.not_repo': { ru: 'Не git-репозиторий.', en: 'Not a git repository.' },
|
|
89
|
+
'export.done': { ru: 'Сессия выгружена: {v}', en: 'Session exported: {v}' },
|
|
90
|
+
'export.outside': { ru: 'Путь вне рабочей директории.', en: 'Path is outside the working directory.' },
|
|
91
|
+
'adddir.not_dir': { ru: 'Нет такой директории: {v}', en: 'No such directory: {v}' },
|
|
92
|
+
'adddir.note': { ru: 'Директория существует: {v}. Песочница фиксируется при запуске.', en: 'Directory exists: {v}. The sandbox is fixed at startup.' },
|
|
81
93
|
'help.cmd.config': { ru: '/config настройки: показать и изменить', en: '/config settings: view and edit' },
|
|
82
94
|
'help.cmd.lang': { ru: '/config lang <ru|en> сменить язык интерфейса и агента', en: '/config lang <ru|en> switch UI and agent language' },
|
|
83
95
|
'help.cmd.debug_dom': { ru: '/debug-dom сохранить HTML страницы (для отладки)', en: '/debug-dom dump page HTML (debug)' },
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
|
|
|
15
15
|
import { Transcript } from './transcript.js';
|
|
16
16
|
import { UndoStore } from './undo.js';
|
|
17
17
|
import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
|
|
18
|
+
import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, } from './commands.js';
|
|
18
19
|
import { closeWeb } from './web.js';
|
|
19
20
|
import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
|
|
20
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -93,6 +94,7 @@ const SRC_EXT = /[.]ts$/.test(new URL(import.meta.url).pathname) ? '.ts' : '.js'
|
|
|
93
94
|
const RELOADABLE = [
|
|
94
95
|
'mcp',
|
|
95
96
|
'tools',
|
|
97
|
+
'extraTools',
|
|
96
98
|
'agent-loop',
|
|
97
99
|
'system-prompt',
|
|
98
100
|
'gitTools',
|
|
@@ -217,6 +219,13 @@ ${theme.bold(t('help.commands'))}
|
|
|
217
219
|
${t('help.cmd.undo')}
|
|
218
220
|
${t('help.cmd.undo_list')}
|
|
219
221
|
${t('help.cmd.transcript')}
|
|
222
|
+
${t('help.cmd.diff')}
|
|
223
|
+
${t('help.cmd.cost')}
|
|
224
|
+
${t('help.cmd.export')}
|
|
225
|
+
${t('help.cmd.doctor')}
|
|
226
|
+
${t('help.cmd.permissions')}
|
|
227
|
+
${t('help.cmd.add_dir')}
|
|
228
|
+
${t('help.cmd.review')}
|
|
220
229
|
${t('help.cmd.config')}
|
|
221
230
|
${t('help.cmd.lang')}
|
|
222
231
|
${t('help.cmd.debug_dom')}
|
|
@@ -265,6 +274,13 @@ const SLASH_COMMANDS = [
|
|
|
265
274
|
{ name: '/undo', key: 'help.cmd.undo' },
|
|
266
275
|
{ name: '/undo-list', key: 'help.cmd.undo_list' },
|
|
267
276
|
{ name: '/transcript', key: 'help.cmd.transcript' },
|
|
277
|
+
{ name: '/diff', key: 'help.cmd.diff' },
|
|
278
|
+
{ name: '/cost', key: 'help.cmd.cost' },
|
|
279
|
+
{ name: '/export', key: 'help.cmd.export' },
|
|
280
|
+
{ name: '/doctor', key: 'help.cmd.doctor' },
|
|
281
|
+
{ name: '/permissions', key: 'help.cmd.permissions' },
|
|
282
|
+
{ name: '/add-dir', key: 'help.cmd.add_dir' },
|
|
283
|
+
{ name: '/review', key: 'help.cmd.review' },
|
|
268
284
|
{ name: '/config', key: 'help.cmd.config' },
|
|
269
285
|
{ name: '/skills', key: 'help.cmd.skills' },
|
|
270
286
|
{ name: '/memory', key: 'help.cmd.memory' },
|
|
@@ -825,7 +841,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
825
841
|
// case no assistant message was shown, and the operator saw the run
|
|
826
842
|
// just "stop" after a tool call with no explanation. Surface it.
|
|
827
843
|
if (outcome &&
|
|
828
|
-
(outcome.startsWith('
|
|
844
|
+
(outcome.startsWith('Iteration limit reached') ||
|
|
829
845
|
outcome.startsWith('ask() watchdog'))) {
|
|
830
846
|
ui.warning(outcome);
|
|
831
847
|
transcript?.log('agent_no_answer', { outcome });
|
|
@@ -1985,6 +2001,150 @@ async function main() {
|
|
|
1985
2001
|
console.log(theme.system(transcript.file || t('common.off')));
|
|
1986
2002
|
continue;
|
|
1987
2003
|
}
|
|
2004
|
+
if (lower === '/diff' || lower.startsWith('/diff ')) {
|
|
2005
|
+
const staged = lower.indexOf("--staged") !== -1;
|
|
2006
|
+
const { runGit } = await import('./gitTools.js');
|
|
2007
|
+
const probe = await runGit('git rev-parse --is-inside-work-tree', currentWorkdir, 5000);
|
|
2008
|
+
if (probe.trim() !== 'true') {
|
|
2009
|
+
console.error(theme.error(t('diff.not_repo')));
|
|
2010
|
+
continue;
|
|
2011
|
+
}
|
|
2012
|
+
const out = await runGit(diffGitArgs(staged), currentWorkdir, 20_000);
|
|
2013
|
+
console.log(theme.system(formatDiff(out, { maxLines: 400 })));
|
|
2014
|
+
continue;
|
|
2015
|
+
}
|
|
2016
|
+
if (lower === '/cost' || lower === '/usage') {
|
|
2017
|
+
let stats = summarizeTranscript([]);
|
|
2018
|
+
if (transcript.file) {
|
|
2019
|
+
try {
|
|
2020
|
+
const body = await fs.readFile(transcript.file, 'utf-8');
|
|
2021
|
+
stats = summarizeTranscript(parseTranscript(body));
|
|
2022
|
+
}
|
|
2023
|
+
catch {
|
|
2024
|
+
// best-effort
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
console.log(theme.system(renderCost(stats, transcript.file)));
|
|
2028
|
+
continue;
|
|
2029
|
+
}
|
|
2030
|
+
if (lower === '/export' || lower.startsWith('/export ')) {
|
|
2031
|
+
const arg = trimmed.slice('/export'.length).trim();
|
|
2032
|
+
const target = arg
|
|
2033
|
+
? path.resolve(currentWorkdir, arg)
|
|
2034
|
+
: defaultExportPath(currentWorkdir);
|
|
2035
|
+
const rel = path.relative(sandboxRoot, target);
|
|
2036
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
2037
|
+
console.error(theme.error(t('export.outside')));
|
|
2038
|
+
continue;
|
|
2039
|
+
}
|
|
2040
|
+
let entries = [];
|
|
2041
|
+
if (transcript.file) {
|
|
2042
|
+
try {
|
|
2043
|
+
entries = parseTranscript(await fs.readFile(transcript.file, 'utf-8'));
|
|
2044
|
+
}
|
|
2045
|
+
catch {
|
|
2046
|
+
entries = [];
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
const md = formatExport(entries, {
|
|
2050
|
+
chatId: currentChatId,
|
|
2051
|
+
workdir: currentWorkdir,
|
|
2052
|
+
});
|
|
2053
|
+
await fs.writeFile(target, md, 'utf-8');
|
|
2054
|
+
console.log(theme.assistant(t('export.done', { v: target })));
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
if (lower === '/doctor') {
|
|
2058
|
+
const { runGit } = await import('./gitTools.js');
|
|
2059
|
+
let gitOk = false;
|
|
2060
|
+
let gitBranch = null;
|
|
2061
|
+
try {
|
|
2062
|
+
const probe = await runGit('git rev-parse --is-inside-work-tree', currentWorkdir, 5000);
|
|
2063
|
+
gitOk = probe.trim() === 'true';
|
|
2064
|
+
if (gitOk) {
|
|
2065
|
+
gitBranch = (await runGit('git branch --show-current', currentWorkdir, 5000)).trim();
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
catch {
|
|
2069
|
+
gitOk = false;
|
|
2070
|
+
}
|
|
2071
|
+
let clipboardTool = null;
|
|
2072
|
+
try {
|
|
2073
|
+
clipboardTool = hasClipboardTool() ? 'available' : null;
|
|
2074
|
+
}
|
|
2075
|
+
catch {
|
|
2076
|
+
clipboardTool = null;
|
|
2077
|
+
}
|
|
2078
|
+
const mcpStatus = mcpPool
|
|
2079
|
+
? mcpPool.status()
|
|
2080
|
+
: { servers: [], toolCount: 0 };
|
|
2081
|
+
console.log(theme.system(renderDoctor({
|
|
2082
|
+
nodeVersion: process.version,
|
|
2083
|
+
platform: process.platform,
|
|
2084
|
+
workdir: currentWorkdir,
|
|
2085
|
+
gitOk,
|
|
2086
|
+
gitBranch,
|
|
2087
|
+
configOk: true,
|
|
2088
|
+
browserChannel: config.browserChannel,
|
|
2089
|
+
clipboardTool,
|
|
2090
|
+
mcpServers: mcpStatus.servers.length,
|
|
2091
|
+
mcpTools: mcpStatus.toolCount,
|
|
2092
|
+
transcriptOk: !!transcript.file,
|
|
2093
|
+
})));
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
if (lower === '/permissions' || lower === '/allowed-tools') {
|
|
2097
|
+
console.log(theme.system(renderPermissions({
|
|
2098
|
+
write: config.confirmation.write,
|
|
2099
|
+
edit: config.confirmation.edit,
|
|
2100
|
+
bash: config.confirmation.bash,
|
|
2101
|
+
alwaysConfirm: config.confirmation.alwaysConfirm,
|
|
2102
|
+
})));
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
if (lower === '/add-dir' || lower.startsWith('/add-dir ')) {
|
|
2106
|
+
const arg = trimmed.slice('/add-dir'.length).trim();
|
|
2107
|
+
const res = resolveExtraDir(arg, currentWorkdir);
|
|
2108
|
+
if ('error' in res) {
|
|
2109
|
+
console.error(theme.warn(res.error));
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
const stat = await fs.stat(res.path).catch(() => null);
|
|
2113
|
+
if (!stat || !stat.isDirectory()) {
|
|
2114
|
+
console.error(theme.error(t('adddir.not_dir', { v: res.path })));
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
console.log(theme.system(t('adddir.note', { v: res.path })));
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
if (lower === '/review' || lower.startsWith('/review ')) {
|
|
2121
|
+
const rest = trimmed.slice('/review'.length).trim();
|
|
2122
|
+
const staged = rest.indexOf("--staged") !== -1;
|
|
2123
|
+
const focus = rest.replace(/--staged/g, '').trim();
|
|
2124
|
+
const reviewTask = buildReviewPrompt(focus, staged);
|
|
2125
|
+
const reviewTools = mod.createTools(currentWorkdir, { undo });
|
|
2126
|
+
if (editor)
|
|
2127
|
+
editor.busy = true;
|
|
2128
|
+
try {
|
|
2129
|
+
await runTask(browser, reviewTools, reviewTask, currentWorkdir, {
|
|
2130
|
+
transcript,
|
|
2131
|
+
freshChat: false,
|
|
2132
|
+
sendSystemPrompt: false,
|
|
2133
|
+
ui: editor || null,
|
|
2134
|
+
onChatReady: (chatId) => {
|
|
2135
|
+
if (chatId) {
|
|
2136
|
+
currentChatId = chatId;
|
|
2137
|
+
saveLastChat(chatId, currentWorkdir);
|
|
2138
|
+
}
|
|
2139
|
+
},
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
finally {
|
|
2143
|
+
if (editor)
|
|
2144
|
+
editor.busy = false;
|
|
2145
|
+
}
|
|
2146
|
+
continue;
|
|
2147
|
+
}
|
|
1988
2148
|
if (lower === '/undo') {
|
|
1989
2149
|
const result = await undo.undoLast();
|
|
1990
2150
|
if (result.ok && result.record) {
|