zames_pro 2.29.15 → 2.31.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/dist/commands.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { isImageName } from './attachments.js';
2
3
  // Helpers for the extra slash commands (/diff, /cost, /export, /doctor,
3
4
  // /permissions, /review, /add-dir). Pure functions, unit-tested without a
4
5
  // live agent/browser. index.ts only renders their output.
@@ -342,7 +343,9 @@ export function buildCompactPrompt(locale = 'ru') {
342
343
  '(3) the exact current state (files changed, commands run, their results); ' +
343
344
  '(4) open questions and the next concrete steps. ' +
344
345
  'Keep file paths, function/identifier names, commands and error texts verbatim. ' +
345
- 'Be concise but complete — no small talk, no code dumps beyond short essential snippets.');
346
+ 'Be concise but complete — no small talk, no code dumps beyond short essential snippets. ' +
347
+ 'Reply with the summary text ONLY. Do NOT call any tools and do NOT output tool-call ' +
348
+ 'JSON or DSML — this is a handover summary, not a continuation of the work.');
346
349
  }
347
350
  return ('Сожми историю диалога в краткое резюме для НОВОГО чата. ' +
348
351
  'Это резюме будет ЕДИНСТВЕННЫМ контекстом, с которым новый чат начнёт работу, поэтому оно должно быть самодостаточным. ' +
@@ -350,7 +353,40 @@ export function buildCompactPrompt(locale = 'ru') {
350
353
  '(3) точное текущее состояние (изменённые файлы, выполненные команды и их результаты); ' +
351
354
  '(4) открытые вопросы и следующие конкретные шаги. ' +
352
355
  'Пути к файлам, имена функций/идентификаторов, команды и тексты ошибок сохраняй дословно. ' +
353
- 'Пиши кратко, но полно — без воды и без больших дампов кода (только короткие важные фрагменты).');
356
+ 'Пиши кратко, но полно — без воды и без больших дампов кода (только короткие важные фрагменты). ' +
357
+ 'В ответе верни ТОЛЬКО текст резюме. НЕ вызывай инструменты и НЕ выводи JSON/DSML вызова ' +
358
+ 'инструмента — это резюме для передачи, а не продолжение работы.');
359
+ }
360
+ /**
361
+ * Is the model's /compact answer a usable handover summary?
362
+ *
363
+ * The summary request is sent into the OLD chat as a normal turn, so in
364
+ * reasoning mode the model can answer it with a TOOL CALL instead of prose
365
+ * (observed live: it emitted a Bash call to "gather the current state" and
366
+ * never produced a summary — only a THINK fragment). A tool-call JSON is
367
+ * protocol noise and must NOT be carried into the new chat, otherwise the new
368
+ * chat starts by re-running an old command. An empty answer / the abort
369
+ * sentinel is unusable too.
370
+ */
371
+ export function isUsableCompactSummary(text) {
372
+ const t = String(text || '').trim();
373
+ if (!t)
374
+ return false;
375
+ if (t.length < 40)
376
+ return false;
377
+ if (/^\(прервано пользователем\)$/.test(t))
378
+ return false;
379
+ if (parseAssistantToolCall(t))
380
+ return false;
381
+ if (/DSML/i.test(t))
382
+ return false;
383
+ // A tool-call body without a recognizable head (a cut-off stream) still
384
+ // carries "args": { ... }.
385
+ if (/"args"\s*:\s*\{/.test(t))
386
+ return false;
387
+ if (/<ds_safety>/i.test(t))
388
+ return false;
389
+ return true;
354
390
  }
355
391
  /**
356
392
  * Wrap the model's summary into the text posted as the first message of the
@@ -370,6 +406,74 @@ export function buildCompactCarryover(summary, task) {
370
406
  return out;
371
407
  }
372
408
  // ---------- /review ----------
409
+ // ---------- Ctrl+C escalation ----------
410
+ // Decide what a Ctrl+C does while the agent is busy. The FIRST press aborts
411
+ // the currently running TOOL; a SECOND press within the window stops the WHOLE
412
+ // run (including the queue). Outside the window it is a fresh first press.
413
+ // Returns 'tool' | 'run'.
414
+ export function ctrlCEscalation(sinceLastMs, windowMs = 2000) {
415
+ return sinceLastMs < windowMs ? 'run' : 'tool';
416
+ }
417
+ // True when the queued message is a slash-command. It must NOT be merged into
418
+ // a task: the main loop has to execute it (e.g. /compact, /new).
419
+ export function isSlashCommand(text) {
420
+ return String(text ?? '')
421
+ .trim()
422
+ .startsWith('/');
423
+ }
424
+ function queuedIsImage(a) {
425
+ return (String(a.mime || '').startsWith('image/') ||
426
+ isImageName(String(a.name || '')));
427
+ }
428
+ // Merge a batch of queued messages into a single task. Marker numbering is
429
+ // PER-MESSAGE ([image#1]/[file#1] restart in every message), so a naive
430
+ // concatenation would produce duplicate markers for different files. We
431
+ // RENUMBER the markers across the batch and rebuild the attachments array in
432
+ // the same order, keeping the text markers the source of truth.
433
+ export function mergeMessages(msgs) {
434
+ const list = (msgs || []).filter((m) => String(m?.text ?? '').trim() !== '');
435
+ if (list.length === 0)
436
+ return { text: '', attachments: [] };
437
+ if (list.length === 1) {
438
+ return {
439
+ text: list[0].text,
440
+ attachments: (list[0].attachments || []).slice(),
441
+ };
442
+ }
443
+ let imgOffset = 0;
444
+ let fileOffset = 0;
445
+ const attachments = [];
446
+ const parts = [];
447
+ for (const m of list) {
448
+ const atts = m.attachments || [];
449
+ const images = atts.filter(queuedIsImage);
450
+ const files = atts.filter((a) => !queuedIsImage(a));
451
+ let text = String(m.text);
452
+ text = text.replace(/\[image#(\d+)\]/g, (whole, n) => {
453
+ const i = Number(n);
454
+ if (!Number.isFinite(i) || i < 1 || i > images.length)
455
+ return whole;
456
+ return '[image#' + (imgOffset + i) + ']';
457
+ });
458
+ text = text.replace(/\[file#(\d+)\]/g, (whole, n) => {
459
+ const i = Number(n);
460
+ if (!Number.isFinite(i) || i < 1 || i > files.length)
461
+ return whole;
462
+ return '[file#' + (fileOffset + i) + ']';
463
+ });
464
+ imgOffset += images.length;
465
+ fileOffset += files.length;
466
+ attachments.push(...atts);
467
+ parts.push(text.trim());
468
+ }
469
+ const header = 'The operator sent ' +
470
+ list.length +
471
+ ' messages while you were working. They are listed below in order — ' +
472
+ 'treat them as one instruction.';
473
+ const text = header + NL + NL + parts.join(NL + NL + '---' + NL + NL);
474
+ return { text, attachments };
475
+ }
476
+ // ---------- /review ----------
373
477
  export function buildReviewPrompt(focus, hasStaged = false) {
374
478
  const scope = hasStaged ? 'staged' : 'uncommitted';
375
479
  const f = String(focus ?? '').trim();
@@ -0,0 +1,178 @@
1
+ import { translate } from './i18n.js';
2
+ import { buildCompactPrompt, buildCompactCarryover, isUsableCompactSummary, trimRestoredMessages, formatRestoredHistory, } from './commands.js';
3
+ const NL = String.fromCharCode(10);
4
+ /** How many times the summary call is retried before the local fallback. */
5
+ export const COMPACT_SUMMARY_ATTEMPTS = 3;
6
+ /** How many restored messages the local fallback keeps. */
7
+ export const COMPACT_FALLBACK_LIMIT = 40;
8
+ /**
9
+ * Compact the CURRENT chat: summarize it (retrying a few times, with a LOCAL
10
+ * history fallback), open a NEW chat, resend the system prompt and post the
11
+ * summary as the carried-over context. Shared by the manual `/compact` command
12
+ * and the automatic between-tools trigger, so both paths stay identical.
13
+ *
14
+ * The caller owns the browser and the throttle; this function only sends.
15
+ */
16
+ export async function performCompact(opts) {
17
+ const { browser, workdir, locale, task, transcript = null, ui = null, buildSystemPrompt, tools, answerTimeoutMs, fallbackLimit = COMPACT_FALLBACK_LIMIT, skipUiLock = false, quiet = false, } = opts;
18
+ const t = translate(locale);
19
+ const log = (msg) => {
20
+ if (!quiet)
21
+ console.error(msg);
22
+ };
23
+ let currentChatId = opts.currentChatId;
24
+ if (!currentChatId) {
25
+ currentChatId = await browser.getCurrentChatId();
26
+ }
27
+ if (!currentChatId) {
28
+ log(t('compact.no_chat'));
29
+ return {
30
+ ok: false,
31
+ chatId: null,
32
+ summaryChars: 0,
33
+ beforeTokens: null,
34
+ error: t('compact.no_chat'),
35
+ };
36
+ }
37
+ const beforeTokens = browser.getLastTokenUsage
38
+ ? browser.getLastTokenUsage()
39
+ : null;
40
+ if (ui && !skipUiLock)
41
+ ui.lock(t('msg.input_locked'));
42
+ try {
43
+ if (!quiet)
44
+ log(t('compact.start'));
45
+ // 1) Ask the OLD chat to summarize itself.
46
+ let summary = '';
47
+ let summaryErr = '';
48
+ for (let attempt = 1; attempt <= COMPACT_SUMMARY_ATTEMPTS; attempt++) {
49
+ let prompt = buildCompactPrompt(locale);
50
+ if (attempt > 1) {
51
+ prompt +=
52
+ locale === 'en'
53
+ ? '\n\nIMPORTANT: reply with the summary as PLAIN TEXT only. Do NOT call any tools and do NOT output tool-call JSON/DSML.'
54
+ : '\n\nВАЖНО: ответь ТОЛЬКО текстом резюме. НЕ вызывай инструменты и НЕ выводи JSON/DSML вызова инструмента.';
55
+ }
56
+ let answer = '';
57
+ try {
58
+ answer = await browser.ask(prompt, {
59
+ agent: true,
60
+ timeout: Math.max(60_000, answerTimeoutMs),
61
+ });
62
+ }
63
+ catch (e) {
64
+ summaryErr = e.message;
65
+ }
66
+ const trimmed = String(answer || '').trim();
67
+ if (/^\(прервано пользователем\)$/.test(trimmed)) {
68
+ return {
69
+ ok: false,
70
+ chatId: currentChatId,
71
+ summaryChars: 0,
72
+ beforeTokens,
73
+ error: trimmed,
74
+ };
75
+ }
76
+ if (!summaryErr && isUsableCompactSummary(trimmed)) {
77
+ summary = trimmed;
78
+ summaryErr = '';
79
+ break;
80
+ }
81
+ if (!summaryErr)
82
+ summaryErr = trimmed || t('common.unknown');
83
+ if (attempt < COMPACT_SUMMARY_ATTEMPTS) {
84
+ log(t('compact.summary_retry', {
85
+ v: summaryErr,
86
+ attempt: String(attempt),
87
+ max: String(COMPACT_SUMMARY_ATTEMPTS),
88
+ }));
89
+ await new Promise((r) => setTimeout(r, 3000 * attempt));
90
+ }
91
+ }
92
+ if (!summary) {
93
+ let fallback = '';
94
+ try {
95
+ const all = browser.fetchChatMessages && currentChatId
96
+ ? await browser.fetchChatMessages(currentChatId).catch(() => [])
97
+ : [];
98
+ const msgs = trimRestoredMessages(all, 0);
99
+ if (msgs.length) {
100
+ fallback = formatRestoredHistory(msgs, { limit: fallbackLimit });
101
+ }
102
+ }
103
+ catch { }
104
+ fallback = String(fallback || '').trim();
105
+ if (fallback) {
106
+ summary = fallback;
107
+ log(t('compact.summary_fallback', { v: String(fallbackLimit) }));
108
+ }
109
+ else {
110
+ const err = summaryErr || t('common.unknown');
111
+ log(t('compact.summary_failed', { v: err }));
112
+ return {
113
+ ok: false,
114
+ chatId: currentChatId,
115
+ summaryChars: 0,
116
+ beforeTokens,
117
+ error: err,
118
+ };
119
+ }
120
+ }
121
+ transcript?.log('compact_summary', {
122
+ chars: summary.length,
123
+ beforeTokens,
124
+ });
125
+ // 2) New chat + system prompt + the summary as the first message.
126
+ await browser.newChat();
127
+ await browser.ask(buildSystemPrompt({ workdir, tools, locale }), {
128
+ timeout: 60_000,
129
+ agent: false,
130
+ });
131
+ await browser.ask(buildCompactCarryover(summary, task), {
132
+ timeout: 60_000,
133
+ agent: false,
134
+ });
135
+ const newChatId = browser.getCurrentChatId
136
+ ? await browser.getCurrentChatId()
137
+ : null;
138
+ if (!quiet) {
139
+ log(t('compact.done') +
140
+ NL +
141
+ t('compact.report', {
142
+ chars: summary.length,
143
+ tokens: beforeTokens === null
144
+ ? t('common.unknown')
145
+ : String(beforeTokens),
146
+ }));
147
+ }
148
+ if (ui && !quiet) {
149
+ ui.printAbove(NL +
150
+ '--- compacted context ---' +
151
+ NL +
152
+ summary +
153
+ NL +
154
+ '--- end ---' +
155
+ NL);
156
+ }
157
+ return {
158
+ ok: true,
159
+ chatId: newChatId,
160
+ summaryChars: summary.length,
161
+ beforeTokens,
162
+ };
163
+ }
164
+ catch (e) {
165
+ log(e.message);
166
+ return {
167
+ ok: false,
168
+ chatId: currentChatId,
169
+ summaryChars: 0,
170
+ beforeTokens,
171
+ error: e.message,
172
+ };
173
+ }
174
+ finally {
175
+ if (ui && !skipUiLock)
176
+ ui.unlock();
177
+ }
178
+ }
@@ -105,11 +105,26 @@ export function runConfigMenu(opts) {
105
105
  output.write(lines.join('\n') + '\n');
106
106
  lastRows = lines.reduce((n, l) => n + rowsOf(l), 0);
107
107
  };
108
- const cleanup = () => {
108
+ // Erase the rendered menu block from the screen. Used on exit so the config
109
+ // list does not stay in the terminal history / scrollback (the operator
110
+ // asked for the menu to disappear after Esc).
111
+ const clearBlock = () => {
112
+ if (lastRows > 0) {
113
+ output.write(ESC + '[' + lastRows + 'A');
114
+ for (let i = 0; i < lastRows; i++) {
115
+ output.write(ESC + '[2K' + ESC + '[1B');
116
+ }
117
+ output.write(ESC + '[' + lastRows + 'A');
118
+ lastRows = 0;
119
+ }
120
+ };
121
+ const cleanup = (clearOnExit = false) => {
109
122
  if (exited)
110
123
  return;
111
124
  exited = true;
112
125
  input.removeListener('data', onData);
126
+ if (clearOnExit)
127
+ clearBlock();
113
128
  if (input.setRawMode)
114
129
  input.setRawMode(wasRaw || false);
115
130
  output.write(ESC + '[?25h'); // restore the cursor
@@ -167,13 +182,14 @@ export function runConfigMenu(opts) {
167
182
  break;
168
183
  }
169
184
  if (ch === ESC) {
185
+ // Cancel the edit (do NOT exit the menu) — Esc in the field is
186
+ // "abandon this value", not "close the config".
170
187
  editing = false;
171
188
  editBuf = '';
172
189
  break;
173
190
  }
174
191
  if (ch === '\x03') {
175
- cleanup();
176
- output.write('\n');
192
+ cleanup(true);
177
193
  return;
178
194
  }
179
195
  if (ch === '\x7f' || ch === '\b') {
@@ -205,8 +221,9 @@ export function runConfigMenu(opts) {
205
221
  }
206
222
  const ch = s[i];
207
223
  if (ch === 'q' || ch === 'Q' || ch === '\x03' || ch === ESC) {
208
- cleanup();
209
- output.write('\n');
224
+ // Erase the menu block from the screen so the config list does not
225
+ // linger in the terminal history after Esc.
226
+ cleanup(true);
210
227
  return;
211
228
  }
212
229
  if (ch === 'k') {
package/dist/config.js CHANGED
@@ -47,6 +47,11 @@ export const DEFAULTS = {
47
47
  maxIncompleteRetries: 4,
48
48
  incompleteWaitMs: 2000,
49
49
  autoContinue: true,
50
+ continueMinGapMs: 1500,
51
+ askDeadlineMs: 240000,
52
+ maxAfterToolRetries: 6,
53
+ autoCompact: false,
54
+ autoCompactPct: 95,
50
55
  resendPromptOnResume: false,
51
56
  deepThinking: false,
52
57
  webSearch: true,
@@ -58,6 +63,7 @@ export const DEFAULTS = {
58
63
  },
59
64
  ui: {
60
65
  locale: DEFAULT_LOCALE,
66
+ contextLimit: 1_000_000,
61
67
  },
62
68
  };
63
69
  export function loadConfig() {
@@ -265,6 +271,44 @@ export const CONFIG_SCHEMA = [
265
271
  labelKey: 'cfg.f.browser_autoContinue',
266
272
  groupKey: 'cfg.group.browser',
267
273
  },
274
+ {
275
+ path: 'browser.continueMinGapMs',
276
+ type: 'number',
277
+ min: 0,
278
+ max: 600000,
279
+ labelKey: 'cfg.f.browser_continueMinGapMs',
280
+ groupKey: 'cfg.group.browser',
281
+ },
282
+ {
283
+ path: 'browser.askDeadlineMs',
284
+ type: 'number',
285
+ min: 30000,
286
+ max: 1800000,
287
+ labelKey: 'cfg.f.browser_askDeadlineMs',
288
+ groupKey: 'cfg.group.browser',
289
+ },
290
+ {
291
+ path: 'browser.maxAfterToolRetries',
292
+ type: 'number',
293
+ min: 0,
294
+ max: 50,
295
+ labelKey: 'cfg.f.browser_maxAfterToolRetries',
296
+ groupKey: 'cfg.group.browser',
297
+ },
298
+ {
299
+ path: 'browser.autoCompact',
300
+ type: 'boolean',
301
+ labelKey: 'cfg.f.browser_autoCompact',
302
+ groupKey: 'cfg.group.browser',
303
+ },
304
+ {
305
+ path: 'browser.autoCompactPct',
306
+ type: 'number',
307
+ min: 50,
308
+ max: 100,
309
+ labelKey: 'cfg.f.browser_autoCompactPct',
310
+ groupKey: 'cfg.group.browser',
311
+ },
268
312
  {
269
313
  path: 'browser.resendPromptOnResume',
270
314
  type: 'boolean',
@@ -283,6 +327,14 @@ export const CONFIG_SCHEMA = [
283
327
  labelKey: 'cfg.f.browser_webSearch',
284
328
  groupKey: 'cfg.group.browser',
285
329
  },
330
+ {
331
+ path: 'ui.contextLimit',
332
+ type: 'number',
333
+ min: 10_000,
334
+ max: 10_000_000,
335
+ labelKey: 'cfg.f.ui_contextLimit',
336
+ groupKey: 'cfg.group.ui',
337
+ },
286
338
  ];
287
339
  export function getConfigField(path) {
288
340
  return CONFIG_SCHEMA.find((f) => f.path === path);
package/dist/confirm.js CHANGED
@@ -1,18 +1,30 @@
1
1
  import * as readlinePromises from 'readline/promises';
2
2
  import { theme } from './theme.js';
3
3
  import { unifiedDiff, colorDiff } from './diff.js';
4
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
4
5
  export class ConfirmManager {
5
6
  settings;
6
7
  alwaysConfirm;
7
8
  allowedForSession;
8
- constructor({ config } = {}) {
9
+ locale;
10
+ constructor({ config, locale = DEFAULT_LOCALE, } = {}) {
9
11
  const c = config || {};
12
+ this.locale = locale;
10
13
  this.settings = {
11
14
  write: c.write !== false,
12
15
  edit: c.edit !== false,
13
16
  bash: c.bash !== false,
14
17
  };
15
- this.alwaysConfirm = (c.alwaysConfirm || []).map((p) => new RegExp(p, 'i'));
18
+ // An invalid operator-supplied regex must NEVER crash the run. Fall back to
19
+ // a literal substring match (case-insensitive) for that pattern.
20
+ this.alwaysConfirm = (c.alwaysConfirm || []).map((p) => {
21
+ try {
22
+ return new RegExp(p, 'i');
23
+ }
24
+ catch {
25
+ return new RegExp(escapeRegExp(p), 'i');
26
+ }
27
+ });
16
28
  this.allowedForSession = new Set();
17
29
  }
18
30
  _matchesAlwaysConfirm(command) {
@@ -28,21 +40,27 @@ export class ConfirmManager {
28
40
  return false;
29
41
  return true;
30
42
  }
43
+ // Hot-update the language (from /config lang). The manager holds the locale
44
+ // so its prompt follows the interface language without a restart.
45
+ setLocale(locale) {
46
+ this.locale = locale;
47
+ }
31
48
  async ask({ kind, target, preview = null, message, }) {
32
49
  if (!this.shouldAsk(kind, target ?? null))
33
50
  return true;
34
51
  if (preview) {
35
52
  console.log(preview);
36
53
  }
37
- const label = message || `Разрешить ${kind}?`;
54
+ const t = translate(this.locale);
55
+ const label = message || t('confirm.ask_label') + ' ' + kind + '?';
38
56
  const question = theme.warn(`${label} `) +
39
57
  theme.system('[') +
40
58
  theme.assistant('y') +
41
- theme.system(' = да, ') +
59
+ theme.system(' = ' + t('common.yes') + ', ') +
42
60
  theme.error('n') +
43
- theme.system(' = нет, ') +
61
+ theme.system(' = ' + t('common.no') + ', ') +
44
62
  theme.user('a') +
45
- theme.system(' = всегда для этого типа] ');
63
+ theme.system(' = ' + t('confirm.hint') + '] ');
46
64
  const answer = await this._prompt(question);
47
65
  if (answer === 'a') {
48
66
  if (kind !== 'bash' || !this._matchesAlwaysConfirm(target ?? '')) {
@@ -75,6 +93,12 @@ export class ConfirmManager {
75
93
  }
76
94
  }
77
95
  }
96
+ // Escape a string so it can be used as a literal regex (used when an
97
+ // operator-supplied alwaysConfirm pattern is invalid and we fall back to a
98
+ // substring match).
99
+ function escapeRegExp(s) {
100
+ return s.replace(/[.*+?^${}()|[\]\\]/g, (m) => '\\' + m);
101
+ }
78
102
  export function formatDiffPreview(oldText, newText, { label } = {}) {
79
103
  const diff = unifiedDiff(oldText, newText, { label });
80
104
  return colorDiff(diff);
package/dist/context.js CHANGED
@@ -25,9 +25,10 @@ async function readIfFile(p, budget) {
25
25
  if (!st.isFile())
26
26
  return null;
27
27
  const raw = await fs.readFile(p, 'utf-8');
28
+ // An EXISTING but empty file is still present: keep it so the prompt
29
+ // shows an (empty) AGENTS.md/MEMORY.md section instead of silently
30
+ // dropping the file. Only a read failure returns null.
28
31
  const content = clipIf(path.basename(p), raw, budget);
29
- if (!content)
30
- return null;
31
32
  return { path: p, content };
32
33
  }
33
34
  catch {
package/dist/gitTools.js CHANGED
@@ -177,8 +177,9 @@ export function createGitTools(workdir) {
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
- const hasStaged = !/^\s*/.test(staged) &&
181
- staged.trim() !== '(command produced no output)';
180
+ const stagedTrimmed = staged.trim();
181
+ const hasStaged = stagedTrimmed !== '' &&
182
+ stagedTrimmed !== '(command produced no output)';
182
183
  const addResult = hasStaged
183
184
  ? '(index is not empty — add -A skipped)'
184
185
  : await runGit('git add -A', workdir, 20_000);