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/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, } 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';
@@ -55,6 +56,14 @@ function getPositional() {
55
56
  }
56
57
  return positional;
57
58
  }
59
+ // /compact asks the OLD chat for a summary. If browser.ask() exhausts its own
60
+ // rate-limit budget, aborting the whole compaction used to force the operator
61
+ // to start over. We retry the summary call a few times instead (inside
62
+ // performCompact()).
63
+ // How many restored messages the LOCAL fallback summary keeps when the model
64
+ // could not produce one (a rate limit / server error must not lose the
65
+ // compaction).
66
+ const COMPACT_FALLBACK_LIMIT = 40;
58
67
  const config = loadConfig();
59
68
  // Current interface/agent language. Changed by the /config lang <ru|en> command.
60
69
  let currentLocale = isLocale(config.ui?.locale)
@@ -279,6 +288,7 @@ ${theme.bold(t('help.commands'))}
279
288
  ${t('help.cmd.add_dir')}
280
289
  ${t('help.cmd.review')}
281
290
  ${t('help.cmd.compact')}
291
+ ${t('help.cmd.queue')}
282
292
  ${t('help.cmd.config')}
283
293
  ${t('help.cmd.lang')}
284
294
  ${t('help.cmd.debug_dom')}
@@ -335,6 +345,7 @@ const SLASH_COMMANDS = [
335
345
  { name: '/add-dir', key: 'help.cmd.add_dir' },
336
346
  { name: '/review', key: 'help.cmd.review' },
337
347
  { name: '/compact', key: 'help.cmd.compact' },
348
+ { name: '/queue', key: 'help.cmd.queue' },
338
349
  { name: '/config', key: 'help.cmd.config' },
339
350
  { name: '/skills', key: 'help.cmd.skills' },
340
351
  { name: '/memory', key: 'help.cmd.memory' },
@@ -836,7 +847,7 @@ async function resolveWorkdir() {
836
847
  const dir = explicitDir ? path.resolve(explicitDir) : process.cwd();
837
848
  const stat = await fs.stat(dir).catch(() => null);
838
849
  if (!stat || !stat.isDirectory()) {
839
- throw new Error('Не директория: ' + dir);
850
+ throw new Error(t('msg.not_dir', { v: dir }));
840
851
  }
841
852
  return dir;
842
853
  }
@@ -895,6 +906,16 @@ async function printRestoredHistory(browser, ui, chatId = null) {
895
906
  const restoredTokens = browser.getLastTokenUsage();
896
907
  if (typeof restoredTokens === 'number') {
897
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
+ }
898
919
  }
899
920
  for (const m of messages) {
900
921
  if (m.role === 'user') {
@@ -914,7 +935,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
914
935
  }
915
936
  // ---------- task runner ----------
916
937
  async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
917
- 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;
918
939
  // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
919
940
  // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
920
941
  // mode (pipes) — a regular spinner + watchInput.
@@ -963,6 +984,11 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
963
984
  // the whole pre-send phase (chat creation, throttle wait) with no
964
985
  // generation in flight.
965
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;
966
992
  const outcome = await mod.runAgentLoop({
967
993
  browser,
968
994
  tools,
@@ -976,13 +1002,27 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
976
1002
  onThinking: () => ui.thinking(),
977
1003
  onSendPause: (seconds) => ui.sendPause(seconds),
978
1004
  onNotice: (msg) => ui.warning(msg),
979
- onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
1005
+ onToolCall: (name, toolArgs) => {
1006
+ taskTools++;
1007
+ ui.toolCall(name, toolArgs);
1008
+ },
980
1009
  onToolResult: (result) => ui.toolResult(result),
981
1010
  onAssistantMessage: (msg) => ui.assistant(msg),
982
1011
  onWarning: (msg) => ui.warning(msg),
983
1012
  onChatReady,
984
1013
  debugLog: debug,
985
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,
986
1026
  });
987
1027
  // The loop may end WITHOUT a model answer: an exhausted iteration
988
1028
  // limit or an ask() watchdog (the model stopped responding). In that
@@ -994,6 +1034,16 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
994
1034
  ui.warning(outcome);
995
1035
  transcript?.log('agent_no_answer', { outcome });
996
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);
997
1047
  // Aborted (Esc/Ctrl+C) — we don't start the next tasks from the queue
998
1048
  // and clear it, so "stop" really stops everything.
999
1049
  if (browser._stopped) {
@@ -1002,18 +1052,34 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
1002
1052
  }
1003
1053
  if (!queue.length)
1004
1054
  break;
1005
- 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;
1006
1070
  ui.stop();
1007
- if (editor) {
1008
- editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
1009
- }
1010
- else {
1011
- console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
1012
- }
1013
- 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 });
1014
1080
  next = {
1015
- text: queued.text,
1016
- attachments: queued.attachments,
1081
+ text: merged.text,
1082
+ attachments: merged.attachments,
1017
1083
  freshChat: false,
1018
1084
  sendSystemPrompt: false,
1019
1085
  };
@@ -1192,6 +1258,9 @@ async function main() {
1192
1258
  const pendingQueue = [];
1193
1259
  // Show the "no clipboard image" hint only once per session.
1194
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;
1195
1264
  // ---------- review mode state ----------
1196
1265
  // null — normal mode.
1197
1266
  // { snapDir, snapName, originalWorkdir } — we're inside a snapshot, the chat
@@ -1206,6 +1275,10 @@ async function main() {
1206
1275
  if (mcpPool)
1207
1276
  await mcpPool.close().catch(() => { });
1208
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
+ })));
1209
1282
  console.log(theme.system(String.fromCharCode(10) + t('msg.bye')));
1210
1283
  process.exit(0);
1211
1284
  });
@@ -1220,7 +1293,9 @@ async function main() {
1220
1293
  const last = loadLastSession(currentWorkdir);
1221
1294
  if (last && last.id) {
1222
1295
  resumeId = last.id;
1223
- 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
+ })));
1224
1299
  }
1225
1300
  }
1226
1301
  if (resumeId) {
@@ -1278,6 +1353,8 @@ async function main() {
1278
1353
  // The editor pulls the number on every render, so it follows the live
1279
1354
  // DeepSeek counter (accumulated_token_usage) without a polling timer.
1280
1355
  ed.onContextQuery = () => browser.getLastTokenUsage();
1356
+ // Use the configured context window for the fill percentage/color.
1357
+ ed.setContextLimit(config.ui.contextLimit);
1281
1358
  // Toggle icons (🧠 deep thinking, 🌐 web search) before the context
1282
1359
  // counter. The editor pulls the live state on every render.
1283
1360
  ed.onToggleQuery = () => browser.getToggleStatesSync();
@@ -1296,6 +1373,7 @@ async function main() {
1296
1373
  ed.printAbove(theme.system(t('msg.attached_image', {
1297
1374
  marker: att.marker,
1298
1375
  size: formatSize(image.data.length),
1376
+ path: p,
1299
1377
  })));
1300
1378
  return att;
1301
1379
  }
@@ -1318,6 +1396,7 @@ async function main() {
1318
1396
  marker: att.marker,
1319
1397
  name,
1320
1398
  size: formatSize(data.length),
1399
+ path: filePath,
1321
1400
  })));
1322
1401
  return att;
1323
1402
  };
@@ -1341,6 +1420,7 @@ async function main() {
1341
1420
  marker: att.marker,
1342
1421
  name: nm,
1343
1422
  size: formatSize(data.length),
1423
+ path: fp,
1344
1424
  }) + theme.dim(' (windows-clipboard)')));
1345
1425
  return att;
1346
1426
  }
@@ -1366,6 +1446,7 @@ async function main() {
1366
1446
  ed.printAbove(theme.system(t('msg.attached_image', {
1367
1447
  marker: att.marker,
1368
1448
  size: formatSize(res.data.length),
1449
+ path: p,
1369
1450
  }) + theme.dim(' (' + res.via + ')')));
1370
1451
  return att;
1371
1452
  };
@@ -1391,8 +1472,22 @@ async function main() {
1391
1472
  };
1392
1473
  ed.onCtrlC = () => {
1393
1474
  if (ed.busy) {
1394
- ed.printAbove(theme.warn(t('msg.abort_ctrlc_short')));
1395
- 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;
1396
1491
  }
1397
1492
  else {
1398
1493
  // Not busy — exit. We wake takeInput() so the loop finishes.
@@ -1472,6 +1567,15 @@ async function main() {
1472
1567
  if (editor)
1473
1568
  editor.refreshStatus();
1474
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
+ }
1475
1579
  }
1476
1580
  function configSetRaw(field, raw) {
1477
1581
  const value = validateConfigValue(field, raw);
@@ -1692,7 +1796,6 @@ async function main() {
1692
1796
  continue;
1693
1797
  }
1694
1798
  if (['/new', '/clear', 'new'].includes(lower)) {
1695
- console.log(theme.system(t('msg.new_chat')));
1696
1799
  if (editor)
1697
1800
  editor.lock(t('msg.input_locked'));
1698
1801
  try {
@@ -1702,6 +1805,10 @@ async function main() {
1702
1805
  currentChatId = await browser.getCurrentChatId();
1703
1806
  saveLastChat(currentChatId, currentWorkdir);
1704
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')));
1705
1812
  console.log(theme.system(t('msg.new_chat_ok') + String.fromCharCode(10)));
1706
1813
  }
1707
1814
  catch (e) {
@@ -1844,7 +1951,7 @@ async function main() {
1844
1951
  await mod.selfList({ config });
1845
1952
  }
1846
1953
  catch (e) {
1847
- console.error(theme.error('Ошибка:'), e.message);
1954
+ console.error(theme.error(t('msg.error')), e.message);
1848
1955
  }
1849
1956
  continue;
1850
1957
  }
@@ -1858,7 +1965,7 @@ async function main() {
1858
1965
  await mod.selfDiff({ config, name });
1859
1966
  }
1860
1967
  catch (e) {
1861
- console.error(theme.error('Ошибка:'), e.message);
1968
+ console.error(theme.error(t('msg.error')), e.message);
1862
1969
  }
1863
1970
  continue;
1864
1971
  }
@@ -1872,7 +1979,7 @@ async function main() {
1872
1979
  await mod.selfApply({ config, name });
1873
1980
  }
1874
1981
  catch (e) {
1875
- console.error(theme.error('Ошибка:'), e.message);
1982
+ console.error(theme.error(t('msg.error')), e.message);
1876
1983
  }
1877
1984
  continue;
1878
1985
  }
@@ -1886,7 +1993,7 @@ async function main() {
1886
1993
  lastChats = await browser.listChats(30);
1887
1994
  spin.stop();
1888
1995
  if (!lastChats.length) {
1889
- console.log(theme.system('Чатов не найдено. Возможно, сайдбар свёрнут или селекторы устарели.'));
1996
+ console.log(theme.system(t('chats.none_hint')));
1890
1997
  }
1891
1998
  else {
1892
1999
  console.log(theme.system(t('chats.recent')));
@@ -1927,7 +2034,6 @@ async function main() {
1927
2034
  console.error(theme.error(t('chats.no_n', { n, total: lastChats.length })));
1928
2035
  continue;
1929
2036
  }
1930
- console.log(theme.system(t('chats.opening', { v: pick.title })));
1931
2037
  if (editor)
1932
2038
  editor.lock(t('msg.input_locked'));
1933
2039
  try {
@@ -1937,12 +2043,14 @@ async function main() {
1937
2043
  sendSystemPromptNext = promptOnResume();
1938
2044
  saveLastChat(pick.id, currentWorkdir, pick.title);
1939
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 })));
1940
2050
  console.log(theme.assistant(t('msg.chat_opened')) +
1941
2051
  theme.system(sendSystemPromptNext
1942
- ? String.fromCharCode(10) +
1943
- ' Системный промпт будет переслан на следующей задаче.\n'
1944
- : String.fromCharCode(10) +
1945
- ' Системный промпт НЕ пересылается (он уже в начале чата). Включить: /config set browser.resendPromptOnResume true.\n'));
2052
+ ? String.fromCharCode(10) + t('chats.prompt_will_resend')
2053
+ : String.fromCharCode(10) + t('chats.prompt_no_resend')));
1946
2054
  await printRestoredHistory(browser, editor, pick.id);
1947
2055
  }
1948
2056
  catch (e) {
@@ -1992,6 +2100,8 @@ async function main() {
1992
2100
  if (editor)
1993
2101
  editor.lock(t('msg.input_locked'));
1994
2102
  try {
2103
+ if (editor)
2104
+ editor.clearScreen();
1995
2105
  console.log(theme.system(t('msg.opening_chat', { id })));
1996
2106
  await browser.openChat(id);
1997
2107
  currentChatId = id;
@@ -1999,7 +2109,7 @@ async function main() {
1999
2109
  sendSystemPromptNext = promptOnResume();
2000
2110
  saveLastChat(id, currentWorkdir);
2001
2111
  transcript.log('resume_chat', { id });
2002
- console.log(theme.assistant('Чат открыт.') +
2112
+ console.log(theme.assistant(t('msg.chat_opened')) +
2003
2113
  theme.system(String.fromCharCode(10)));
2004
2114
  await printRestoredHistory(browser, editor, id);
2005
2115
  }
@@ -2016,6 +2126,37 @@ async function main() {
2016
2126
  console.log(theme.system(currentWorkdir));
2017
2127
  continue;
2018
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
+ }
2019
2160
  if (lower === '/mcp') {
2020
2161
  if (!mcpPool) {
2021
2162
  console.log(theme.dim(t('mcp.none')));
@@ -2171,7 +2312,7 @@ async function main() {
2171
2312
  console.error(theme.error(' ' + e));
2172
2313
  }
2173
2314
  else {
2174
- console.log(theme.assistant(`Перезагружено модулей: ${count}. Браузер и чат не тронуты.`));
2315
+ console.log(theme.assistant(t('msg.reloaded', { n: count })));
2175
2316
  }
2176
2317
  }
2177
2318
  catch (e) {
@@ -2351,86 +2492,27 @@ async function main() {
2351
2492
  // Compaction: ask DeepSeek (in the CURRENT chat) to compress the
2352
2493
  // history into a handover summary, then start a NEW chat, resend the
2353
2494
  // system prompt and post the summary as the carried-over context.
2354
- // This keeps the model working with a small context while nothing is
2355
- // lost: the summary plus the system prompt are all the new chat needs.
2356
- if (!currentChatId) {
2357
- currentChatId = await browser.getCurrentChatId();
2358
- }
2359
- if (!currentChatId) {
2360
- console.error(theme.warn(t('compact.no_chat')));
2361
- continue;
2362
- }
2363
- const beforeTokens = browser.getLastTokenUsage();
2364
- if (editor)
2365
- editor.lock(t('msg.input_locked'));
2366
- try {
2367
- console.log(theme.system(t('compact.start')));
2368
- // 1) Ask the OLD chat to summarize itself. agent: true - this is a
2369
- // real back-and-forth, so the send throttle applies.
2370
- let summary = '';
2371
- try {
2372
- summary = await browser.ask(buildCompactPrompt(currentLocale), {
2373
- agent: true,
2374
- timeout: Math.max(60_000, config.browser.answerTimeoutMs),
2375
- });
2376
- }
2377
- catch (e) {
2378
- console.error(theme.error(t('compact.summary_failed', { v: e.message })));
2379
- continue;
2380
- }
2381
- summary = String(summary || '').trim();
2382
- // A model "answer" that is actually an error/abort sentinel is not a
2383
- // summary - do not carry it over.
2384
- if (!summary || /^\(прервано пользователем\)$/.test(summary)) {
2385
- console.error(theme.error(t('compact.summary_failed', {
2386
- v: summary || t('common.unknown'),
2387
- })));
2388
- continue;
2389
- }
2390
- transcript.log('compact_summary', {
2391
- chars: summary.length,
2392
- beforeTokens,
2393
- });
2394
- // 2) New chat + system prompt + the summary as the first message.
2395
- await browser.newChat();
2396
- await browser.ask(mod.buildSystemPrompt({
2397
- workdir: currentWorkdir,
2398
- tools: mod.createTools(currentWorkdir, { undo }),
2399
- locale: currentLocale,
2400
- }), { timeout: 60_000, agent: false });
2401
- await browser.ask(buildCompactCarryover(summary, task ?? undefined), {
2402
- timeout: 60_000,
2403
- agent: false,
2404
- });
2405
- currentChatId = await browser.getCurrentChatId();
2495
+ // Shared with the AUTO-compact path via performCompact() so both
2496
+ // paths stay identical (same retries, same fallback, same resend).
2497
+ const res = await performCompact({
2498
+ browser,
2499
+ currentChatId,
2500
+ workdir: currentWorkdir,
2501
+ locale: currentLocale,
2502
+ task: task ?? undefined,
2503
+ transcript,
2504
+ ui: editor,
2505
+ buildSystemPrompt: mod.buildSystemPrompt,
2506
+ tools: mod.createTools(currentWorkdir, { undo }),
2507
+ answerTimeoutMs: config.browser.answerTimeoutMs,
2508
+ fallbackLimit: COMPACT_FALLBACK_LIMIT,
2509
+ });
2510
+ if (res.ok) {
2511
+ currentChatId = res.chatId;
2406
2512
  saveLastChat(currentChatId, currentWorkdir);
2407
2513
  freshChatNext = false;
2408
2514
  // The new chat already carries the system prompt and the context.
2409
2515
  sendSystemPromptNext = false;
2410
- console.log(theme.assistant(t('compact.done') +
2411
- String.fromCharCode(10) +
2412
- t('compact.report', {
2413
- chars: summary.length,
2414
- tokens: beforeTokens === null
2415
- ? t('common.unknown')
2416
- : String(beforeTokens),
2417
- })));
2418
- if (editor) {
2419
- editor.printAbove(theme.dim(String.fromCharCode(10) +
2420
- '--- compacted context ---' +
2421
- String.fromCharCode(10) +
2422
- summary +
2423
- String.fromCharCode(10) +
2424
- '--- end ---' +
2425
- String.fromCharCode(10)));
2426
- }
2427
- }
2428
- catch (e) {
2429
- console.error(theme.error(e.message));
2430
- }
2431
- finally {
2432
- if (editor)
2433
- editor.unlock();
2434
2516
  }
2435
2517
  continue;
2436
2518
  }
@@ -2578,6 +2660,36 @@ async function main() {
2578
2660
  saveLastChat(chatId, currentWorkdir);
2579
2661
  }
2580
2662
  },
2663
+ // Auto-compact between tool calls when the context nears the
2664
+ // window limit. Enabled via browser.autoCompact. It sends messages
2665
+ // from THIS execution context (the task owns the browser), and the
2666
+ // loop awaits it, so it serializes with the throttle.
2667
+ onAutoCompact: config.browser.autoCompact
2668
+ ? async () => {
2669
+ const res = await performCompact({
2670
+ browser,
2671
+ currentChatId,
2672
+ workdir: currentWorkdir,
2673
+ locale: currentLocale,
2674
+ task: taskText,
2675
+ transcript,
2676
+ ui: editor,
2677
+ buildSystemPrompt: mod.buildSystemPrompt,
2678
+ tools: mod.createTools(currentWorkdir, { undo }),
2679
+ answerTimeoutMs: config.browser.answerTimeoutMs,
2680
+ quiet: true,
2681
+ skipUiLock: true,
2682
+ });
2683
+ if (res.ok) {
2684
+ currentChatId = res.chatId;
2685
+ saveLastChat(res.chatId, currentWorkdir);
2686
+ }
2687
+ return res.ok ? res.chatId : null;
2688
+ }
2689
+ : null,
2690
+ autoCompactPct: config.browser.autoCompactPct,
2691
+ contextLimit: config.ui.contextLimit,
2692
+ getTokenUsage: () => browser.getLastTokenUsage(),
2581
2693
  }, inputAttachments);
2582
2694
  }
2583
2695
  finally {
@@ -2598,6 +2710,12 @@ async function main() {
2598
2710
  if (mcpPool)
2599
2711
  await mcpPool.close().catch(() => { });
2600
2712
  transcript.close();
2713
+ // One dim line so the operator can find the log and the session after
2714
+ // the run (previously the path was only available via /transcript).
2715
+ console.log(theme.dim(t('msg.exit_summary', {
2716
+ transcript: transcript.file || t('common.off'),
2717
+ chat: currentChatId || t('chats.not_created'),
2718
+ })));
2601
2719
  }
2602
2720
  main().catch((e) => {
2603
2721
  console.error(theme.error(t('msg.critical')), e.message);