zames_pro 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,27 @@ While the agent is working you can keep typing: press Enter to queue a message
41
41
  abort the current generation. This mirrors typing during generation on the
42
42
  DeepSeek website.
43
43
 
44
+ ### Images and files
45
+
46
+ You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Insert
47
+ or the terminal's own paste). zames saves it under `<project>/tmp` and shows a
48
+ marker in the line - `[image#1]` for images, `[file#1]` for other files - then
49
+ uploads the real file to the chat together with your message. These are the
50
+ same formats the DeepSeek web chat accepts (PNG, JPEG, GIF, WEBP, BMP, SVG and
51
+ the usual document types).
52
+
53
+ Pasting an image works when the terminal sends it as a `data:` URL or as a
54
+ base64 blob with a recognizable image signature. You can also paste a path to a
55
+ local file (drag a file into the terminal or copy its path); if the file exists
56
+ it is attached, otherwise the text is inserted as usual.
57
+
58
+ Press Ctrl+V (or use an empty paste) and zames reads the image straight from
59
+ the clipboard: Linux via wl-paste (Wayland) or xclip/xsel (X11), macOS
60
+ via pngpaste, Windows via PowerShell. The needed Linux tools are installed
61
+ automatically on npm install (best-effort, through the detected package
62
+ manager). If no image is found, zames says so and reports which tool it tried,
63
+ instead of staying silent.
64
+
44
65
  ### Options
45
66
 
46
67
  ```
@@ -2,7 +2,7 @@ import { buildSystemPrompt } from './system-prompt.js';
2
2
  import { getGitContext, formatGitContext } from './gitTools.js';
3
3
  import { parseXmlToolCalls } from './xml-toolcall.js';
4
4
  import { translate } from './i18n.js';
5
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
5
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
6
6
  if (freshChat) {
7
7
  await browser.newChat();
8
8
  transcript?.log('new_chat');
@@ -48,6 +48,25 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
48
48
  await reportChat();
49
49
  }
50
50
  let message = task;
51
+ if (attachments.length) {
52
+ // Tell the model what the markers mean even when the system-prompt is not
53
+ // (re)sent (resumed chat). The files are uploaded to the chat by the
54
+ // browser; this note just explains the [image#N] / [file#N] markers.
55
+ const markers = attachments.map((a, i) => {
56
+ const ext = String(a.name || '');
57
+ const isImg = /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(ext);
58
+ return (isImg ? '[image#' : '[file#') + (i + 1) + ']';
59
+ });
60
+ message =
61
+ task +
62
+ String.fromCharCode(10) +
63
+ String.fromCharCode(10) +
64
+ '(The user attached ' +
65
+ attachments.length +
66
+ ' file(s) to this message: ' +
67
+ markers.join(', ') +
68
+ '. Look at the uploaded images in the chat; file copies are in <project>/tmp.)';
69
+ }
51
70
  transcript?.log('task', { task });
52
71
  let malformedRetries = 0;
53
72
  const MAX_MALFORMED_RETRIES = 3;
@@ -61,13 +80,24 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
61
80
  // chatty model.
62
81
  let looksDoneRetries = 0;
63
82
  const MAX_LOOKSDONE_RETRIES = 3;
83
+ // Watchdog against the agent emitting a tool call and then going silent.
84
+ // After a tool result the expected next answer is a fresh tool call; if we
85
+ // instead get an EMPTY answer or the EXACT same answer as the previous turn
86
+ // (a sign the new message was not sent), we nudge instead of stopping.
87
+ let lastRaw = '';
88
+ let justRanTool = false;
89
+ let watchdogRetries = 0;
90
+ const MAX_WATCHDOG_RETRIES = 3;
64
91
  for (let i = 0; i < maxIterations; i++) {
65
92
  onThinking();
66
93
  // The first message (task) is user input: no throttle.
67
94
  // Subsequent ones (tool-result and resend requests) are agent sends:
68
95
  // throttled so we don't hit the rate limit.
69
96
  const isFirst = i === 0;
70
- const rawResponse = await browser.ask(message, { agent: !isFirst });
97
+ const rawResponse = await browser.ask(message, {
98
+ agent: !isFirst,
99
+ attachments: isFirst ? attachments : [],
100
+ });
71
101
  await reportChat();
72
102
  transcript?.log('assistant_raw', { response: rawResponse });
73
103
  // The user aborted generation (Esc/Ctrl+C).
@@ -75,6 +105,23 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
75
105
  transcript?.log('user_aborted');
76
106
  return rawResponse;
77
107
  }
108
+ // Watchdog: after a tool result we expect a FRESH tool call. If the answer
109
+ // is empty or identical to the previous turn (the new message was not
110
+ // sent), nudge instead of stopping.
111
+ const wdEmpty = !String(rawResponse || '').trim();
112
+ const wdStale = justRanTool && lastRaw.trim() !== '' && rawResponse.trim() === lastRaw.trim();
113
+ if (!isFirst && (wdEmpty || wdStale) && watchdogRetries < MAX_WATCHDOG_RETRIES) {
114
+ watchdogRetries++;
115
+ transcript?.log('watchdog_nudge', {
116
+ attempt: watchdogRetries,
117
+ empty: wdEmpty,
118
+ stale: wdStale,
119
+ response: String(rawResponse || '').slice(0, 200),
120
+ });
121
+ await new Promise((r) => setTimeout(r, 1500));
122
+ continue;
123
+ }
124
+ lastRaw = rawResponse;
78
125
  const parsed = parseToolCall(rawResponse);
79
126
  if (parsed) {
80
127
  const thought = extractPreToolText(rawResponse);
@@ -237,6 +284,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
237
284
  });
238
285
  results.push({ tool: call.tool, result });
239
286
  }
287
+ // A tool just ran — the next answer is expected to be a fresh tool call.
288
+ // Reset the watchdog so the next empty/repeated answer is nudged.
289
+ justRanTool = true;
290
+ watchdogRetries = 0;
240
291
  if (results.length === 1) {
241
292
  const r = results[0];
242
293
  const resultStr = typeof r.result === 'string' ? r.result : JSON.stringify(r.result);
@@ -0,0 +1,332 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execFileSync } from 'child_process';
5
+ export const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.ico'];
6
+ export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
7
+ function sanitize(name) {
8
+ return String(name || '')
9
+ .replace(/[\u0000-\u001f\u007f]/g, '')
10
+ .replace(/[\\/:*?"<>|]/g, '_')
11
+ .replace(/\s+/g, '_')
12
+ .slice(0, 80) || 'file';
13
+ }
14
+ export function guessMime(name) {
15
+ const ext = path.extname(String(name || '')).toLowerCase();
16
+ const map = {
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif',
21
+ '.webp': 'image/webp',
22
+ '.bmp': 'image/bmp',
23
+ '.svg': 'image/svg+xml',
24
+ '.ico': 'image/x-icon',
25
+ '.txt': 'text/plain',
26
+ '.md': 'text/markdown',
27
+ '.json': 'application/json',
28
+ '.js': 'text/javascript',
29
+ '.ts': 'text/plain',
30
+ '.css': 'text/css',
31
+ '.html': 'text/html',
32
+ '.pdf': 'application/pdf',
33
+ '.zip': 'application/zip',
34
+ '.csv': 'text/csv',
35
+ '.log': 'text/plain',
36
+ };
37
+ return map[ext] || 'application/octet-stream';
38
+ }
39
+ export function extForMime(mime) {
40
+ const m = String(mime || '').toLowerCase();
41
+ if (m.includes('png'))
42
+ return '.png';
43
+ if (m.includes('jpeg') || m.includes('jpg'))
44
+ return '.jpg';
45
+ if (m.includes('gif'))
46
+ return '.gif';
47
+ if (m.includes('webp'))
48
+ return '.webp';
49
+ if (m.includes('svg'))
50
+ return '.svg';
51
+ if (m.includes('text/plain'))
52
+ return '.txt';
53
+ if (m.includes('json'))
54
+ return '.json';
55
+ if (m.includes('pdf'))
56
+ return '.pdf';
57
+ return '';
58
+ }
59
+ export function formatSize(bytes) {
60
+ const n = Number(bytes) || 0;
61
+ if (n < 1024)
62
+ return n + ' B';
63
+ if (n < 1024 * 1024)
64
+ return (n / 1024).toFixed(1) + ' KB';
65
+ return (n / (1024 * 1024)).toFixed(1) + ' MB';
66
+ }
67
+ export function isImageName(name) {
68
+ return IMAGE_EXTS.includes(path.extname(String(name || '')).toLowerCase());
69
+ }
70
+ export function parseDataUrl(text) {
71
+ const m = String(text || '').match(/^data:([^;,]*);base64,([\s\S]*)$/);
72
+ if (!m)
73
+ return null;
74
+ try {
75
+ return { mime: m[1] || 'application/octet-stream', data: Buffer.from(m[2].replace(/\s+/g, ''), 'base64') };
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ export function sniffMime(data) {
82
+ if (!data || data.length < 4)
83
+ return '';
84
+ const b = data;
85
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
86
+ return 'image/png';
87
+ if (b[0] === 0xff && b[1] === 0xd8)
88
+ return 'image/jpeg';
89
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46)
90
+ return 'image/gif';
91
+ if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46)
92
+ return 'image/webp';
93
+ if (b[0] === 0x42 && b[1] === 0x4d)
94
+ return 'image/bmp';
95
+ return '';
96
+ }
97
+ export function parseImagePaste(raw) {
98
+ const text = String(raw || '').trim();
99
+ if (!text)
100
+ return null;
101
+ const dataUrl = parseDataUrl(text);
102
+ if (dataUrl)
103
+ return dataUrl;
104
+ const onlyB64 = /^[A-Za-z0-9+/\s=]+$/;
105
+ if (onlyB64.test(text) && text.length >= 64) {
106
+ try {
107
+ const data = Buffer.from(text.replace(/\s+/g, ''), 'base64');
108
+ const mime = sniffMime(data);
109
+ if (mime && mime.startsWith('image/'))
110
+ return { mime, data };
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ export class AttachmentStore {
119
+ items = [];
120
+ imageCount = 0;
121
+ fileCount = 0;
122
+ reset() {
123
+ this.items = [];
124
+ this.imageCount = 0;
125
+ this.fileCount = 0;
126
+ }
127
+ add(item) {
128
+ const image = isImageName(item.name) || String(item.mime).startsWith('image/');
129
+ const index = image ? ++this.imageCount : ++this.fileCount;
130
+ const marker = image ? '[image#' + index + ']' : '[file#' + index + ']';
131
+ const att = {
132
+ index,
133
+ path: item.path,
134
+ name: item.name,
135
+ mime: item.mime,
136
+ image,
137
+ marker,
138
+ size: Number(item.size) || 0,
139
+ };
140
+ this.items.push(att);
141
+ return att;
142
+ }
143
+ }
144
+ // True if the pasted text is a single file path (e.g. '/tmp/a.png', './img.jpg',
145
+ // 'C:\\pics\\a.png' or a bare 'photo.png'). We do NOT require the file to
146
+ // exist yet — the caller checks that.
147
+ export function looksLikeFilePath(text) {
148
+ const s = String(text || '').trim();
149
+ if (!s || s.length > 500)
150
+ return false;
151
+ if (/[\r\n]/.test(s))
152
+ return false;
153
+ // A bare file name with an image/known extension.
154
+ if (looksLikeFileName(s))
155
+ return true;
156
+ // A path with separators and an extension.
157
+ if (/[\\/]/.test(s) && /\.[A-Za-z0-9]{1,8}$/.test(s))
158
+ return true;
159
+ return false;
160
+ }
161
+ export function looksLikeFileName(name) {
162
+ const n = String(name || '').trim();
163
+ if (!n)
164
+ return false;
165
+ if (/[\r\n]/.test(n))
166
+ return false;
167
+ return /^[\w.\- ]+\.[A-Za-z0-9]{1,8}$/.test(n);
168
+ }
169
+ export async function saveToTemp(tmpDir, name, data) {
170
+ await fs.mkdir(tmpDir, { recursive: true });
171
+ const base = sanitize(name);
172
+ let target = path.join(tmpDir, base);
173
+ if (await fs.stat(target).catch(() => null)) {
174
+ const ext = path.extname(base);
175
+ const stem = base.slice(0, base.length - ext.length);
176
+ let i = 1;
177
+ while (await fs.stat(target).catch(() => null)) {
178
+ target = path.join(tmpDir, stem + '-' + i + ext);
179
+ i++;
180
+ }
181
+ }
182
+ await fs.writeFile(target, data);
183
+ return target;
184
+ }
185
+ // Read an image from the OS clipboard. Tries, in order, every known tool for
186
+ // the current platform. Returns the raw bytes plus a short note about which
187
+ // tool was used (or the reason nothing was found) — the caller shows that note
188
+ // to the user so a failed paste is never silent.
189
+ // True when running inside WSL. There the X11 clipboard is a SEPARATE
190
+ // clipboard from the Windows one: the user copies an image in Windows, so we
191
+ // must read it through powershell.exe, not xclip.
192
+ export function isWsl() {
193
+ if (process.platform !== 'linux')
194
+ return false;
195
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
196
+ return true;
197
+ try {
198
+ const rel = os.release().toLowerCase();
199
+ if (rel.includes('microsoft') || rel.includes('wsl'))
200
+ return true;
201
+ }
202
+ catch { }
203
+ return false;
204
+ }
205
+ // PowerShell one-liner: write the clipboard image as raw PNG bytes to stdout.
206
+ const PS_GET_IMAGE = 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
207
+ '$img=[System.Windows.Forms.Clipboard]::GetImage(); ' +
208
+ 'if($img){$ms=New-Object System.IO.MemoryStream; ' +
209
+ '$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
210
+ '[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
211
+ // PowerShell one-liner: write the clipboard file paths (CF_HDROP) to stdout.
212
+ const PS_GET_FILE_DROP = 'Add-Type -AssemblyName System.Windows.Forms; ' +
213
+ '$d=[System.Windows.Forms.Clipboard]::GetDataObject(); ' +
214
+ 'if($d -and $d.GetDataPresent([System.Windows.Forms.DataFormats]::FileDrop)){' +
215
+ '$f=$d.GetData([System.Windows.Forms.DataFormats]::FileDrop); $f -join [Environment]::NewLine}';
216
+ export function readClipboardImageDetailed() {
217
+ if (process.platform === 'linux' || process.platform === 'freebsd' || process.platform === 'openbsd') {
218
+ const wayland = process.env.WAYLAND_DISPLAY;
219
+ const x11 = process.env.DISPLAY;
220
+ const attempts = [];
221
+ // On WSL the Windows clipboard is what the user copies into — read it first.
222
+ if (isWsl()) {
223
+ attempts.push({ bin: 'powershell.exe', args: ['-NoProfile', '-NonInteractive', '-Command', PS_GET_IMAGE], via: 'powershell.exe' });
224
+ }
225
+ if (wayland || !x11)
226
+ attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
227
+ if (x11 || !wayland) {
228
+ attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
229
+ attempts.push({ bin: 'xsel', args: ['--clipboard', '--output'], via: 'xsel' });
230
+ }
231
+ // Even if the session env is missing, try the other family as a fallback.
232
+ attempts.push({ bin: 'wl-paste', args: ['--type', 'image/png', '--no-newline'], via: 'wl-paste' });
233
+ attempts.push({ bin: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png', '-o'], via: 'xclip' });
234
+ const tried = [];
235
+ for (const a of attempts) {
236
+ if (tried.includes(a.via))
237
+ continue;
238
+ tried.push(a.via);
239
+ const data = tryCommand(a.bin, a.args);
240
+ if (data && sniffMime(data).startsWith('image/'))
241
+ return { data, via: a.via };
242
+ if (data && data.length)
243
+ return { data, via: a.via };
244
+ }
245
+ return { data: null, via: 'no-tool:' + tried.join(',') };
246
+ }
247
+ if (process.platform === 'darwin') {
248
+ const data = tryCommand('pngpaste', ['-']);
249
+ return { data: data && data.length ? data : null, via: data ? 'pngpaste' : 'no-tool:pngpaste' };
250
+ }
251
+ if (process.platform === 'win32') {
252
+ // PowerShell reads the clipboard image and writes raw PNG bytes to stdout.
253
+ const ps = 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; ' +
254
+ '$img=[System.Windows.Forms.Clipboard]::GetImage(); ' +
255
+ 'if($img){$ms=New-Object System.IO.MemoryStream; ' +
256
+ '$img.Save($ms,[System.Drawing.Imaging.ImageFormat]::Png); ' +
257
+ '[Console]::OpenStandardOutput().Write($ms.ToArray(),0,$ms.Length)}';
258
+ const data = tryCommand('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]);
259
+ return { data: data && data.length ? data : null, via: data ? 'powershell' : 'no-tool:powershell' };
260
+ }
261
+ return { data: null, via: 'unsupported-platform' };
262
+ }
263
+ // Backwards-compatible wrapper used elsewhere.
264
+ export function readClipboardImage() {
265
+ return readClipboardImageDetailed().data;
266
+ }
267
+ function tryCommand(bin, args) {
268
+ try {
269
+ const out = execFileSync(bin, args, {
270
+ stdio: ['ignore', 'pipe', 'ignore'],
271
+ maxBuffer: MAX_ATTACHMENT_BYTES,
272
+ timeout: 8000,
273
+ windowsHide: true,
274
+ });
275
+ if (!out || !out.length)
276
+ return null;
277
+ return out;
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ export async function findFileByName(workdir, name) {
284
+ const candidate = path.resolve(workdir, name);
285
+ if (await fs.stat(candidate).catch(() => null))
286
+ return candidate;
287
+ try {
288
+ const { glob } = await import('fs/promises');
289
+ for await (const f of glob('**/' + name, { cwd: workdir })) {
290
+ const abs = path.resolve(workdir, f);
291
+ const rel = path.relative(workdir, abs);
292
+ if (rel.startsWith('..') || path.isAbsolute(rel))
293
+ continue;
294
+ return abs;
295
+ }
296
+ }
297
+ catch { }
298
+ return null;
299
+ }
300
+ // Read file paths from the Windows clipboard (copied files, CF_HDROP) and
301
+ // translate them to WSL paths. On WSL the user often copies a FILE, not an
302
+ // image. Returns [] on non-WSL / when nothing is there.
303
+ export function readWindowsClipboardFiles() {
304
+ if (!isWsl())
305
+ return [];
306
+ const out = tryCommand('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PS_GET_FILE_DROP]);
307
+ if (!out)
308
+ return [];
309
+ const text = out.toString('utf-8');
310
+ return text
311
+ .split(/\r?\n/)
312
+ .map((s) => s.trim())
313
+ .filter(Boolean)
314
+ .map(winPathToWsl)
315
+ .filter((p) => !!p);
316
+ }
317
+ // Convert a Windows path (C:\\Users\\me\\a.png) into a WSL path
318
+ // (/mnt/c/Users/me/a.png). Leaves already-POSIX paths untouched.
319
+ export function winPathToWsl(winPath) {
320
+ let p = String(winPath || '').trim();
321
+ if (p.startsWith('"') && p.endsWith('"'))
322
+ p = p.slice(1, -1);
323
+ if (!p)
324
+ return null;
325
+ if (p.startsWith('/'))
326
+ return p;
327
+ if (p.charAt(1) !== ':' || (p.charAt(2) !== '/' && p.charAt(2) !== '\\'))
328
+ return null;
329
+ const drive = p.charAt(0).toLowerCase();
330
+ const rest = p.slice(3).split('\\').join('/');
331
+ return '/mnt/' + drive + '/' + rest;
332
+ }
package/dist/browser.js CHANGED
@@ -43,6 +43,14 @@ const RATE_LIMIT_RE = /(messages? too frequent|too many requests|rate limit|сл
43
43
  export function isRateLimitText(text) {
44
44
  return RATE_LIMIT_RE.test(String(text || ''));
45
45
  }
46
+ // Normalize text for comparison: collapse whitespace so that tiny DOM
47
+ // differences (nbsp, trailing spaces) don't count as "a new answer".
48
+ export function normText(s) {
49
+ return String(s || '')
50
+ .replace(/\u00a0/g, ' ')
51
+ .replace(/\s+/g, ' ')
52
+ .trim();
53
+ }
46
54
  // The "too frequent" error: distinct from others so ask() waits a long time
47
55
  // (DeepSeek limits reset over minutes) and retries the send itself.
48
56
  export class RateLimitError extends Error {
@@ -408,14 +416,14 @@ export class DeepSeekBrowser {
408
416
  catch { }
409
417
  return false;
410
418
  }
411
- async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, } = {}) {
419
+ async ask(prompt, { timeout = this.answerTimeoutMs, agent = false, attachments = [], } = {}) {
412
420
  let lastErr = null;
413
421
  let attempt = 0;
414
422
  let rateLimitRetries = 0;
415
423
  while (attempt < this.askRetries) {
416
424
  attempt++;
417
425
  try {
418
- return await this._askOnce(prompt, { timeout, agent });
426
+ return await this._askOnce(prompt, { timeout, agent, attachments });
419
427
  }
420
428
  catch (e) {
421
429
  lastErr = e;
@@ -494,6 +502,69 @@ export class DeepSeekBrowser {
494
502
  await this.page.keyboard.insertText(text);
495
503
  }
496
504
  }
505
+ // Attach files/images to the chat via the hidden <input type=file> of the
506
+ // DeepSeek upload widget. The element is not visible, so we set the files
507
+ // programmatically (Playwright setInputFiles works on hidden inputs too).
508
+ async _attachFiles(files) {
509
+ const bufPayload = [];
510
+ for (const f of files) {
511
+ try {
512
+ const buffer = await fs.readFile(f.path);
513
+ bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
514
+ }
515
+ catch (e) {
516
+ console.error(theme.warn('⚠ не удалось прочитать вложение ' +
517
+ f.name +
518
+ ': ' +
519
+ e.message));
520
+ }
521
+ }
522
+ if (!bufPayload.length)
523
+ return;
524
+ // Preferred path: click the attach button and let the file chooser event
525
+ // carry the files. This is how a real user attaches a file and it reliably
526
+ // triggers DeepSeek's upload handler.
527
+ const ATTACH_SELECTORS = [
528
+ 'div[role="button"][aria-label*="attach" i]',
529
+ 'div[role="button"][aria-label*="влож" i]',
530
+ 'button[aria-label*="attach" i]',
531
+ 'button[aria-label*="влож" i]',
532
+ '[class*="upload"]',
533
+ '[class*="attach"]',
534
+ ];
535
+ for (const sel of ATTACH_SELECTORS) {
536
+ const btn = this.page.locator(sel).last();
537
+ try {
538
+ if ((await btn.count()) === 0)
539
+ continue;
540
+ if (!(await btn.isVisible().catch(() => false)))
541
+ continue;
542
+ const [chooser] = await Promise.all([
543
+ this.page.waitForEvent('filechooser', { timeout: 3000 }),
544
+ btn.click({ timeout: 1500 }),
545
+ ]);
546
+ await chooser.setFiles(bufPayload);
547
+ await this.page.waitForTimeout(2500);
548
+ return;
549
+ }
550
+ catch { }
551
+ }
552
+ // Fallback: set the files directly on the hidden <input type=file>.
553
+ const input = this.page.locator('input[type="file"]').first();
554
+ if ((await input.count()) === 0) {
555
+ console.error(theme.warn('⚠ не найдено поле загрузки файлов на странице — вложения не прикреплены'));
556
+ return;
557
+ }
558
+ try {
559
+ await input.setInputFiles(bufPayload, { timeout: 15_000 });
560
+ }
561
+ catch (e) {
562
+ console.error(theme.warn('⚠ не удалось прикрепить файлы: ' + e.message));
563
+ return;
564
+ }
565
+ // Wait for the upload to finish (the attach preview to appear).
566
+ await this.page.waitForTimeout(2000);
567
+ }
497
568
  // Pause between sends. Applied ONLY to agent messages
498
569
  // (tool-result, system-prompt) so we don't hit the rate limit.
499
570
  // User input is sent without delay.
@@ -508,7 +579,7 @@ export class DeepSeekBrowser {
508
579
  console.error(theme.warn(`⏳ пауза ${Math.ceil(gap / 1000)}с перед отправкой`));
509
580
  await this.page.waitForTimeout(gap);
510
581
  }
511
- async _askOnce(prompt, { timeout, agent }) {
582
+ async _askOnce(prompt, { timeout, agent, attachments = [], }) {
512
583
  // We reset the abort flag ONLY at the very start of the send.
513
584
  this._abort = false;
514
585
  const input = await this._findVisible(INPUT_SELECTORS, 10_000);
@@ -519,6 +590,11 @@ export class DeepSeekBrowser {
519
590
  await this._waitForSendSlot(agent);
520
591
  this._netCapture = '';
521
592
  this._netCaptureAt = 0;
593
+ // Attach files/images FIRST (before the text): the DeepSeek upload widget
594
+ // shows them above the input, and only then the message can be sent.
595
+ if (attachments.length) {
596
+ await this._attachFiles(attachments);
597
+ }
522
598
  await this._setInputText(input, prompt);
523
599
  await this.page.waitForTimeout(200);
524
600
  let sent = false;
@@ -554,12 +630,19 @@ export class DeepSeekBrowser {
554
630
  if (isRateLimitText(pageText)) {
555
631
  throw new RateLimitError(pageText.slice(0, 300));
556
632
  }
557
- const gen = await this._isGenerating();
558
633
  const cur = await this._readLastAnswerTextClean().catch(() => '');
559
634
  const bodyLen = await this.page
560
635
  .evaluate(() => document.body.innerText.length)
561
636
  .catch(() => 0);
562
- if (gen || (cur && cur !== beforeText) || bodyLen > startBodyLen) {
637
+ // A NEW answer is the only reliable sign that the message was actually
638
+ // sent: the text on the page must differ from what was there before the
639
+ // send, OR the page must have grown. The Stop-button heuristic
640
+ // (_isGenerating) is NOT used here: right after a tool result the stop
641
+ // button may briefly linger from the previous generation, which used to
642
+ // make us think the new answer had started when in fact nothing was sent
643
+ // — and then the agent silently "stopped".
644
+ const changed = cur && normText(cur) !== normText(beforeText);
645
+ if (changed || bodyLen > startBodyLen) {
563
646
  started = true;
564
647
  break;
565
648
  }
@@ -592,7 +675,12 @@ export class DeepSeekBrowser {
592
675
  }
593
676
  }
594
677
  const cur = await this._readLastAnswerTextClean().catch(() => '');
595
- if (cur && cur === last) {
678
+ // Ignore an "answer" that is identical to what was on the page BEFORE we
679
+ // sent the message: that is the previous answer, not a new one. Returning
680
+ // it would make the agent re-process the old tool call (or silently
681
+ // stop). We keep waiting instead.
682
+ const isNew = cur && normText(cur) !== normText(beforeText);
683
+ if (isNew && cur === last) {
596
684
  stable++;
597
685
  if (stable >= 2)
598
686
  return cur;
@@ -600,12 +688,14 @@ export class DeepSeekBrowser {
600
688
  else {
601
689
  stable = 0;
602
690
  }
603
- last = cur;
691
+ if (isNew)
692
+ last = cur;
604
693
  await this.page.waitForTimeout(800);
605
694
  }
606
- if (last)
695
+ if (last && normText(last) !== normText(beforeText))
607
696
  return last;
608
- throw new Error('Таймаут ожидания ответа. Попробуйте /debug-dom.');
697
+ throw new Error('Новый ответ не получен (на странице остался прежний текст). ' +
698
+ 'Возможно, сообщение не отправилось.');
609
699
  }
610
700
  async dumpDom(filePath) {
611
701
  if (!this.page)
package/dist/i18n.js CHANGED
@@ -59,6 +59,10 @@ const CATALOG = {
59
59
  'help.key.slash': { ru: '/ + Tab подсказка и автодополнение slash-команд', en: '/ + Tab slash-command hints and completion' },
60
60
  'help.key.newline': { ru: 'Ctrl+J / Ctrl+Enter новая строка (Shift+Enter в терминалах с поддержкой)', en: 'Ctrl+J / Ctrl+Enter new line (Shift+Enter in capable terminals)' },
61
61
  'help.key.backslash': { ru: '\\ + Enter тоже новая строка', en: '\\ + Enter also a new line' },
62
+ 'help.key.attach': {
63
+ ru: 'Ctrl+Shift+V картинки вставить изображение/файл (сохранится в tmp)',
64
+ en: 'Ctrl+Shift+V image paste an image/file (saved to tmp)',
65
+ },
62
66
  'help.key.esc': { ru: 'Esc, Ctrl+C прервать текущую генерацию', en: 'Esc, Ctrl+C abort current generation' },
63
67
  'help.commands': { ru: 'Обычные команды:', en: 'Commands:' },
64
68
  'help.cmd.new': { ru: '/new, /clear новый чат (сброс контекста)', en: '/new, /clear new chat (reset context)' },
@@ -133,6 +137,22 @@ const CATALOG = {
133
137
  'msg.abort_gen': { ru: '⏹ Esc — прерываю генерацию...', en: '⏹ Esc — aborting generation...' },
134
138
  'msg.abort_ctrlc': { ru: '⏹ Ctrl+C — прерываю генерацию...', en: '⏹ Ctrl+C — aborting generation...' },
135
139
  'msg.queued': { ru: '📨 В очередь ({n}): ', en: '📨 Queued ({n}): ' },
140
+ 'msg.attached_image': {
141
+ ru: '🖼 Вложено изображение {marker} ({size}) — сохранено в tmp',
142
+ en: '🖼 Attached image {marker} ({size}) — saved to tmp',
143
+ },
144
+ 'msg.attached_file': {
145
+ ru: '📎 Вложен файл {marker} {name} ({size}) — сохранён в tmp',
146
+ en: '📎 Attached file {marker} {name} ({size}) — saved to tmp',
147
+ },
148
+ 'msg.clip_empty': {
149
+ ru: '⚠ В буфере обмена нет картинки (проверено: {via}).',
150
+ en: '⚠ No image found in the clipboard (tried: {via}).',
151
+ },
152
+ 'msg.clip_hint': {
153
+ ru: ' Установите утилиту клипборда: Linux/X11 — xclip или xsel, Wayland — wl-clipboard; Windows/macOS — работает из коробки. Либо вставьте путь к файлу.',
154
+ en: ' Install a clipboard tool: Linux/X11 — xclip or xsel, Wayland — wl-clipboard; Windows/macOS — works out of the box. Or paste a file path.',
155
+ },
136
156
  'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
137
157
  'msg.agent_error': { ru: '✖ Ошибка агента:', en: '✖ Agent error:' },
138
158
  'msg.suspicious_stop': {