zames_pro 2.29.16 → 2.32.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/index.js CHANGED
@@ -16,7 +16,8 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
16
16
  import { Transcript } from './transcript.js';
17
17
  import { UndoStore } from './undo.js';
18
18
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
19
- import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, trimRestoredMessages, RESTORED_HISTORY_LIMIT, buildCompactPrompt, buildCompactCarryover, formatRestoredHistory, isUsableCompactSummary, } from './commands.js';
19
+ import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, isSlashCommand, ctrlCEscalation, } from './commands.js';
20
+ import { performCompact } from './compact.js';
20
21
  import { renderMarkdown } from './markdown.js';
21
22
  import { closeWeb } from './web.js';
22
23
  import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
@@ -57,8 +58,8 @@ function getPositional() {
57
58
  }
58
59
  // /compact asks the OLD chat for a summary. If browser.ask() exhausts its own
59
60
  // rate-limit budget, aborting the whole compaction used to force the operator
60
- // to start over. We retry the summary call a few times instead.
61
- const COMPACT_SUMMARY_ATTEMPTS = 3;
61
+ // to start over. We retry the summary call a few times instead (inside
62
+ // performCompact()).
62
63
  // How many restored messages the LOCAL fallback summary keeps when the model
63
64
  // could not produce one (a rate limit / server error must not lose the
64
65
  // compaction).
@@ -287,6 +288,7 @@ ${theme.bold(t('help.commands'))}
287
288
  ${t('help.cmd.add_dir')}
288
289
  ${t('help.cmd.review')}
289
290
  ${t('help.cmd.compact')}
291
+ ${t('help.cmd.queue')}
290
292
  ${t('help.cmd.config')}
291
293
  ${t('help.cmd.lang')}
292
294
  ${t('help.cmd.debug_dom')}
@@ -343,6 +345,7 @@ const SLASH_COMMANDS = [
343
345
  { name: '/add-dir', key: 'help.cmd.add_dir' },
344
346
  { name: '/review', key: 'help.cmd.review' },
345
347
  { name: '/compact', key: 'help.cmd.compact' },
348
+ { name: '/queue', key: 'help.cmd.queue' },
346
349
  { name: '/config', key: 'help.cmd.config' },
347
350
  { name: '/skills', key: 'help.cmd.skills' },
348
351
  { name: '/memory', key: 'help.cmd.memory' },
@@ -844,7 +847,7 @@ async function resolveWorkdir() {
844
847
  const dir = explicitDir ? path.resolve(explicitDir) : process.cwd();
845
848
  const stat = await fs.stat(dir).catch(() => null);
846
849
  if (!stat || !stat.isDirectory()) {
847
- throw new Error('Не директория: ' + dir);
850
+ throw new Error(t('msg.not_dir', { v: dir }));
848
851
  }
849
852
  return dir;
850
853
  }
@@ -903,6 +906,16 @@ async function printRestoredHistory(browser, ui, chatId = null) {
903
906
  const restoredTokens = browser.getLastTokenUsage();
904
907
  if (typeof restoredTokens === 'number') {
905
908
  out(theme.dim(t('chats.history_tokens', { v: String(restoredTokens) })));
909
+ // Warn when the restored chat is near the context limit, and suggest
910
+ // /compact. The threshold mirrors the auto-compact one so the operator
911
+ // sees the same signal whether or not auto-compact is enabled.
912
+ const limit = config.ui.contextLimit;
913
+ if (limit > 0) {
914
+ const pct = (restoredTokens / limit) * 100;
915
+ if (pct >= 80) {
916
+ out(theme.warn(t('chats.history_near_limit', { pct: String(Math.round(pct)) })));
917
+ }
918
+ }
906
919
  }
907
920
  for (const m of messages) {
908
921
  if (m.role === 'user') {
@@ -922,7 +935,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
922
935
  }
923
936
  // ---------- task runner ----------
924
937
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
925
- const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, } = opts;
938
+ const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, } = opts;
926
939
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
927
940
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
928
941
  // mode (pipes) — a regular spinner + watchInput.
@@ -971,6 +984,11 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
971
984
  // the whole pre-send phase (chat creation, throttle wait) with no
972
985
  // generation in flight.
973
986
  while (true) {
987
+ // T7: per-task summary (duration + number of tool calls). Counts tools
988
+ // via the UI callback and reports ONE dim line after the task, so the
989
+ // operator sees how long it took and how much work happened.
990
+ const taskStart = Date.now();
991
+ let taskTools = 0;
974
992
  const outcome = await mod.runAgentLoop({
975
993
  browser,
976
994
  tools,
@@ -984,13 +1002,27 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
984
1002
  onThinking: () => ui.thinking(),
985
1003
  onSendPause: (seconds) => ui.sendPause(seconds),
986
1004
  onNotice: (msg) => ui.warning(msg),
987
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
1005
+ onToolCall: (name, toolArgs) => {
1006
+ taskTools++;
1007
+ ui.toolCall(name, toolArgs);
1008
+ },
988
1009
  onToolResult: (result) => ui.toolResult(result),
989
1010
  onAssistantMessage: (msg) => ui.assistant(msg),
990
1011
  onWarning: (msg) => ui.warning(msg),
991
1012
  onChatReady,
992
1013
  debugLog: debug,
993
1014
  locale: currentLocale,
1015
+ askDeadlineMs: config.browser.askDeadlineMs,
1016
+ maxAfterToolRetries: config.browser.maxAfterToolRetries,
1017
+ // Auto-compact between tool calls when the context nears the
1018
+ // window limit. The callback is provided by the caller (runTask) so it
1019
+ // can refresh the outer currentChatId. It runs in the SAME execution
1020
+ // context (this task owns the browser send loop) and the loop awaits
1021
+ // it, so it serializes with the throttle.
1022
+ onAutoCompact,
1023
+ autoCompactPct,
1024
+ contextLimit,
1025
+ getTokenUsage,
994
1026
  });
995
1027
  // The loop may end WITHOUT a model answer: an exhausted iteration
996
1028
  // limit or an ask() watchdog (the model stopped responding). In that
@@ -1002,6 +1034,16 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1002
1034
  ui.warning(outcome);
1003
1035
  transcript?.log('agent_no_answer', { outcome });
1004
1036
  }
1037
+ // T7: one dim summary line per task (duration + tool calls). Printed
1038
+ // even on an abort, so the operator sees what happened.
1039
+ const summary = theme.dim(t('msg.task_summary', {
1040
+ sec: ((Date.now() - taskStart) / 1000).toFixed(1),
1041
+ tools: String(taskTools),
1042
+ }));
1043
+ if (editor)
1044
+ editor.printAbove(summary);
1045
+ else
1046
+ console.log(summary);
1005
1047
  // Aborted (Esc/Ctrl+C) — we don't start the next tasks from the queue
1006
1048
  // and clear it, so "stop" really stops everything.
1007
1049
  if (browser._stopped) {
@@ -1010,18 +1052,34 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1010
1052
  }
1011
1053
  if (!queue.length)
1012
1054
  break;
1013
- const queued = queue.shift() ?? { text: '' };
1055
+ // Merge the LEADING plain-text messages into ONE batch (fewer sends ->
1056
+ // less rate-limit risk and the operator's thoughts arrive together). We
1057
+ // stop at the first slash-command: it must NOT be sent as a task — it is
1058
+ // handled by the main loop, so we leave it (and anything after it) in
1059
+ // the queue and break out of this task's drain loop.
1060
+ let batchLen = 0;
1061
+ while (batchLen < queue.length && !isSlashCommand(queue[batchLen].text)) {
1062
+ batchLen++;
1063
+ }
1064
+ if (batchLen === 0)
1065
+ break;
1066
+ const batch = queue.splice(0, batchLen);
1067
+ const merged = mergeMessages(batch);
1068
+ if (!merged.text.trim())
1069
+ break;
1014
1070
  ui.stop();
1015
- if (editor) {
1016
- editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
1017
- }
1018
- else {
1019
- console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
1020
- }
1021
- transcript?.log('queued_task', { task: queued.text });
1071
+ const banner = batch.length === 1
1072
+ ? theme.user(t('msg.from_queue')) + theme.assistant(batch[0].text)
1073
+ : theme.user(t('msg.from_queue_batch', { n: batch.length })) +
1074
+ theme.assistant(t('msg.batch_joined', { n: batch.length }));
1075
+ if (editor)
1076
+ editor.printAbove(banner);
1077
+ else
1078
+ console.log(banner);
1079
+ transcript?.log('queued_task', { task: merged.text, count: batch.length });
1022
1080
  next = {
1023
- text: queued.text,
1024
- attachments: queued.attachments,
1081
+ text: merged.text,
1082
+ attachments: merged.attachments,
1025
1083
  freshChat: false,
1026
1084
  sendSystemPrompt: false,
1027
1085
  };
@@ -1200,6 +1258,9 @@ async function main() {
1200
1258
  const pendingQueue = [];
1201
1259
  // Show the "no clipboard image" hint only once per session.
1202
1260
  let clipboardWarned = false;
1261
+ // Timestamp of the last Ctrl+C while the agent was busy. Two presses
1262
+ // within 2s escalate from "abort the running tool" to "stop the whole run".
1263
+ let lastCtrlCAt = 0;
1203
1264
  // ---------- review mode state ----------
1204
1265
  // null — normal mode.
1205
1266
  // { snapDir, snapName, originalWorkdir } — we're inside a snapshot, the chat
@@ -1214,6 +1275,10 @@ async function main() {
1214
1275
  if (mcpPool)
1215
1276
  await mcpPool.close().catch(() => { });
1216
1277
  transcript.close();
1278
+ console.log(theme.dim(t('msg.exit_summary', {
1279
+ transcript: transcript.file || t('common.off'),
1280
+ chat: currentChatId || t('chats.not_created'),
1281
+ })));
1217
1282
  console.log(theme.system(String.fromCharCode(10) + t('msg.bye')));
1218
1283
  process.exit(0);
1219
1284
  });
@@ -1228,7 +1293,9 @@ async function main() {
1228
1293
  const last = loadLastSession(currentWorkdir);
1229
1294
  if (last && last.id) {
1230
1295
  resumeId = last.id;
1231
- console.log(theme.system(`Восстанавливаю сессию ${last.id}${last.title ? ` (${last.title})` : ''}...`));
1296
+ console.log(theme.system(t('msg.resuming', {
1297
+ id: last.id + (last.title ? ` (${last.title})` : ''),
1298
+ })));
1232
1299
  }
1233
1300
  }
1234
1301
  if (resumeId) {
@@ -1286,6 +1353,8 @@ async function main() {
1286
1353
  // The editor pulls the number on every render, so it follows the live
1287
1354
  // DeepSeek counter (accumulated_token_usage) without a polling timer.
1288
1355
  ed.onContextQuery = () => browser.getLastTokenUsage();
1356
+ // Use the configured context window for the fill percentage/color.
1357
+ ed.setContextLimit(config.ui.contextLimit);
1289
1358
  // Toggle icons (🧠 deep thinking, 🌐 web search) before the context
1290
1359
  // counter. The editor pulls the live state on every render.
1291
1360
  ed.onToggleQuery = () => browser.getToggleStatesSync();
@@ -1304,6 +1373,7 @@ async function main() {
1304
1373
  ed.printAbove(theme.system(t('msg.attached_image', {
1305
1374
  marker: att.marker,
1306
1375
  size: formatSize(image.data.length),
1376
+ path: p,
1307
1377
  })));
1308
1378
  return att;
1309
1379
  }
@@ -1326,6 +1396,7 @@ async function main() {
1326
1396
  marker: att.marker,
1327
1397
  name,
1328
1398
  size: formatSize(data.length),
1399
+ path: filePath,
1329
1400
  })));
1330
1401
  return att;
1331
1402
  };
@@ -1349,6 +1420,7 @@ async function main() {
1349
1420
  marker: att.marker,
1350
1421
  name: nm,
1351
1422
  size: formatSize(data.length),
1423
+ path: fp,
1352
1424
  }) + theme.dim(' (windows-clipboard)')));
1353
1425
  return att;
1354
1426
  }
@@ -1374,6 +1446,7 @@ async function main() {
1374
1446
  ed.printAbove(theme.system(t('msg.attached_image', {
1375
1447
  marker: att.marker,
1376
1448
  size: formatSize(res.data.length),
1449
+ path: p,
1377
1450
  }) + theme.dim(' (' + res.via + ')')));
1378
1451
  return att;
1379
1452
  };
@@ -1399,8 +1472,22 @@ async function main() {
1399
1472
  };
1400
1473
  ed.onCtrlC = () => {
1401
1474
  if (ed.busy) {
1402
- ed.printAbove(theme.warn(t('msg.abort_ctrlc_short')));
1403
- browser.stopGeneration().catch(() => { });
1475
+ // A running tool can be long (npm test, a big build). The FIRST
1476
+ // Ctrl+C aborts just the TOOL (sets browser._abort, which the tool's
1477
+ // AbortSignal follows and which makes the loop drop the tool result).
1478
+ // A SECOND Ctrl+C within 2s is an explicit "stop the whole run"
1479
+ // (sets _stopped, which also halts the queue). This makes the two
1480
+ // intents distinguishable instead of one press doing everything.
1481
+ const now = Date.now();
1482
+ if (ctrlCEscalation(now - lastCtrlCAt) === 'run') {
1483
+ ed.printAbove(theme.warn(t('msg.abort_ctrlc_short')));
1484
+ browser.stopGeneration().catch(() => { });
1485
+ }
1486
+ else {
1487
+ ed.printAbove(theme.warn(t('msg.abort_tool_short')));
1488
+ browser._abort = true;
1489
+ }
1490
+ lastCtrlCAt = now;
1404
1491
  }
1405
1492
  else {
1406
1493
  // Not busy — exit. We wake takeInput() so the loop finishes.
@@ -1480,6 +1567,15 @@ async function main() {
1480
1567
  if (editor)
1481
1568
  editor.refreshStatus();
1482
1569
  }
1570
+ // The context window size feeds the status-bar percentage and its color.
1571
+ // Apply it to the live editor immediately (the value is a plain number,
1572
+ // no restart needed).
1573
+ if (path === 'ui.contextLimit' && typeof value === 'number') {
1574
+ if (editor) {
1575
+ editor.setContextLimit(value);
1576
+ editor.refreshStatus();
1577
+ }
1578
+ }
1483
1579
  }
1484
1580
  function configSetRaw(field, raw) {
1485
1581
  const value = validateConfigValue(field, raw);
@@ -1700,7 +1796,6 @@ async function main() {
1700
1796
  continue;
1701
1797
  }
1702
1798
  if (['/new', '/clear', 'new'].includes(lower)) {
1703
- console.log(theme.system(t('msg.new_chat')));
1704
1799
  if (editor)
1705
1800
  editor.lock(t('msg.input_locked'));
1706
1801
  try {
@@ -1710,6 +1805,10 @@ async function main() {
1710
1805
  currentChatId = await browser.getCurrentChatId();
1711
1806
  saveLastChat(currentChatId, currentWorkdir);
1712
1807
  transcript.log('new_chat');
1808
+ // Start the new chat on a clean screen (like /clear in a shell).
1809
+ if (editor)
1810
+ editor.clearScreen();
1811
+ console.log(theme.system(t('msg.new_chat')));
1713
1812
  console.log(theme.system(t('msg.new_chat_ok') + String.fromCharCode(10)));
1714
1813
  }
1715
1814
  catch (e) {
@@ -1852,7 +1951,7 @@ async function main() {
1852
1951
  await mod.selfList({ config });
1853
1952
  }
1854
1953
  catch (e) {
1855
- console.error(theme.error('Ошибка:'), e.message);
1954
+ console.error(theme.error(t('msg.error')), e.message);
1856
1955
  }
1857
1956
  continue;
1858
1957
  }
@@ -1866,7 +1965,7 @@ async function main() {
1866
1965
  await mod.selfDiff({ config, name });
1867
1966
  }
1868
1967
  catch (e) {
1869
- console.error(theme.error('Ошибка:'), e.message);
1968
+ console.error(theme.error(t('msg.error')), e.message);
1870
1969
  }
1871
1970
  continue;
1872
1971
  }
@@ -1880,7 +1979,7 @@ async function main() {
1880
1979
  await mod.selfApply({ config, name });
1881
1980
  }
1882
1981
  catch (e) {
1883
- console.error(theme.error('Ошибка:'), e.message);
1982
+ console.error(theme.error(t('msg.error')), e.message);
1884
1983
  }
1885
1984
  continue;
1886
1985
  }
@@ -1894,7 +1993,7 @@ async function main() {
1894
1993
  lastChats = await browser.listChats(30);
1895
1994
  spin.stop();
1896
1995
  if (!lastChats.length) {
1897
- console.log(theme.system('Чатов не найдено. Возможно, сайдбар свёрнут или селекторы устарели.'));
1996
+ console.log(theme.system(t('chats.none_hint')));
1898
1997
  }
1899
1998
  else {
1900
1999
  console.log(theme.system(t('chats.recent')));
@@ -1935,7 +2034,6 @@ async function main() {
1935
2034
  console.error(theme.error(t('chats.no_n', { n, total: lastChats.length })));
1936
2035
  continue;
1937
2036
  }
1938
- console.log(theme.system(t('chats.opening', { v: pick.title })));
1939
2037
  if (editor)
1940
2038
  editor.lock(t('msg.input_locked'));
1941
2039
  try {
@@ -1945,12 +2043,14 @@ async function main() {
1945
2043
  sendSystemPromptNext = promptOnResume();
1946
2044
  saveLastChat(pick.id, currentWorkdir, pick.title);
1947
2045
  transcript.log('resume_chat', { id: pick.id, title: pick.title });
2046
+ // Resume on a clean screen so the restored dialogue is readable.
2047
+ if (editor)
2048
+ editor.clearScreen();
2049
+ console.log(theme.system(t('chats.opening', { v: pick.title })));
1948
2050
  console.log(theme.assistant(t('msg.chat_opened')) +
1949
2051
  theme.system(sendSystemPromptNext
1950
- ? String.fromCharCode(10) +
1951
- ' Системный промпт будет переслан на следующей задаче.\n'
1952
- : String.fromCharCode(10) +
1953
- ' Системный промпт НЕ пересылается (он уже в начале чата). Включить: /config set browser.resendPromptOnResume true.\n'));
2052
+ ? String.fromCharCode(10) + t('chats.prompt_will_resend')
2053
+ : String.fromCharCode(10) + t('chats.prompt_no_resend')));
1954
2054
  await printRestoredHistory(browser, editor, pick.id);
1955
2055
  }
1956
2056
  catch (e) {
@@ -2000,6 +2100,8 @@ async function main() {
2000
2100
  if (editor)
2001
2101
  editor.lock(t('msg.input_locked'));
2002
2102
  try {
2103
+ if (editor)
2104
+ editor.clearScreen();
2003
2105
  console.log(theme.system(t('msg.opening_chat', { id })));
2004
2106
  await browser.openChat(id);
2005
2107
  currentChatId = id;
@@ -2007,7 +2109,7 @@ async function main() {
2007
2109
  sendSystemPromptNext = promptOnResume();
2008
2110
  saveLastChat(id, currentWorkdir);
2009
2111
  transcript.log('resume_chat', { id });
2010
- console.log(theme.assistant('Чат открыт.') +
2112
+ console.log(theme.assistant(t('msg.chat_opened')) +
2011
2113
  theme.system(String.fromCharCode(10)));
2012
2114
  await printRestoredHistory(browser, editor, id);
2013
2115
  }
@@ -2024,6 +2126,37 @@ async function main() {
2024
2126
  console.log(theme.system(currentWorkdir));
2025
2127
  continue;
2026
2128
  }
2129
+ if (lower === '/queue' || lower.startsWith('/queue ')) {
2130
+ const sub = trimmed.slice('/queue'.length).trim().toLowerCase();
2131
+ if (sub === 'clear' || sub === 'c' || sub === 'clean') {
2132
+ const n = pendingQueue.length;
2133
+ pendingQueue.length = 0;
2134
+ console.log(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
2135
+ continue;
2136
+ }
2137
+ if (sub && sub !== 'list' && sub !== 'ls') {
2138
+ console.error(theme.error(t('msg.queue_usage')));
2139
+ continue;
2140
+ }
2141
+ if (!pendingQueue.length) {
2142
+ console.log(theme.dim(t('msg.queue_empty')));
2143
+ continue;
2144
+ }
2145
+ console.log(theme.system(t('msg.queue_title')));
2146
+ pendingQueue.forEach((m, i) => {
2147
+ const one = String(m.text || '')
2148
+ .replace(/\s+/g, ' ')
2149
+ .trim();
2150
+ const shown = one.length > 80 ? one.slice(0, 80) + ' …' : one;
2151
+ const att = m.attachments?.length
2152
+ ? ' [+' + m.attachments.length + ']'
2153
+ : '';
2154
+ console.log(theme.system(' ') +
2155
+ theme.assistant(t('msg.queue_item', { n: i + 1, text: shown + att })));
2156
+ });
2157
+ console.log(theme.dim(t('msg.queue_cleared_hint')));
2158
+ continue;
2159
+ }
2027
2160
  if (lower === '/mcp') {
2028
2161
  if (!mcpPool) {
2029
2162
  console.log(theme.dim(t('mcp.none')));
@@ -2179,7 +2312,7 @@ async function main() {
2179
2312
  console.error(theme.error(' ' + e));
2180
2313
  }
2181
2314
  else {
2182
- console.log(theme.assistant(`Перезагружено модулей: ${count}. Браузер и чат не тронуты.`));
2315
+ console.log(theme.assistant(t('msg.reloaded', { n: count })));
2183
2316
  }
2184
2317
  }
2185
2318
  catch (e) {
@@ -2328,6 +2461,11 @@ async function main() {
2328
2461
  mcpTools: mcpStatus.toolCount,
2329
2462
  transcriptOk: !!transcript.file,
2330
2463
  authSaved: authMarkerExists(),
2464
+ contextLimit: config.ui.contextLimit,
2465
+ hasOrigin: gitOk
2466
+ ? (await runGit('git remote', currentWorkdir, 5000)).includes('origin')
2467
+ : undefined,
2468
+ minSendIntervalMs: config.browser.minSendIntervalMs,
2331
2469
  })));
2332
2470
  continue;
2333
2471
  }
@@ -2359,153 +2497,27 @@ async function main() {
2359
2497
  // Compaction: ask DeepSeek (in the CURRENT chat) to compress the
2360
2498
  // history into a handover summary, then start a NEW chat, resend the
2361
2499
  // system prompt and post the summary as the carried-over context.
2362
- // This keeps the model working with a small context while nothing is
2363
- // lost: the summary plus the system prompt are all the new chat needs.
2364
- if (!currentChatId) {
2365
- currentChatId = await browser.getCurrentChatId();
2366
- }
2367
- if (!currentChatId) {
2368
- console.error(theme.warn(t('compact.no_chat')));
2369
- continue;
2370
- }
2371
- const beforeTokens = browser.getLastTokenUsage();
2372
- if (editor)
2373
- editor.lock(t('msg.input_locked'));
2374
- try {
2375
- console.log(theme.system(t('compact.start')));
2376
- // 1) Ask the OLD chat to summarize itself. agent: true - this is a
2377
- // real back-and-forth, so the send throttle applies.
2378
- //
2379
- // The call is retried a few times: browser.ask() already retries the
2380
- // "Messages too frequent" rate limit internally, but if it exhausts
2381
- // its own budget the whole /compact used to abort and the operator
2382
- // had to start over. A transient server error must NOT lose the
2383
- // compaction, so we keep trying with a growing pause before giving up.
2384
- let summary = '';
2385
- let summaryErr = '';
2386
- for (let attempt = 1; attempt <= COMPACT_SUMMARY_ATTEMPTS; attempt++) {
2387
- // On a retry after an unusable answer we append an explicit
2388
- // "plain text only" note. In reasoning mode DeepSeek answered the
2389
- // /compact prompt with a Bash TOOL CALL ("gather the current state")
2390
- // instead of a summary — protocol noise that must NOT be carried
2391
- // into the new chat.
2392
- let prompt = buildCompactPrompt(currentLocale);
2393
- if (attempt > 1) {
2394
- prompt +=
2395
- currentLocale === 'en'
2396
- ? '\n\nIMPORTANT: reply with the summary as PLAIN TEXT only. Do NOT call any tools and do NOT output tool-call JSON/DSML.'
2397
- : '\n\nВАЖНО: ответь ТОЛЬКО текстом резюме. НЕ вызывай инструменты и НЕ выводи JSON/DSML вызова инструмента.';
2398
- }
2399
- let answer = '';
2400
- try {
2401
- answer = await browser.ask(prompt, {
2402
- agent: true,
2403
- timeout: Math.max(60_000, config.browser.answerTimeoutMs),
2404
- });
2405
- }
2406
- catch (e) {
2407
- summaryErr = e.message;
2408
- }
2409
- const trimmed = String(answer || '').trim();
2410
- // The user aborted (Esc) — do not keep retrying.
2411
- if (/^\(прервано пользователем\)$/.test(trimmed)) {
2412
- summaryErr = trimmed;
2413
- break;
2414
- }
2415
- if (!summaryErr && isUsableCompactSummary(trimmed)) {
2416
- summary = trimmed;
2417
- summaryErr = '';
2418
- break;
2419
- }
2420
- // The turn may have succeeded yet produced an unusable answer (a
2421
- // tool call, empty text). Record it and retry with the note.
2422
- if (!summaryErr)
2423
- summaryErr = trimmed || t('common.unknown');
2424
- if (attempt < COMPACT_SUMMARY_ATTEMPTS) {
2425
- console.error(theme.warn(t('compact.summary_retry', {
2426
- v: summaryErr,
2427
- attempt: String(attempt),
2428
- max: String(COMPACT_SUMMARY_ATTEMPTS),
2429
- })));
2430
- await new Promise((r) => setTimeout(r, 3000 * attempt));
2431
- }
2432
- }
2433
- // A model summary that never arrived (a server error, or only
2434
- // tool-call answers) must NOT lose the compaction: fall back to a
2435
- // LOCAL summary built from the restored chat history (the same
2436
- // displayable dialogue /resume prints).
2437
- if (!summary) {
2438
- let fallback = '';
2439
- try {
2440
- const all = currentChatId && browser.fetchChatMessages
2441
- ? await browser.fetchChatMessages(currentChatId).catch(() => [])
2442
- : [];
2443
- const msgs = trimRestoredMessages(all, 0);
2444
- if (msgs.length) {
2445
- fallback = formatRestoredHistory(msgs, {
2446
- limit: COMPACT_FALLBACK_LIMIT,
2447
- });
2448
- }
2449
- }
2450
- catch { }
2451
- fallback = String(fallback || '').trim();
2452
- if (fallback) {
2453
- summary = fallback;
2454
- console.error(theme.warn(t('compact.summary_fallback', {
2455
- v: String(COMPACT_FALLBACK_LIMIT),
2456
- })));
2457
- }
2458
- else {
2459
- console.error(theme.error(t('compact.summary_failed', {
2460
- v: summaryErr || t('common.unknown'),
2461
- })));
2462
- continue;
2463
- }
2464
- }
2465
- transcript.log('compact_summary', {
2466
- chars: summary.length,
2467
- beforeTokens,
2468
- });
2469
- // 2) New chat + system prompt + the summary as the first message.
2470
- await browser.newChat();
2471
- await browser.ask(mod.buildSystemPrompt({
2472
- workdir: currentWorkdir,
2473
- tools: mod.createTools(currentWorkdir, { undo }),
2474
- locale: currentLocale,
2475
- }), { timeout: 60_000, agent: false });
2476
- await browser.ask(buildCompactCarryover(summary, task ?? undefined), {
2477
- timeout: 60_000,
2478
- agent: false,
2479
- });
2480
- currentChatId = await browser.getCurrentChatId();
2500
+ // Shared with the AUTO-compact path via performCompact() so both
2501
+ // paths stay identical (same retries, same fallback, same resend).
2502
+ const res = await performCompact({
2503
+ browser,
2504
+ currentChatId,
2505
+ workdir: currentWorkdir,
2506
+ locale: currentLocale,
2507
+ task: task ?? undefined,
2508
+ transcript,
2509
+ ui: editor,
2510
+ buildSystemPrompt: mod.buildSystemPrompt,
2511
+ tools: mod.createTools(currentWorkdir, { undo }),
2512
+ answerTimeoutMs: config.browser.answerTimeoutMs,
2513
+ fallbackLimit: COMPACT_FALLBACK_LIMIT,
2514
+ });
2515
+ if (res.ok) {
2516
+ currentChatId = res.chatId;
2481
2517
  saveLastChat(currentChatId, currentWorkdir);
2482
2518
  freshChatNext = false;
2483
2519
  // The new chat already carries the system prompt and the context.
2484
2520
  sendSystemPromptNext = false;
2485
- console.log(theme.assistant(t('compact.done') +
2486
- String.fromCharCode(10) +
2487
- t('compact.report', {
2488
- chars: summary.length,
2489
- tokens: beforeTokens === null
2490
- ? t('common.unknown')
2491
- : String(beforeTokens),
2492
- })));
2493
- if (editor) {
2494
- editor.printAbove(theme.dim(String.fromCharCode(10) +
2495
- '--- compacted context ---' +
2496
- String.fromCharCode(10) +
2497
- summary +
2498
- String.fromCharCode(10) +
2499
- '--- end ---' +
2500
- String.fromCharCode(10)));
2501
- }
2502
- }
2503
- catch (e) {
2504
- console.error(theme.error(e.message));
2505
- }
2506
- finally {
2507
- if (editor)
2508
- editor.unlock();
2509
2521
  }
2510
2522
  continue;
2511
2523
  }
@@ -2653,6 +2665,36 @@ async function main() {
2653
2665
  saveLastChat(chatId, currentWorkdir);
2654
2666
  }
2655
2667
  },
2668
+ // Auto-compact between tool calls when the context nears the
2669
+ // window limit. Enabled via browser.autoCompact. It sends messages
2670
+ // from THIS execution context (the task owns the browser), and the
2671
+ // loop awaits it, so it serializes with the throttle.
2672
+ onAutoCompact: config.browser.autoCompact
2673
+ ? async () => {
2674
+ const res = await performCompact({
2675
+ browser,
2676
+ currentChatId,
2677
+ workdir: currentWorkdir,
2678
+ locale: currentLocale,
2679
+ task: taskText,
2680
+ transcript,
2681
+ ui: editor,
2682
+ buildSystemPrompt: mod.buildSystemPrompt,
2683
+ tools: mod.createTools(currentWorkdir, { undo }),
2684
+ answerTimeoutMs: config.browser.answerTimeoutMs,
2685
+ quiet: true,
2686
+ skipUiLock: true,
2687
+ });
2688
+ if (res.ok) {
2689
+ currentChatId = res.chatId;
2690
+ saveLastChat(res.chatId, currentWorkdir);
2691
+ }
2692
+ return res.ok ? res.chatId : null;
2693
+ }
2694
+ : null,
2695
+ autoCompactPct: config.browser.autoCompactPct,
2696
+ contextLimit: config.ui.contextLimit,
2697
+ getTokenUsage: () => browser.getLastTokenUsage(),
2656
2698
  }, inputAttachments);
2657
2699
  }
2658
2700
  finally {
@@ -2673,6 +2715,12 @@ async function main() {
2673
2715
  if (mcpPool)
2674
2716
  await mcpPool.close().catch(() => { });
2675
2717
  transcript.close();
2718
+ // One dim line so the operator can find the log and the session after
2719
+ // the run (previously the path was only available via /transcript).
2720
+ console.log(theme.dim(t('msg.exit_summary', {
2721
+ transcript: transcript.file || t('common.off'),
2722
+ chat: currentChatId || t('chats.not_created'),
2723
+ })));
2676
2724
  }
2677
2725
  main().catch((e) => {
2678
2726
  console.error(theme.error(t('msg.critical')), e.message);