zames_pro 2.6.0 → 2.8.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
@@ -7,7 +7,8 @@ import { DeepSeekBrowser } from './browser.js';
7
7
  import { createTools } from './tools.js';
8
8
  import { runAgentLoop } from './agent-loop.js';
9
9
  import { createSpinner } from './spinner.js';
10
- import { LineEditor } from './input.js';
10
+ import { LineEditor, expandPastes, pasteReplacement } from './input.js';
11
+ import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, } from './attachments.js';
11
12
  import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
12
13
  import { runConfigMenu } from './config-menu.js';
13
14
  import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n.js';
@@ -41,12 +42,12 @@ function getPositional() {
41
42
  return positional;
42
43
  }
43
44
  const config = loadConfig();
44
- // Текущий язык интерфейса/агента. Меняется командой /config lang <ru|en>.
45
+ // Current interface/agent language. Changed by the /config lang <ru|en> command.
45
46
  let currentLocale = isLocale(config.ui?.locale)
46
47
  ? config.ui.locale
47
48
  : 'ru';
48
- // Перевод: читает currentLocale в момент вызова, поэтому смена языка
49
- // действует сразу, без перезапуска (для уже напечатанных строк).
49
+ // Translation: reads currentLocale at call time, so a language change takes
50
+ // effect immediately, without a restart (for lines printed afterwards).
50
51
  const t = (key, params) => translate(currentLocale)(key, params);
51
52
  const headless = hasFlag('--headless') || config.headless;
52
53
  const debug = hasFlag('--debug') || config.debug;
@@ -56,21 +57,21 @@ const maxIter = Number(getArg('--max-iter', String(config.maxIterations))) ||
56
57
  const positional = getPositional();
57
58
  const task = getArg('--task', positional.join(' ').trim() || null);
58
59
  const chatIdArg = getArg('--chat', null);
59
- // При возобновлении существующего чата system-prompt по умолчанию НЕ
60
- // переотправляется (он уже есть в начале чата). Флаг --resend-prompt
61
- // заставляет дослать его заново — например, если промпт обновился.
60
+ // When resuming an existing chat, the system-prompt is by default NOT
61
+ // resent (it is already at the start of the chat). The --resend-prompt flag
62
+ // forces resending it — for example, if the prompt was updated.
62
63
  const resendPrompt = hasFlag('--resend-prompt');
63
- // По умолчанию при старте начинается НОВЫЙ чат (контекст не тянется).
64
- // --resume-last: вернуться в последнюю сессию для рабочей директории.
65
- // --new-chat: оставлен для совместимости — это и так поведение по умолчанию.
64
+ // By default a NEW chat is started on launch (context is not carried over).
65
+ // --resume-last: return to the last session for the working directory.
66
+ // --new-chat: kept for compatibility — this is the default behavior anyway.
66
67
  const newChatFlag = hasFlag('--new-chat');
67
68
  const resumeLastFlag = hasFlag('--resume-last');
68
69
  // ---------- session persistence ----------
69
- // Сессии (чаты DeepSeek) храним в ~/.zames/.sessions, чтобы они переживали
70
- // перезапуск и были доступны для явного восстановления (--resume-last,
71
- // --chat <id>, /resume <n>). Автоматически при старте они НЕ поднимаются.
72
- // Раньше здесь был один файл last-chat.json, который терялся при смене
73
- // проекта и не давал списка сессий для восстановления.
70
+ // We store sessions (DeepSeek chats) in ~/.zames/.sessions so they survive
71
+ // a restart and are available for explicit restore (--resume-last,
72
+ // --chat <id>, /resume <n>). They are NOT brought up automatically on launch.
73
+ // Previously there was a single last-chat.json file that got lost when the
74
+ // project changed and gave no list of sessions to restore.
74
75
  function saveLastChat(id, workdir = '', title = '') {
75
76
  if (!id)
76
77
  return;
@@ -81,13 +82,13 @@ function loadLastChat(workdir = '') {
81
82
  return s ? s.id : null;
82
83
  }
83
84
  // ---------- hot reload ----------
84
- // Держим ссылки на модули логики в объекте mod. Команда /reload перечитывает
85
- // их через динамический import с timestamp-query — Node кэширует ESM по URL,
86
- // поэтому такой import вернёт свежую версию модуля. Браузер, чат и текущее
87
- // состояние НЕ перезапускаются: обновляется только логика.
88
- // В dev-режиме tsx грузит .ts-исходники из src/, а в собранном dist — .js.
89
- // Динамический import с query ?t= идёт мимо переписывания расширений tsx,
90
- // поэтому расширение подбираем сами — по фактическому файлу текущего модуля.
85
+ // We keep references to the logic modules in the mod object. The /reload
86
+ // command re-reads them via a dynamic import with a timestamp query — Node
87
+ // caches ESM by URL, so such an import returns a fresh version of the module.
88
+ // The browser, chat and current state are NOT restarted: only the logic is updated.
89
+ // In dev mode tsx loads the .ts sources from src/, and in the built dist — .js.
90
+ // A dynamic import with the ?t= query bypasses tsx's extension rewriting,
91
+ // so we pick the extension ourselves — from the actual file of the current module.
91
92
  const SRC_EXT = /[.]ts$/.test(new URL(import.meta.url).pathname) ? '.ts' : '.js';
92
93
  const RELOADABLE = [
93
94
  'tools',
@@ -151,13 +152,13 @@ async function reloadModules() {
151
152
  mod.createSpinner = pick('spinner', 'createSpinner');
152
153
  return { count: loaded.size, errors };
153
154
  }
154
- // Первичная загрузка, чтобы mod.buildSystemPrompt и остальные были заполнены.
155
+ // Initial load so that mod.buildSystemPrompt and the rest are populated.
155
156
  await reloadModules();
156
- // Dev-режим: авто-перечитывание модулей логики перед каждой задачей.
157
- // Включается флагом --dev (его ставит `npm run dev`) или config.hotReload === true.
158
- // В обычном режиме (npm start, глобальный zames) авто-reload выключен.
157
+ // Dev mode: auto-reload of the logic modules before each task.
158
+ // Enabled by the --dev flag (`npm run dev` sets it) or config.hotReload === true.
159
+ // In normal mode (npm start, global zames) auto-reload is off.
159
160
  const devMode = hasFlag('--dev') || config.hotReload === true;
160
- // Безопасный авто-reload: при ошибке загрузки оставляем прошлые рабочие модули.
161
+ // Safe auto-reload: on a load error we keep the previous working modules.
161
162
  async function autoReload() {
162
163
  if (!devMode)
163
164
  return;
@@ -195,6 +196,7 @@ ${theme.bold(t('help.while_working'))}
195
196
  ${t('help.key.slash')}
196
197
  ${t('help.key.newline')}
197
198
  ${t('help.key.backslash')}
199
+ ${t('help.key.attach')}
198
200
  ${t('help.key.esc')}
199
201
 
200
202
  ${theme.bold(t('help.commands'))}
@@ -239,8 +241,8 @@ ${theme.bold(t('help.files'))}
239
241
  function dirLabel(p) {
240
242
  return path.basename(p) || p;
241
243
  }
242
- // Список slash-команд для автодополнения при вводе «/» (Tab — дополнить).
243
- // Описание локализуется по ключу help.cmd.* в момент отображения — см.
244
+ // List of slash commands for completion when you type «/» (Tab — complete).
245
+ // The description is localized by the help.cmd.* key at display time — see
244
246
  // buildSlashCommands().
245
247
  const SLASH_COMMANDS = [
246
248
  { name: '/help', key: 'help.cmd.help' },
@@ -269,16 +271,37 @@ const SLASH_COMMANDS = [
269
271
  { name: '/exit', key: 'help.cmd.exit' },
270
272
  { name: '/quit', key: 'help.cmd.exit' },
271
273
  ];
272
- // Описания slash-команд на текущем языке (для подсказок LineEditor).
274
+ // Slash-command descriptions in the current language (for LineEditor hints).
273
275
  function buildSlashCommands() {
274
276
  return SLASH_COMMANDS.map((c) => ({
275
277
  name: c.name,
276
278
  description: t(c.key),
277
279
  }));
278
280
  }
279
- // Временные файлы агента (одноразовые скрипты и т.п.) складываем в
280
- // <проект>/tmp — эта папка в .gitignore и очищается при каждом запуске.
281
+ // We put the agent's temporary files (one-off scripts, etc.) in
282
+ // <project>/tmp — this folder is in .gitignore and is cleaned on every launch.
281
283
  const TMP_DIR = path.join(__dirname, '..', 'tmp');
284
+ // Resolve a pasted string into a path to an existing file. Handles quoted
285
+ // paths (drag&drop from some file managers adds quotes) and paths relative to
286
+ // the working directory.
287
+ async function resolveAttachPath(workdir, raw) {
288
+ let s = String(raw || '').trim();
289
+ if (!s)
290
+ return null;
291
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
292
+ s = s.slice(1, -1);
293
+ }
294
+ s = s.replace(/\\ /g, ' ');
295
+ if (!looksLikeFilePath(s) && !isImageName(s))
296
+ return null;
297
+ const candidates = path.isAbsolute(s) ? [s] : [path.resolve(workdir, s)];
298
+ for (const c of candidates) {
299
+ const st = await fs.stat(c).catch(() => null);
300
+ if (st && st.isFile())
301
+ return c;
302
+ }
303
+ return null;
304
+ }
282
305
  async function cleanTmpDir() {
283
306
  try {
284
307
  await fs.rm(TMP_DIR, { recursive: true, force: true });
@@ -289,23 +312,22 @@ async function cleanTmpDir() {
289
312
  console.error('tmp: не удалось очистить:', e.message);
290
313
  }
291
314
  }
292
- // Ввод строки в терминале с корректной обработкой вставки (Shift+Insert,
293
- // Ctrl+Shift+V, правая кнопка мыши и т.п.).
315
+ // Terminal line input with correct paste handling (Shift+Insert,
316
+ // Ctrl+Shift+V, right mouse button, etc.).
294
317
  //
295
- // Зачем свой ридер вместо readline:
296
- // 1) readline отправляет строку на ПЕРВОМ переводе строки. При вставке
297
- // многострочного текста это приводило к немедленной отправке и к тому,
298
- // что в чат уходила только первая строка. Здесь переводы строк внутри
299
- // вставки заменяются на пробелы, а отправка происходит только по
300
- // одиночному нажатию Enter.
301
- // 2) Включаем bracketed paste mode (\x1b[?2004h): терминал оборачивает
302
- // вставленный текст в маркеры \x1b[200~ … \x1b[201~, поэтому мы точно
303
- // знаем, что это вставка, а не набор с клавиатуры, и Enter внутри неё
304
- // не считается отправкой.
318
+ // Why a custom reader instead of readline:
319
+ // 1) readline submits the line on the FIRST newline. When pasting
320
+ // multiline text this caused immediate submission and only the first
321
+ // line went to the chat. Here newlines inside a paste are replaced with
322
+ // spaces, and submission happens only on a single Enter press.
323
+ // 2) We enable bracketed paste mode (\x1b[?2004h): the terminal wraps the
324
+ // pasted text in the markers \x1b[200~ … \x1b[201~, so we know for sure
325
+ // that this is a paste, not keyboard typing, and Enter inside it is not
326
+ // treated as submission.
305
327
  async function promptOnce(question) {
306
328
  const stdin = process.stdin;
307
329
  const stdout = process.stdout;
308
- // Не-TTY (пайп, редирект): читаем всё до EOF одной строкой.
330
+ // Non-TTY (pipe, redirect): read everything up to EOF as a single string.
309
331
  if (!stdin.isTTY || !stdin.setRawMode) {
310
332
  const chunks = [];
311
333
  return await new Promise((resolve) => {
@@ -323,7 +345,7 @@ async function promptOnce(question) {
323
345
  const wasRaw = stdin.isRaw;
324
346
  stdin.setRawMode(true);
325
347
  stdin.resume();
326
- // Bracketed paste включаем/выключаем парно.
348
+ // We enable/disable bracketed paste in pairs.
327
349
  stdout.write('\x1b[?2004h');
328
350
  stdout.write(question);
329
351
  let line = '';
@@ -332,11 +354,11 @@ async function promptOnce(question) {
332
354
  const PASTE_START = '\x1b[200~';
333
355
  const PASTE_END = '\x1b[201~';
334
356
  const redraw = () => {
335
- // Возвращаемся в начало строки, стираем и печатаем заново.
357
+ // Return to the start of the line, erase and print again.
336
358
  stdout.write(String.fromCharCode(13));
337
359
  stdout.write('\x1b[K');
338
360
  stdout.write(question + line);
339
- // Ставим курсор в нужную позицию.
361
+ // Put the cursor in the right position.
340
362
  const back = line.length - cursor;
341
363
  if (back > 0)
342
364
  stdout.write('\x1b[' + back + 'D');
@@ -352,9 +374,9 @@ async function promptOnce(question) {
352
374
  resolve(value);
353
375
  };
354
376
  const insertText = (text) => {
355
- // Нормализуем переводы строк: они приходят от многострочной вставки,
356
- // но означают «отправить». Внутри сообщения заменяем на пробел, чтобы
357
- // вся вставка ушла ОДНИМ сообщением.
377
+ // Normalize newlines: they come from a multiline paste but mean
378
+ // "submit". Inside the message we replace them with a space so the
379
+ // whole paste goes as ONE message.
358
380
  const clean = text
359
381
  .replace(/\r\n/g, ' ')
360
382
  .replace(/\r/g, ' ')
@@ -364,11 +386,11 @@ async function promptOnce(question) {
364
386
  };
365
387
  const onData = (buf) => {
366
388
  let s = buf.toString('utf-8');
367
- // Fallback для терминалов без bracketed paste: если весь чанк — это
368
- // «голый» перевод строки (один байт), значит нажали Enter → отправляем.
369
- // Если переводы строк пришли ВМЕСТЕ с другим текстом в одном чанке —
370
- // это вставка; такие переводы строк не отправляют сообщение, а
371
- // заменяются на пробелы (см. insertText).
389
+ // Fallback for terminals without bracketed paste: if the whole chunk is
390
+ // a "bare" newline (one byte), then Enter was pressed → submit.
391
+ // If newlines came TOGETHER with other text in one chunk — that's a
392
+ // paste; such newlines don't submit the message but are replaced with
393
+ // spaces (see insertText).
372
394
  if (!inPaste && (s === '\r' || s === '\n')) {
373
395
  return finish(line, true);
374
396
  }
@@ -389,14 +411,14 @@ async function promptOnce(question) {
389
411
  }
390
412
  const start = s.indexOf(PASTE_START);
391
413
  if (start !== -1) {
392
- // Всё до маркера обрабатываем как обычный ввод.
414
+ // Everything before the marker is processed as regular input.
393
415
  const before = s.slice(0, start);
394
416
  s = s.slice(start + PASTE_START.length);
395
417
  inPaste = true;
396
418
  if (before) {
397
419
  for (const ch of before) {
398
420
  if (ch === '\r' || ch === '\n') {
399
- /* внутри вставки — пропускаем */
421
+ /* inside a paste — skip */
400
422
  }
401
423
  else
402
424
  insertText(ch);
@@ -409,22 +431,22 @@ async function promptOnce(question) {
409
431
  const code = s.charCodeAt(0);
410
432
  s = s.slice(1);
411
433
  if (ch === '\r' || ch === '\n') {
412
- // Перевод строки внутри чанка с другим текстом (вставка без
413
- // bracketed paste): не отправляем, а вставляем пробел.
434
+ // A newline inside a chunk with other text (paste without
435
+ // bracketed paste): don't submit, insert a space instead.
414
436
  insertText(' ');
415
437
  redraw();
416
438
  continue;
417
439
  }
418
440
  if (code === 3) {
419
- // Ctrl+C — прерываем ввод.
441
+ // Ctrl+C — abort input.
420
442
  return finish('', true);
421
443
  }
422
444
  if (code === 4) {
423
- // Ctrl+D — как отправка пустой строки.
445
+ // Ctrl+D — like submitting an empty line.
424
446
  return finish(line, true);
425
447
  }
426
448
  if (code === 21) {
427
- // Ctrl+U — стереть строку.
449
+ // Ctrl+U — erase the line.
428
450
  line = '';
429
451
  cursor = 0;
430
452
  redraw();
@@ -440,7 +462,7 @@ async function promptOnce(question) {
440
462
  continue;
441
463
  }
442
464
  if (ch === '\x1b') {
443
- // Escape-последовательности (стрелки, Home/End, Delete…).
465
+ // Escape sequences (arrows, Home/End, Delete…).
444
466
  const rest = s;
445
467
  if (rest.startsWith('[D')) {
446
468
  if (cursor > 0)
@@ -477,14 +499,14 @@ async function promptOnce(question) {
477
499
  s = s.slice(3);
478
500
  continue;
479
501
  }
480
- // Прочие ESC-последовательности пропускаем до буквы/тильды.
502
+ // Other ESC sequences are skipped up to a letter/tilde.
481
503
  const m = s.match(/^\[[0-9;]*[A-Za-z~]/);
482
504
  if (m)
483
505
  s = s.slice(m[0].length);
484
506
  continue;
485
507
  }
486
508
  if (code < 32)
487
- continue; // прочие управляющие символы игнорируем
509
+ continue; // other control characters are ignored
488
510
  insertText(ch);
489
511
  redraw();
490
512
  }
@@ -492,24 +514,24 @@ async function promptOnce(question) {
492
514
  stdin.on('data', onData);
493
515
  });
494
516
  }
495
- // Слежение за клавиатурой во время работы агента.
517
+ // Keyboard monitoring while the agent works.
496
518
  //
497
- // Терминал остаётся живым, пока агент думает:
498
- // - Esc (или Ctrl+C) — прервать текущую генерацию (клик Stop в браузере);
499
- // - набор текста + Enter — поставить сообщение в очередь; оно уйдёт
500
- // агенту сразу после того, как текущая задача завершится
501
- // (как «отправить во время генерации» в веб-версии DeepSeek).
519
+ // The terminal stays live while the agent thinks:
520
+ // - Esc (or Ctrl+C) — abort the current generation (a Stop click in the browser);
521
+ // - typing + Enter — queue a message; it goes to the agent right after
522
+ // the current task finishes (like "send during generation" in the
523
+ // DeepSeek web version).
502
524
  //
503
- // Ввод буферизуется без построчного редактора: набранный текст отображается
504
- // в строке спиннера через onChange -> ui.setPending(). Enter отправляет буфер
505
- // в onQueue, пустой Enter игнорируется. Поддержаны Backspace, Ctrl+U, Esc-
506
- // последовательности (стрелки/Home/End/Delete игнорируются) и bracketed paste.
525
+ // Input is buffered without a line editor: the typed text is shown in the
526
+ // spinner line via onChange -> ui.setPending(). Enter sends the buffer to
527
+ // onQueue, an empty Enter is ignored. Backspace, Ctrl+U, Esc sequences
528
+ // (arrows/Home/End/Delete are ignored) and bracketed paste are supported.
507
529
  function watchInput({ onEscape, onChange, onQueue, } = {}) {
508
530
  const stdin = process.stdin;
509
531
  if (!stdin.isTTY || !stdin.setRawMode)
510
532
  return () => { };
511
- // Управляющие символы собираем из кодов: в этом файле нельзя писать
512
- // «сырые» ESC/CR/LF в строковых литералах (см. AGENTS.md).
533
+ // Control characters are assembled from codes: this file must not contain
534
+ // "raw" ESC/CR/LF in string literals (see AGENTS.md).
513
535
  const ESC = String.fromCharCode(27);
514
536
  const CSI = String.fromCharCode(91);
515
537
  const CR = String.fromCharCode(13);
@@ -520,6 +542,8 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
520
542
  process.stdout.write(ESC + '[?2004h');
521
543
  let buf = '';
522
544
  let inPaste = false;
545
+ let pasteBuf = '';
546
+ const pastes = [];
523
547
  const PASTE_START = ESC + '[200~';
524
548
  const PASTE_END = ESC + '[201~';
525
549
  const CSI_RE = new RegExp('^' + CSI + '[0-9;]*[A-Za-z~]');
@@ -527,8 +551,9 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
527
551
  if (onChange)
528
552
  onChange(buf);
529
553
  };
530
- // Вставленный текст: переводы строк внутри многострочной вставки
531
- // трактуем как пробелы — сообщение уходит одной строкой.
554
+ // Pasted text: small pastes (1–2 lines) are flattened into one line;
555
+ // large ones (3+ lines) are collapsed into "[Pasted lines#N]" (the original
556
+ // text is expanded back when the message is queued).
532
557
  const insert = (text) => {
533
558
  buf += text
534
559
  .split(CR + LF)
@@ -538,10 +563,19 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
538
563
  .split(LF)
539
564
  .join(' ');
540
565
  };
566
+ const insertPaste = (raw) => {
567
+ const rep = pasteReplacement(raw);
568
+ if (!rep) {
569
+ insert(raw);
570
+ return;
571
+ }
572
+ pastes.push(rep);
573
+ buf += rep.marker;
574
+ };
541
575
  function onData(data) {
542
576
  let s = data.toString('utf-8');
543
- // Одиночный Esc — прервать генерацию. Стрелки приходят целым чанком
544
- // и сюда не попадают.
577
+ // A single Esc — abort generation. Arrows come as a whole chunk and
578
+ // don't reach here.
545
579
  if (!inPaste && s === ESC) {
546
580
  if (onEscape)
547
581
  onEscape();
@@ -551,13 +585,15 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
551
585
  if (inPaste) {
552
586
  const end = s.indexOf(PASTE_END);
553
587
  if (end === -1) {
554
- insert(s);
588
+ pasteBuf += s;
555
589
  s = '';
556
590
  }
557
591
  else {
558
- insert(s.slice(0, end));
592
+ pasteBuf += s.slice(0, end);
559
593
  s = s.slice(end + PASTE_END.length);
560
594
  inPaste = false;
595
+ insertPaste(pasteBuf);
596
+ pasteBuf = '';
561
597
  }
562
598
  emitChange();
563
599
  continue;
@@ -567,6 +603,7 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
567
603
  const before = s.slice(0, start);
568
604
  s = s.slice(start + PASTE_START.length);
569
605
  inPaste = true;
606
+ pasteBuf = '';
570
607
  if (before) {
571
608
  insert(before);
572
609
  emitChange();
@@ -577,22 +614,24 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
577
614
  const code = s.charCodeAt(0);
578
615
  s = s.slice(1);
579
616
  if (ch === CR || ch === LF) {
580
- const text = buf.trim();
617
+ const text = expandPastes(pastes, buf).trim();
581
618
  buf = '';
619
+ pastes.length = 0;
582
620
  emitChange();
583
621
  if (text && onQueue)
584
622
  onQueue(text);
585
623
  continue;
586
624
  }
587
625
  if (code === 3) {
588
- // Ctrl+C — как Esc: прервать генерацию.
626
+ // Ctrl+C — like Esc: abort generation.
589
627
  if (onEscape)
590
628
  onEscape();
591
629
  continue;
592
630
  }
593
631
  if (code === 21) {
594
- // Ctrl+U — очистить набранное.
632
+ // Ctrl+U — clear what was typed.
595
633
  buf = '';
634
+ pastes.length = 0;
596
635
  emitChange();
597
636
  continue;
598
637
  }
@@ -605,14 +644,14 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
605
644
  continue;
606
645
  }
607
646
  if (ch === ESC) {
608
- // Escape-последовательность (стрелки, Home/End, Delete) — пропускаем.
647
+ // Escape sequence (arrows, Home/End, Delete) — skip.
609
648
  const m = s.match(CSI_RE);
610
649
  if (m)
611
650
  s = s.slice(m[0].length);
612
651
  continue;
613
652
  }
614
653
  if (code < 32)
615
- continue; // прочие управляющие — игнорируем
654
+ continue; // other control characters — ignore
616
655
  buf += ch;
617
656
  emitChange();
618
657
  }
@@ -627,8 +666,8 @@ function watchInput({ onEscape, onChange, onQueue, } = {}) {
627
666
  };
628
667
  }
629
668
  // ---------- workdir resolution ----------
630
- // Агент работает в директории, из которой его запустили (process.cwd()).
631
- // Это корень sandbox: инструменты не могут выходить выше него.
669
+ // The agent works in the directory it was launched from (process.cwd()).
670
+ // This is the sandbox root: tools cannot go above it.
632
671
  async function resolveWorkdir() {
633
672
  const explicitDir = getArg('--dir', null);
634
673
  const dir = explicitDir ? path.resolve(explicitDir) : process.cwd();
@@ -639,12 +678,12 @@ async function resolveWorkdir() {
639
678
  return dir;
640
679
  }
641
680
  // ---------- task runner ----------
642
- async function runTask(browser, tools, taskText, workdir, opts) {
681
+ async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
643
682
  const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, } = opts;
644
- // В TTY-режиме UI — это LineEditor: он владеет вводом (очередь, Esc,
645
- // Ctrl+C) и рисует статус НАД постоянной строкой ввода. В не-TTY режиме
646
- // (пайпы) — обычный спиннер + watchInput.
647
- // Новая задача с промпта — сбрасываем «стоп» от прошлого прерывания.
683
+ // In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
684
+ // Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
685
+ // mode (pipes) — a regular spinner + watchInput.
686
+ // A new task from the prompt — we reset the "stop" from the previous abort.
648
687
  browser._stopped = false;
649
688
  browser._abort = false;
650
689
  const ui = editor || mod.createSpinner(currentLocale);
@@ -658,7 +697,7 @@ async function runTask(browser, tools, taskText, workdir, opts) {
658
697
  },
659
698
  onChange: (text) => ui.setPending(text),
660
699
  onQueue: (text) => {
661
- queue.push(text);
700
+ queue.push({ text });
662
701
  ui.setPending(null);
663
702
  ui.stop();
664
703
  console.log(theme.user(t('msg.queued', { n: queue.length })) +
@@ -667,46 +706,58 @@ async function runTask(browser, tools, taskText, workdir, opts) {
667
706
  },
668
707
  });
669
708
  try {
670
- let next = { task: taskText, freshChat, sendSystemPrompt };
671
- // Выполняем задачу, затем — всё, что пользователь успел напечатать за
672
- // время работы. Очередь может пополняться прямо во время дренажа.
709
+ let next = {
710
+ text: taskText,
711
+ attachments,
712
+ freshChat,
713
+ sendSystemPrompt,
714
+ };
715
+ // Execute the task, then everything the user managed to type while it
716
+ // ran. The queue may be replenished right during draining.
673
717
  while (true) {
674
718
  ui.thinking();
675
719
  await mod.runAgentLoop({
676
720
  browser,
677
721
  tools,
678
- task: next.task,
722
+ task: next.text,
679
723
  workdir,
680
724
  maxIterations: maxIter,
681
725
  freshChat: next.freshChat,
682
726
  sendSystemPrompt: next.sendSystemPrompt,
727
+ attachments: next.attachments || [],
683
728
  transcript,
684
729
  onThinking: () => ui.thinking(),
685
730
  onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
686
731
  onToolResult: (result) => ui.toolResult(result),
687
732
  onAssistantMessage: (msg) => ui.assistant(msg),
733
+ onWarning: (msg) => ui.warning(msg),
688
734
  onChatReady,
689
735
  debugLog: debug,
690
736
  locale: currentLocale,
691
737
  });
692
- // Прервали (Esc/Ctrl+C) — не запускаем следующие задачи из очереди
693
- // и очищаем её, чтобы «стоп» действительно останавливал всё.
738
+ // Aborted (Esc/Ctrl+C) — we don't start the next tasks from the queue
739
+ // and clear it, so "stop" really stops everything.
694
740
  if (browser._stopped) {
695
741
  queue.length = 0;
696
742
  break;
697
743
  }
698
744
  if (!queue.length)
699
745
  break;
700
- const queued = queue.shift() ?? '';
746
+ const queued = queue.shift() ?? { text: '' };
701
747
  ui.stop();
702
748
  if (editor) {
703
- editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued));
749
+ editor.printAbove(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
704
750
  }
705
751
  else {
706
- console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued));
707
- }
708
- transcript?.log('queued_task', { task: queued });
709
- next = { task: queued, freshChat: false, sendSystemPrompt: false };
752
+ console.log(theme.user(t('msg.from_queue')) + theme.assistant(queued.text));
753
+ }
754
+ transcript?.log('queued_task', { task: queued.text });
755
+ next = {
756
+ text: queued.text,
757
+ attachments: queued.attachments,
758
+ freshChat: false,
759
+ sendSystemPrompt: false,
760
+ };
710
761
  }
711
762
  }
712
763
  catch (e) {
@@ -740,7 +791,7 @@ async function main() {
740
791
  let sandboxRoot;
741
792
  try {
742
793
  currentWorkdir = await resolveWorkdir();
743
- // Корень sandbox: агент не может выходить выше директории запуска.
794
+ // Sandbox root: the agent cannot go above the launch directory.
744
795
  sandboxRoot = currentWorkdir;
745
796
  }
746
797
  catch (e) {
@@ -779,13 +830,13 @@ async function main() {
779
830
  transcript.close();
780
831
  process.exit(1);
781
832
  }
782
- // Разовый режим
833
+ // One-shot mode
783
834
  if (task) {
784
835
  const tools = mod.createTools(currentWorkdir, { undo });
785
836
  let freshChat = true;
786
837
  let sendSystemPrompt = true;
787
- // По умолчанию начинаем новый чат. Продолжить прошлую сессию —
788
- // явно: --chat <id> или --resume-last.
838
+ // By default we start a new chat. To continue a previous session —
839
+ // explicitly: --chat <id> or --resume-last.
789
840
  let resumeId = chatIdArg;
790
841
  if (!resumeId && resumeLastFlag && !newChatFlag) {
791
842
  const last = loadLastSession(currentWorkdir);
@@ -828,29 +879,34 @@ async function main() {
828
879
  let lastChats = [];
829
880
  let currentChatId = null;
830
881
  let running = true;
831
- // Сообщения, набранные пользователем, пока агент работал. runTask
832
- // забирает их по одному после завершения текущей задачи.
882
+ // Messages the user typed while the agent worked. runTask takes them one
883
+ // by one after the current task finishes.
833
884
  const pendingQueue = [];
885
+ // Attachments for the next submit (filled by the editor's onAttachments).
886
+ let pendingAttachments = [];
887
+ // Show the "no clipboard image" hint only once per session.
888
+ let clipboardWarned = false;
834
889
  // ---------- review mode state ----------
835
- // null — обычный режим.
836
- // { snapDir, snapName, originalWorkdir } — мы внутри снапшота, чат уже
837
- // инициализирован review-промптом, юзер может просто писать «исправь...».
890
+ // null — normal mode.
891
+ // { snapDir, snapName, originalWorkdir } — we're inside a snapshot, the chat
892
+ // is already initialized with the review prompt, the user can just type "fix...".
838
893
  let reviewMode = null;
839
894
  process.on('SIGINT', async () => {
840
895
  running = false;
841
896
  await browser.close().catch(() => { });
842
- // Закрываем ленивый headless-браузер из web.js, иначе он останется
843
- // висеть отдельным процессом после выхода агента.
897
+ // Close the lazy headless browser from web.js, otherwise it would stay
898
+ // as a separate process after the agent exits.
844
899
  await mod.closeWeb().catch(() => { });
845
900
  transcript.close();
846
901
  console.log(theme.system(String.fromCharCode(10) + t('msg.bye')));
847
902
  process.exit(0);
848
903
  });
849
- // По умолчанию при старте начинаем НОВЫЙ чат: контекст прошлой сессии
850
- // не тянется автоматически. Продолжить прошлую сессию можно явно:
851
- // --chat <id> открыть конкретный чат
852
- // --resume-last вернуться в последний чат для этой рабочей директории
853
- // --new-chat оставлен для совместимости (это и так поведение по умолчанию)
904
+ // By default we start a NEW chat on launch: the context of a previous
905
+ // session is not carried over automatically. To continue a previous
906
+ // session explicitly:
907
+ // --chat <id> open a specific chat
908
+ // --resume-last return to the last chat for this working directory
909
+ // --new-chat kept for compatibility (this is the default behavior)
854
910
  let resumeId = chatIdArg;
855
911
  if (!resumeId && resumeLastFlag && !newChatFlag) {
856
912
  const last = loadLastSession(currentWorkdir);
@@ -873,11 +929,11 @@ async function main() {
873
929
  console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
874
930
  }
875
931
  }
876
- // ---------- ввод: постоянная строка внизу + статус сверху ----------
877
- // В TTY используем LineEditor: он владеет вводом всё время, показывает
878
- // статус над строкой ввода и печатает ответы агента ВЫШЕ неё, поэтому
879
- // набранный текст никогда не затирается выводом. В не-TTY (пайп) —
880
- // старый promptOnce.
932
+ // ---------- input: permanent line below + status above ----------
933
+ // In TTY we use LineEditor: it owns the input all the time, shows the
934
+ // status above the input line and prints the agent's answers ABOVE it, so
935
+ // the typed text is never overwritten by output. In non-TTY (pipe) —
936
+ // the old promptOnce.
881
937
  let editor = null;
882
938
  let waiter = null;
883
939
  const takeInput = () => {
@@ -904,8 +960,74 @@ async function main() {
904
960
  commands: buildSlashCommands(),
905
961
  });
906
962
  editor = ed;
907
- ed.onSubmit = (text) => {
908
- pendingQueue.push(text);
963
+ ed.setTmpDir(TMP_DIR);
964
+ ed.onAttach = async (raw) => {
965
+ // Case 1: the paste is the image data itself (data URL / base64 blob).
966
+ const image = parseImagePaste(raw);
967
+ if (image) {
968
+ const name = 'paste' + extForMime(image.mime);
969
+ const p = await saveToTemp(TMP_DIR, name, image.data);
970
+ const att = ed.attachments.add({
971
+ path: p,
972
+ name,
973
+ mime: image.mime,
974
+ size: image.data.length,
975
+ });
976
+ ed.printAbove(theme.system(t('msg.attached_image', { marker: att.marker, size: formatSize(image.data.length) })));
977
+ return att;
978
+ }
979
+ // Case 2: the paste is a path to a local file (drag&drop or copy path).
980
+ const filePath = await resolveAttachPath(currentWorkdir, raw);
981
+ if (!filePath)
982
+ return null;
983
+ const data = await fs.readFile(filePath).catch(() => null);
984
+ if (!data)
985
+ return null;
986
+ const name = path.basename(filePath);
987
+ const mime = guessMime(name);
988
+ const att = ed.attachments.add({
989
+ path: filePath,
990
+ name,
991
+ mime,
992
+ size: data.length,
993
+ });
994
+ ed.printAbove(theme.system(t('msg.attached_file', { marker: att.marker, name, size: formatSize(data.length) })));
995
+ return att;
996
+ };
997
+ ed.onClipboard = async () => {
998
+ const res = readClipboardImageDetailed();
999
+ if (!res.data || !res.data.length) {
1000
+ if (!clipboardWarned) {
1001
+ clipboardWarned = true;
1002
+ ed.printAbove(theme.warn(t('msg.clip_empty', { via: res.via }) +
1003
+ String.fromCharCode(10) +
1004
+ t('msg.clip_hint')));
1005
+ }
1006
+ return null;
1007
+ }
1008
+ const mime = sniffMime(res.data) || 'image/png';
1009
+ const name = 'clipboard-' + Date.now() + (extForMime(mime) || '.png');
1010
+ const p = await saveToTemp(TMP_DIR, name, res.data);
1011
+ const att = ed.attachments.add({
1012
+ path: p,
1013
+ name,
1014
+ mime,
1015
+ size: res.data.length,
1016
+ });
1017
+ ed.printAbove(theme.system(t('msg.attached_image', {
1018
+ marker: att.marker,
1019
+ size: formatSize(res.data.length),
1020
+ }) + theme.dim(' (' + res.via + ')')));
1021
+ return att;
1022
+ };
1023
+ ed.onSubmit = (text, items) => {
1024
+ const attachments = (items || []).map((a) => ({
1025
+ path: a.path,
1026
+ name: a.name,
1027
+ mime: a.mime,
1028
+ }));
1029
+ const msg = { text, attachments };
1030
+ pendingQueue.push(msg);
909
1031
  if (waiter) {
910
1032
  const r = waiter;
911
1033
  waiter = null;
@@ -924,7 +1046,7 @@ async function main() {
924
1046
  browser.stopGeneration().catch(() => { });
925
1047
  }
926
1048
  else {
927
- // Не заняты — выходим. Будим takeInput(), чтобы цикл завершился.
1049
+ // Not busy — exit. We wake takeInput() so the loop finishes.
928
1050
  running = false;
929
1051
  if (waiter) {
930
1052
  const r = waiter;
@@ -934,9 +1056,9 @@ async function main() {
934
1056
  }
935
1057
  };
936
1058
  ed.start();
937
- // Весь вывод команд (console.log/error) должен идти ВЫШЕ строки ввода,
938
- // иначе он затирает набираемый текст. Пока редактор активен, заворачиваем
939
- // оба потока в ed.printAbove.
1059
+ // All command output (console.log/error) must go ABOVE the input line,
1060
+ // otherwise it overwrites the text being typed. While the editor is
1061
+ // active, we wrap both streams in ed.printAbove.
940
1062
  const origLog = console.log.bind(console);
941
1063
  const origErr = console.error.bind(console);
942
1064
  const fmt = (a) => typeof a === 'string'
@@ -951,21 +1073,21 @@ async function main() {
951
1073
  })();
952
1074
  console.log = (...a) => ed.printAbove(a.map(fmt).join(' '));
953
1075
  console.error = (...a) => ed.printAbove(a.map(fmt).join(' '));
954
- // Сохраняем на случай отладки.
1076
+ // Save for debugging.
955
1077
  const edAny = ed;
956
1078
  edAny._origLog = origLog;
957
1079
  edAny._origErr = origErr;
958
1080
  }
959
1081
  // ---------- /config ----------
960
- // Просмотр и правка настроек без перезапуска. Значения валидируются по
961
- // CONFIG_SCHEMA (src/config.ts) и пишутся в проектный .zamesrc.json.
962
- // Язык (ui.locale) применяется сразу: меняем currentLocale, пересобираем
963
- // подсказки редактора и обновляем config.ui.locale (его читает agent-loop
964
- // при следующей задаче).
1082
+ // View and edit settings without a restart. Values are validated against
1083
+ // CONFIG_SCHEMA (src/config.ts) and written to the project .zamesrc.json.
1084
+ // The language (ui.locale) is applied immediately: we change currentLocale,
1085
+ // rebuild the editor hints and update config.ui.locale (agent-loop reads it
1086
+ // on the next task).
965
1087
  //
966
- // Без аргументов в интерактивном терминале открывается меню (стрелки,
967
- // Enter — изменить, d — сбросить, q — выйти). Есть и текстовые подкоманды
968
- // (list/get/set/reset/lang/path) — для скриптов и не-TTY.
1088
+ // With no arguments in an interactive terminal a menu opens (arrows,
1089
+ // Enter — edit, d — reset, q — quit). There are also text subcommands
1090
+ // (list/get/set/reset/lang/path) — for scripts and non-TTY.
969
1091
  function setConfigRuntime(path, value) {
970
1092
  const segs = path.split('.');
971
1093
  let obj = config;
@@ -989,7 +1111,7 @@ async function main() {
989
1111
  }
990
1112
  function configResetField(field) {
991
1113
  resetConfigValue('project', field.path);
992
- // Возвращаем рантайм-значение к дефолту.
1114
+ // Reset the runtime value to the default.
993
1115
  const def = getByPath(DEFAULTS, field.path);
994
1116
  setConfigRuntime(field.path, def);
995
1117
  }
@@ -1028,9 +1150,9 @@ async function main() {
1028
1150
  configShowList();
1029
1151
  return;
1030
1152
  }
1031
- // Меню рисует напрямую в stdout и само читает клавиши. Чтобы его вывод
1032
- // не накладывался на постоянную строку ввода LineEditor, на время меню
1033
- // «ставим редактор на паузу», а после — возвращаем.
1153
+ // The menu draws directly to stdout and reads keys itself. So that its
1154
+ // output doesn't overlap the LineEditor's permanent input line, we "pause"
1155
+ // the editor for the duration of the menu and restore it afterwards.
1034
1156
  if (editor)
1035
1157
  editor.pause();
1036
1158
  try {
@@ -1053,7 +1175,7 @@ async function main() {
1053
1175
  async function handleConfigCommand(input) {
1054
1176
  const parts = input.trim().split(/\s+/);
1055
1177
  const sub = (parts[1] || '').toLowerCase();
1056
- // Без подкоманды — меню (или текстовый список в не-TTY).
1178
+ // With no subcommand — the menu (or a text list in non-TTY).
1057
1179
  if (!sub || sub === 'menu' || sub === 'ui') {
1058
1180
  await configOpenMenu();
1059
1181
  return;
@@ -1069,7 +1191,7 @@ async function main() {
1069
1191
  })));
1070
1192
  return;
1071
1193
  }
1072
- // /config lang <ru|en> — быстрый доступ к ui.locale
1194
+ // /config lang <ru|en> — quick access to ui.locale
1073
1195
  if (sub === 'lang' || sub === 'language' || sub === 'язык') {
1074
1196
  const val = parts[2];
1075
1197
  if (!val) {
@@ -1152,16 +1274,19 @@ async function main() {
1152
1274
  console.log(theme.assistant(t('cfg.reset', { v: key })));
1153
1275
  return;
1154
1276
  }
1155
- // Неизвестная подкоманда — показываем список.
1277
+ // Unknown subcommand — show the list.
1156
1278
  console.error(theme.error(t('cfg.unknown_key', { v: sub })));
1157
1279
  configShowList();
1158
1280
  }
1159
1281
  while (running) {
1160
1282
  let input;
1283
+ let inputAttachments = [];
1161
1284
  try {
1162
1285
  if (editor) {
1163
1286
  editor.setPrompt(buildPrompt());
1164
- input = await takeInput();
1287
+ const msg = await takeInput();
1288
+ input = msg ? msg.text : null;
1289
+ inputAttachments = msg?.attachments || [];
1165
1290
  }
1166
1291
  else {
1167
1292
  let tail;
@@ -1206,10 +1331,10 @@ async function main() {
1206
1331
  }
1207
1332
  continue;
1208
1333
  }
1209
- // ---------- Самообзор ----------
1334
+ // ---------- Self-review ----------
1210
1335
  if (lower === '/self-review' || lower.startsWith('/self-review ')) {
1211
1336
  const focus = trimmed.slice('/self-review'.length).trim();
1212
- // Запоминаем, куда вернуться
1337
+ // Remember where to return
1213
1338
  const originalWorkdir = reviewMode
1214
1339
  ? reviewMode.originalWorkdir
1215
1340
  : currentWorkdir;
@@ -1220,10 +1345,10 @@ async function main() {
1220
1345
  focus: focus || undefined,
1221
1346
  transcript,
1222
1347
  });
1223
- // Переходим в review-режим:
1224
- // - рабочая директория = снапшот
1225
- // - чат НЕ сбрасываем — внутри selfReview уже создан свежий чат
1226
- // и отправлен review-промпт, продолжим в нём
1348
+ // Switch to review mode:
1349
+ // - working directory = snapshot
1350
+ // - we do NOT reset the chat — inside selfReview a fresh chat was
1351
+ // already created and the review prompt sent, we continue in it
1227
1352
  reviewMode = {
1228
1353
  snapDir: result.snapDir,
1229
1354
  snapName: path.basename(result.snapDir),
@@ -1261,7 +1386,7 @@ async function main() {
1261
1386
  const originalWorkdir = reviewMode
1262
1387
  ? reviewMode.originalWorkdir
1263
1388
  : currentWorkdir;
1264
- // Свежий чат + review-промпт на этот снапшот
1389
+ // Fresh chat + review prompt for this snapshot
1265
1390
  try {
1266
1391
  const { runAgentLoop: ral } = await import('./agent-loop.js');
1267
1392
  const tools = mod.createTools(snapRoot, { undo: null });
@@ -1291,6 +1416,8 @@ async function main() {
1291
1416
  onAssistantMessage: (m) => {
1292
1417
  ui.assistant(m);
1293
1418
  },
1419
+ onWarning: (m) => ui.warning(m),
1420
+ locale: currentLocale,
1294
1421
  });
1295
1422
  }
1296
1423
  reviewMode = {
@@ -1318,7 +1445,7 @@ async function main() {
1318
1445
  const back = reviewMode.originalWorkdir;
1319
1446
  reviewMode = null;
1320
1447
  currentWorkdir = back;
1321
- // Раз чат занят review-контекстом, для обычной работы создадим новый
1448
+ // Since the chat is busy with the review context, we'll create a new one for regular work
1322
1449
  freshChatNext = true;
1323
1450
  sendSystemPromptNext = true;
1324
1451
  console.log(theme.system(t('self.done_hint', { v: back })));
@@ -1361,7 +1488,7 @@ async function main() {
1361
1488
  }
1362
1489
  continue;
1363
1490
  }
1364
- // ---------- Обычные команды ----------
1491
+ // ---------- Regular commands ----------
1365
1492
  if (lower === '/chats') {
1366
1493
  const spin = editor || mod.createSpinner(currentLocale);
1367
1494
  spin.thinking();
@@ -1616,8 +1743,8 @@ async function main() {
1616
1743
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
1617
1744
  continue;
1618
1745
  }
1619
- // ---- Обычная задача (в том числе в review-режиме) ----
1620
- // Dev-режим: подхватываем свежие модули логики перед задачей.
1746
+ // ---- Regular task (including in review mode) ----
1747
+ // Dev mode: pick up fresh logic modules before the task.
1621
1748
  await autoReload();
1622
1749
  transcript.log('user_task', { task: trimmed, workdir: currentWorkdir });
1623
1750
  const tools = mod.createTools(currentWorkdir, { undo });
@@ -1631,15 +1758,15 @@ async function main() {
1631
1758
  queue: pendingQueue,
1632
1759
  ui: editor || null,
1633
1760
  onChatReady: (chatId) => {
1634
- // Сохраняем сессию сразу при начале диалога, не дожидаясь конца
1635
- // задачи. Иначе при долгой/прерванной задаче чат не попадал в
1636
- // ~/.zames/.sessions и терялся после перезапуска.
1761
+ // Save the session right at the start of the dialog, without waiting
1762
+ // for the task to finish. Otherwise a long/aborted task would not
1763
+ // get the chat into ~/.zames/.sessions and it would be lost after a restart.
1637
1764
  if (chatId) {
1638
1765
  currentChatId = chatId;
1639
1766
  saveLastChat(chatId, currentWorkdir);
1640
1767
  }
1641
1768
  },
1642
- });
1769
+ }, inputAttachments);
1643
1770
  }
1644
1771
  finally {
1645
1772
  if (editor)