zames_pro 2.73.7 → 2.73.9
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/CHANGELOG.md +15 -0
- package/dist/agent-loop.js +58 -0
- package/dist/attach-flow.js +48 -0
- package/dist/browser.js +13 -39
- package/dist/cli-args.js +1 -0
- package/dist/commands.js +4 -0
- package/dist/config.js +5 -3
- package/dist/extraTools.js +2 -2
- package/dist/i18n/common.js +2 -2
- package/dist/i18n/doctor.js +4 -0
- package/dist/i18n/help.js +5 -0
- package/dist/i18n/qwen.js +8 -0
- package/dist/index.js +40 -3
- package/dist/input/layout.js +29 -0
- package/dist/input.js +23 -2
- package/dist/qwen-browser.js +75 -41
- package/dist/qwen-chats.js +28 -9
- package/dist/qwen-net-capture.js +5 -1
- package/dist/qwen-ui.js +6 -0
- package/dist/shell.js +9 -0
- package/dist/tools.js +2 -2
- package/dist/transcript.js +22 -1
- package/dist/xml-toolcall.js +29 -0
- package/package.json +1 -1
- package/scripts/qwen-smoke.mts +32 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.73.9] - 2026-10-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **qwen:** raise the default send pause to 4s (rate limit is real)
|
|
15
|
+
- **qwen:** read the real history shape (map) + handle quota_limit
|
|
16
|
+
- **cli:** honor --provider <id>; stop blocking bash -c in the sandbox
|
|
17
|
+
|
|
18
|
+
## [2.73.8] - 2026-10-12
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- bound qwen net body, share attach flow, count transcript backpressure, buffer split paste
|
|
23
|
+
- lose fewer tool args, unstick the arg-error loop, harden launch
|
|
24
|
+
|
|
10
25
|
## [2.73.7] - 2026-10-12
|
|
11
26
|
|
|
12
27
|
### Changed
|
package/dist/agent-loop.js
CHANGED
|
@@ -232,6 +232,18 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
232
232
|
let repeatCallCount = 0;
|
|
233
233
|
const MAX_REPEAT_CALLS = 4;
|
|
234
234
|
let repeatWarned = false;
|
|
235
|
+
// Consecutive tool calls that fail with an ARGUMENT error (`Missing required
|
|
236
|
+
// argument`, `Invalid required argument`). Such a call has a DIFFERENT
|
|
237
|
+
// signature each turn (a long `content` changes), so the identical-signature
|
|
238
|
+
// detector above never fires and the model can burn many iterations on the
|
|
239
|
+
// same omission. Track the tool + the error text instead; after a few in a
|
|
240
|
+
// row, nudge with the exact missing field and stop re-running it.
|
|
241
|
+
let argErrSig = '';
|
|
242
|
+
let argErrCount = 0;
|
|
243
|
+
const MAX_ARG_ERRORS = 3;
|
|
244
|
+
// Set when the argument-error series hits the cap; appended to the message
|
|
245
|
+
// AFTER the tool results are formatted (they rebuild `message`).
|
|
246
|
+
let argErrNudge = '';
|
|
235
247
|
let afterToolRetries = 0;
|
|
236
248
|
// toolsRanInTask: how many tools actually ran in THIS task. The protocol
|
|
237
249
|
// guard uses it to tell "started, then slipped into chat mode" (a reasoning
|
|
@@ -976,6 +988,45 @@ ${diag}`;
|
|
|
976
988
|
durationMs: toolMs,
|
|
977
989
|
});
|
|
978
990
|
results.push({ tool: call.tool, result });
|
|
991
|
+
// Argument-error series: `Missing required argument`/`Invalid required
|
|
992
|
+
// argument` from the req() guard. The signature changes each turn when
|
|
993
|
+
// the model forgets one field but rewrites the rest, so the identical
|
|
994
|
+
// detector misses it. Count by tool + error text; at the cap, tell the
|
|
995
|
+
// model EXACTLY which field is missing instead of re-running the call.
|
|
996
|
+
const resStr = String(result);
|
|
997
|
+
const argErr = resStr.match(/(?:Missing|Invalid) required argument[: ]+([A-Za-z0-9_]+)/);
|
|
998
|
+
if (argErr) {
|
|
999
|
+
const sig = call.tool + ':' + argErr[0];
|
|
1000
|
+
if (sig === argErrSig)
|
|
1001
|
+
argErrCount++;
|
|
1002
|
+
else {
|
|
1003
|
+
argErrSig = sig;
|
|
1004
|
+
argErrCount = 1;
|
|
1005
|
+
}
|
|
1006
|
+
if (argErrCount >= MAX_ARG_ERRORS) {
|
|
1007
|
+
argErrCount = 0;
|
|
1008
|
+
argErrSig = '';
|
|
1009
|
+
safeWarning(translate(locale)('msg.arg_error_loop'));
|
|
1010
|
+
// The message the model sees is REBUILT from `results` after the
|
|
1011
|
+
// batch loop, so a nudged `message` here would be overwritten.
|
|
1012
|
+
// Record the nudge and append it after the results are formatted.
|
|
1013
|
+
argErrNudge =
|
|
1014
|
+
'IMPORTANT: the tool `' +
|
|
1015
|
+
call.tool +
|
|
1016
|
+
'` failed with "' +
|
|
1017
|
+
argErr[0] +
|
|
1018
|
+
'". You keep calling it WITHOUT the required argument `' +
|
|
1019
|
+
argErr[1] +
|
|
1020
|
+
'`. Add `' +
|
|
1021
|
+
argErr[1] +
|
|
1022
|
+
'` to the arguments of the SAME call and retry — do NOT repeat ' +
|
|
1023
|
+
'the identical invocation.';
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
else {
|
|
1027
|
+
argErrSig = '';
|
|
1028
|
+
argErrCount = 0;
|
|
1029
|
+
}
|
|
979
1030
|
// The operator pressed Esc/Ctrl+C while the tool was running. The tool
|
|
980
1031
|
// itself has finished (we cannot kill an arbitrary child process from
|
|
981
1032
|
// here), but we must NOT feed its result back to the model and must NOT
|
|
@@ -1023,6 +1074,13 @@ ${diag}`;
|
|
|
1023
1074
|
})
|
|
1024
1075
|
.join('\n\n');
|
|
1025
1076
|
}
|
|
1077
|
+
// Append the argument-error nudge (set inside the batch loop) now that
|
|
1078
|
+
// `message` has been rebuilt from `results`. Cleared so it fires once per
|
|
1079
|
+
// series, not on every later turn.
|
|
1080
|
+
if (argErrNudge) {
|
|
1081
|
+
message = message + '\n\n' + argErrNudge;
|
|
1082
|
+
argErrNudge = '';
|
|
1083
|
+
}
|
|
1026
1084
|
// Auto-compact at the ONLY safe seam — after a tool result and before
|
|
1027
1085
|
// the next send. Never mid-generation, never during a rate-limit wait, and
|
|
1028
1086
|
// never between the task and the first send (this block runs only after a
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Read every file into a Playwright `setFiles` payload. A file that cannot be
|
|
4
|
+
* read is pushed onto `failed` (with a notice through `onReadError`) instead of
|
|
5
|
+
* throwing, so a single unreadable file never aborts the whole attach. Returns
|
|
6
|
+
* the successfully read payloads.
|
|
7
|
+
*/
|
|
8
|
+
export async function readAttachBuffers(files, onReadError) {
|
|
9
|
+
const payload = [];
|
|
10
|
+
const failed = [];
|
|
11
|
+
for (const f of files) {
|
|
12
|
+
try {
|
|
13
|
+
const buffer = await fs.readFile(f.path);
|
|
14
|
+
payload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
15
|
+
}
|
|
16
|
+
catch (e) {
|
|
17
|
+
failed.push({ path: f.path, name: f.name });
|
|
18
|
+
onReadError(f.name, e.message);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { payload, failed };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wait for the upload preview (the attached-file chip above the input) to
|
|
25
|
+
* appear, instead of a fixed sleep. The signal is the FILE NAME showing up on
|
|
26
|
+
* the page: files are attached BEFORE the text is typed, so the name cannot
|
|
27
|
+
* appear there for any other reason. `timeoutMs` is the ceiling, so a build
|
|
28
|
+
* where the name is not detected behaves exactly like the old fixed sleep.
|
|
29
|
+
*/
|
|
30
|
+
export async function awaitUploadPreview(page, names, timeoutMs) {
|
|
31
|
+
const clean = names.map((n) => String(n || '').trim()).filter(Boolean);
|
|
32
|
+
if (!clean.length) {
|
|
33
|
+
await page.waitForTimeout(timeoutMs);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const deadline = Date.now() + timeoutMs;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
for (const name of clean) {
|
|
39
|
+
const n = await page
|
|
40
|
+
.getByText(name, { exact: false })
|
|
41
|
+
.count()
|
|
42
|
+
.catch(() => 0);
|
|
43
|
+
if (n > 0)
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await page.waitForTimeout(150);
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/browser.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { chromium, } from 'playwright';
|
|
2
2
|
import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
|
|
3
3
|
import { rewriteFailedAttachments } from './commands.js';
|
|
4
|
+
import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
|
|
4
5
|
import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
|
|
5
6
|
import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, MFA_CODE_SELECTORS, MFA_SUBMIT_SELECTORS, MFA_SEND_CODE_SELECTORS, MFA_SEND_CODE_RE, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, ANSWER_URL_RE, } from './deepseek-ui.js';
|
|
6
7
|
import { probeUiHealth, } from './ui-health.js';
|
|
@@ -360,7 +361,13 @@ export class DeepSeekBrowser {
|
|
|
360
361
|
}
|
|
361
362
|
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
362
363
|
this._installNetHook();
|
|
363
|
-
|
|
364
|
+
// A slow network / cold CDN can exceed Playwright's 30s goto timeout.
|
|
365
|
+
// That must NOT abort launch(): the page is usually already usable and
|
|
366
|
+
// waitForLogin() polls. Bailing out made a transient slowness a hard
|
|
367
|
+
// startup failure.
|
|
368
|
+
await this.page
|
|
369
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
|
|
370
|
+
.catch(() => { });
|
|
364
371
|
}
|
|
365
372
|
_installNetHook() {
|
|
366
373
|
const pg = this['page'];
|
|
@@ -1516,21 +1523,7 @@ export class DeepSeekBrowser {
|
|
|
1516
1523
|
// message to [attach-failed: name]. The model must not believe a file was
|
|
1517
1524
|
// attached when it was not.
|
|
1518
1525
|
async _attachFiles(files) {
|
|
1519
|
-
const failed =
|
|
1520
|
-
const bufPayload = [];
|
|
1521
|
-
for (const f of files) {
|
|
1522
|
-
try {
|
|
1523
|
-
const buffer = await fs.readFile(f.path);
|
|
1524
|
-
bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
1525
|
-
}
|
|
1526
|
-
catch (e) {
|
|
1527
|
-
failed.push({ path: f.path, name: f.name });
|
|
1528
|
-
this._notice(theme.warn(this._t('ds.attach_read_failed', {
|
|
1529
|
-
name: f.name,
|
|
1530
|
-
error: e.message,
|
|
1531
|
-
})));
|
|
1532
|
-
}
|
|
1533
|
-
}
|
|
1526
|
+
const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('ds.attach_read_failed', { name, error }))));
|
|
1534
1527
|
if (!bufPayload.length)
|
|
1535
1528
|
return failed;
|
|
1536
1529
|
// Preferred path: click the attach button and let the file chooser event
|
|
@@ -1575,30 +1568,11 @@ export class DeepSeekBrowser {
|
|
|
1575
1568
|
await this._awaitUploadPreview(files.map((f) => f.name), 2000);
|
|
1576
1569
|
return failed;
|
|
1577
1570
|
}
|
|
1578
|
-
// Wait for the upload preview (the attached-file chip above the
|
|
1579
|
-
//
|
|
1580
|
-
//
|
|
1581
|
-
// so the name cannot appear there for any other reason. The old fixed value
|
|
1582
|
-
// stays as the TIMEOUT CEILING, so a build where the name is not detected
|
|
1583
|
-
// behaves exactly as before.
|
|
1571
|
+
// Wait for the upload preview (the attached-file chip above the input) to
|
|
1572
|
+
// appear, instead of a fixed sleep. Delegates to the shared helper so both
|
|
1573
|
+
// providers use the SAME preview policy.
|
|
1584
1574
|
async _awaitUploadPreview(names, timeoutMs) {
|
|
1585
|
-
|
|
1586
|
-
if (!clean.length) {
|
|
1587
|
-
await this.page.waitForTimeout(timeoutMs);
|
|
1588
|
-
return;
|
|
1589
|
-
}
|
|
1590
|
-
const deadline = Date.now() + timeoutMs;
|
|
1591
|
-
while (Date.now() < deadline) {
|
|
1592
|
-
for (const name of clean) {
|
|
1593
|
-
const n = await this.page
|
|
1594
|
-
.getByText(name, { exact: false })
|
|
1595
|
-
.count()
|
|
1596
|
-
.catch(() => 0);
|
|
1597
|
-
if (n > 0)
|
|
1598
|
-
return;
|
|
1599
|
-
}
|
|
1600
|
-
await this.page.waitForTimeout(150);
|
|
1601
|
-
}
|
|
1575
|
+
await awaitUploadPreview(this.page, names, timeoutMs);
|
|
1602
1576
|
}
|
|
1603
1577
|
// Wait out a rate-limit pause, showing an animated countdown through
|
|
1604
1578
|
// onSendPause so the operator sees the agent is alive (not frozen). Returns
|
package/dist/cli-args.js
CHANGED
package/dist/commands.js
CHANGED
|
@@ -1044,8 +1044,12 @@ export const SLASH_COMMANDS = [
|
|
|
1044
1044
|
export function buildSlashCommandHints(opts) {
|
|
1045
1045
|
const { devMode, t } = opts;
|
|
1046
1046
|
const table = opts.commands ?? SLASH_COMMANDS;
|
|
1047
|
+
// `/model` picks a QWEN model; DeepSeek has no such command. Hide it unless
|
|
1048
|
+
// the Qwen provider is active (or the caller did not pass a provider).
|
|
1049
|
+
const hideModel = opts.provider !== undefined && opts.provider !== 'qwen';
|
|
1047
1050
|
const base = table
|
|
1048
1051
|
.filter((c) => devMode || !isDevOnlyCommand(c.name))
|
|
1052
|
+
.filter((c) => !(hideModel && c.name === '/model'))
|
|
1049
1053
|
.map((c) => ({ name: c.name, description: t(c.key) }));
|
|
1050
1054
|
const known = new Set(base.map((c) => c.name.toLowerCase()));
|
|
1051
1055
|
for (const d of opts.dynamic ?? []) {
|
package/dist/config.js
CHANGED
|
@@ -21,9 +21,11 @@ export const DEFAULTS = {
|
|
|
21
21
|
qwen: {
|
|
22
22
|
model: '',
|
|
23
23
|
enabled: false,
|
|
24
|
-
// Qwen
|
|
25
|
-
//
|
|
26
|
-
|
|
24
|
+
// Qwen DOES rate-limit a rapid burst: measured live, the 5th back-to-back
|
|
25
|
+
// send at 0 ms gap hit "too frequent" and needed a 5-min back-off. A 1 s
|
|
26
|
+
// gap was too small for a real editing session, so the default is now 4 s
|
|
27
|
+
// — enough to stay under the limit while keeping the agent responsive.
|
|
28
|
+
minSendIntervalMs: 4000,
|
|
27
29
|
// The first send after a cold load can take minutes ("Model loading…" +
|
|
28
30
|
// thinking); a GUEST turn measured up to ~9 min live on a cold profile, so
|
|
29
31
|
// give it generous room. A signed-in session answers in seconds; this only
|
package/dist/extraTools.js
CHANGED
|
@@ -178,11 +178,11 @@ export function createExtraTools(workdir, { undo, todos } = {}) {
|
|
|
178
178
|
// "undefined" in the working directory.
|
|
179
179
|
const req = (v, name) => {
|
|
180
180
|
if (v === undefined || v === null) {
|
|
181
|
-
throw new Error(`Missing required argument: ${name}
|
|
181
|
+
throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
|
|
182
182
|
}
|
|
183
183
|
const s = String(v);
|
|
184
184
|
if (s === '' || s === 'undefined' || s === 'null') {
|
|
185
|
-
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}
|
|
185
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
|
|
186
186
|
}
|
|
187
187
|
return s;
|
|
188
188
|
};
|
package/dist/i18n/common.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Common UI strings: app/common, status, tasks, messages, diff, context, undo, cd.
|
|
2
2
|
export const commonMessages = {
|
|
3
3
|
'app.tagline': {
|
|
4
|
-
ru: 'агент поверх
|
|
5
|
-
en: 'coding agent over
|
|
4
|
+
ru: 'агент поверх {provider} через Playwright',
|
|
5
|
+
en: 'coding agent over {provider} via Playwright',
|
|
6
6
|
},
|
|
7
7
|
'common.yes': { ru: 'да', en: 'yes' },
|
|
8
8
|
'common.no': { ru: 'нет', en: 'no' },
|
package/dist/i18n/doctor.js
CHANGED
|
@@ -91,6 +91,10 @@ export const doctorMessages = {
|
|
|
91
91
|
ru: 'Агент повторяет один и тот же вызов инструмента — переключаю на другой подход.',
|
|
92
92
|
en: 'The agent keeps repeating the same tool call — switching it to a different approach.',
|
|
93
93
|
},
|
|
94
|
+
'msg.arg_error_loop': {
|
|
95
|
+
ru: 'Агент раз за разом вызывает инструмент без обязательного аргумента — прошу добавить его.',
|
|
96
|
+
en: 'The agent keeps calling a tool without a required argument — asking it to add the field.',
|
|
97
|
+
},
|
|
94
98
|
'msg.suspicious_stop': {
|
|
95
99
|
ru: 'агент, похоже, остановился, не распознав вызов инструмента. Ответ сохранён в транскрипте (событие suspicious_final). Можно попросить продолжить или переотправить задачу.',
|
|
96
100
|
en: 'the agent seems to have stopped without recognizing a tool call. The response is saved in the transcript (suspicious_final event). You can ask it to continue or resend the task.',
|
package/dist/i18n/help.js
CHANGED
|
@@ -38,6 +38,10 @@ export const helpMessages = {
|
|
|
38
38
|
ru: 'показать окно браузера (отладка/вход)',
|
|
39
39
|
en: 'show the browser window (debug/sign-in)',
|
|
40
40
|
},
|
|
41
|
+
'help.opt.provider': {
|
|
42
|
+
ru: 'выбрать провайдера чата (deepseek | qwen) на этот запуск',
|
|
43
|
+
en: 'select the chat provider (deepseek | qwen) for this run',
|
|
44
|
+
},
|
|
41
45
|
'help.opt.debug': { ru: 'подробный лог', en: 'verbose log' },
|
|
42
46
|
'help.opt.dev': {
|
|
43
47
|
ru: 'режим разработки: авто-перечитывание модулей',
|
|
@@ -103,6 +107,7 @@ export const helpMessages = {
|
|
|
103
107
|
'help.sec.git': { ru: 'Git', en: 'Git' },
|
|
104
108
|
'help.sec.agent': { ru: 'Агент', en: 'Agent' },
|
|
105
109
|
'help.sec.context': { ru: 'Контекст и настройки', en: 'Context & settings' },
|
|
110
|
+
'help.sec.provider': { ru: 'Провайдер чата', en: 'Chat provider' },
|
|
106
111
|
'help.sec.files': { ru: 'Файлы и прочее', en: 'Files & misc' },
|
|
107
112
|
'help.cmd.new': {
|
|
108
113
|
ru: '/new, /clear новый чат (сброс контекста)',
|
package/dist/i18n/qwen.js
CHANGED
|
@@ -50,6 +50,14 @@ export const qwenMessages = {
|
|
|
50
50
|
ru: 'Не удалось получить список моделей Qwen: {v}',
|
|
51
51
|
en: 'Failed to fetch the Qwen model list: {v}',
|
|
52
52
|
},
|
|
53
|
+
'qwen.model_deepseek_only': {
|
|
54
|
+
ru: '/model доступен только для провайдера Qwen. Сейчас активен DeepSeek (модель выбирается в веб-UI). Переключиться: /provider qwen',
|
|
55
|
+
en: '/model is only available for the Qwen provider. DeepSeek is active (its model is chosen in the web UI). Switch with: /provider qwen',
|
|
56
|
+
},
|
|
57
|
+
'qwen.age_gate_block': {
|
|
58
|
+
ru: '✖ Qwen показывает окно "Confirm your age" (гостевой режим) и не даёт отправить сообщение. Войдите в аккаунт Qwen (команда /provider login) — в гостевом режиме первое сообщение блокируется.',
|
|
59
|
+
en: '✖ Qwen shows the "Confirm your age" dialog (guest mode) and blocks the send. Sign in to Qwen (/provider login) — in guest mode the first message is blocked.',
|
|
60
|
+
},
|
|
53
61
|
'provider.switched': {
|
|
54
62
|
ru: 'Провайдер чата: {v}.',
|
|
55
63
|
en: 'Chat provider: {v}.',
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'url';
|
|
|
7
7
|
import { theme } from './theme.js';
|
|
8
8
|
import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
|
|
9
9
|
import { QwenBrowser } from './qwen-browser.js';
|
|
10
|
-
import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, } from './providers.js';
|
|
10
|
+
import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, PROVIDER_IDS, } from './providers.js';
|
|
11
11
|
import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
|
|
12
12
|
import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
|
|
13
13
|
import { runAgentLoop } from './agent-loop.js';
|
|
@@ -69,6 +69,26 @@ const { getArg, hasFlag, getPositional } = createCliArgs(process.argv.slice(2));
|
|
|
69
69
|
// compaction).
|
|
70
70
|
const COMPACT_FALLBACK_LIMIT = 40;
|
|
71
71
|
const config = loadConfig();
|
|
72
|
+
// A `--provider <deepseek|qwen>` CLI flag selects the backend for THIS run,
|
|
73
|
+
// mirroring the /provider command (which changes it live). Without this an
|
|
74
|
+
// operator who typed `--provider qwen` got the DEFAULT backend — the flag was
|
|
75
|
+
// silently treated as free-form task text and DeepSeek opened instead of Qwen.
|
|
76
|
+
const providerArg = getArg('--provider', null);
|
|
77
|
+
if (providerArg !== null) {
|
|
78
|
+
if (isProviderId(providerArg)) {
|
|
79
|
+
config.provider = providerArg;
|
|
80
|
+
config.qwen.enabled = providerArg === 'qwen';
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.error(theme.warn('Unknown --provider "' +
|
|
84
|
+
providerArg +
|
|
85
|
+
'" (expected: ' +
|
|
86
|
+
PROVIDER_IDS.join(' | ') +
|
|
87
|
+
'). Using ' +
|
|
88
|
+
config.provider +
|
|
89
|
+
'.'));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
72
92
|
// Apply the configured answer width to the markdown renderer (0 = auto).
|
|
73
93
|
setAnswerWidth(config.ui?.answerWidth ?? 0);
|
|
74
94
|
// Current interface/agent language. Changed by the /config lang <ru|en> command.
|
|
@@ -367,7 +387,11 @@ function printHelp() {
|
|
|
367
387
|
t('help.cmd.exit') +
|
|
368
388
|
'\n';
|
|
369
389
|
console.log(`
|
|
370
|
-
${theme.bold('zames')} — ${t('app.tagline'
|
|
390
|
+
${theme.bold('zames')} — ${t('app.tagline', {
|
|
391
|
+
provider: browserProviderUrl()
|
|
392
|
+
.replace(/^https?:\/\//, '')
|
|
393
|
+
.split('/')[0],
|
|
394
|
+
})}
|
|
371
395
|
|
|
372
396
|
${theme.bold(t('help.options'))}
|
|
373
397
|
--dir <path> ${t('help.opt.dir')}
|
|
@@ -379,6 +403,7 @@ ${theme.bold(t('help.options'))}
|
|
|
379
403
|
--max-iter <n> ${t('help.opt.max_iter', { n: config.maxIterations })}
|
|
380
404
|
--headless ${t('help.opt.headless')}
|
|
381
405
|
--headed ${t('help.opt.headed')}
|
|
406
|
+
--provider <id> ${t('help.opt.provider')}
|
|
382
407
|
--plan ${t('help.opt.plan')}
|
|
383
408
|
--output-format <f> ${t('help.opt.output_format')}
|
|
384
409
|
--debug ${t('help.opt.debug')}
|
|
@@ -447,6 +472,9 @@ ${theme.bold(t('help.sec.context'))}
|
|
|
447
472
|
${t('help.cmd.config')}
|
|
448
473
|
${t('help.cmd.lang')}
|
|
449
474
|
|
|
475
|
+
${theme.bold(t('help.sec.provider'))}
|
|
476
|
+
${t('help.cmd.provider')}${config.provider === 'qwen' ? '\n ' + t('help.cmd.model') : ''}
|
|
477
|
+
|
|
450
478
|
${theme.bold(t('help.sec.files'))}
|
|
451
479
|
${t('help.cmd.transcript')}
|
|
452
480
|
${t('help.cmd.undo')}
|
|
@@ -576,6 +604,7 @@ function buildSlashCommands() {
|
|
|
576
604
|
devMode,
|
|
577
605
|
t,
|
|
578
606
|
dynamic: dynamicCommands,
|
|
607
|
+
provider: config.provider,
|
|
579
608
|
});
|
|
580
609
|
}
|
|
581
610
|
// We put the agent's temporary files (one-off scripts, etc.) in
|
|
@@ -2208,7 +2237,10 @@ async function main() {
|
|
|
2208
2237
|
if (lower === '/model' || lower.startsWith('/model ')) {
|
|
2209
2238
|
const arg = trimmed.slice('/model'.length).trim();
|
|
2210
2239
|
if (config.provider !== 'qwen') {
|
|
2211
|
-
|
|
2240
|
+
// `/model` is Qwen-only: it picks a model in Qwen's header dropdown.
|
|
2241
|
+
// DeepSeek exposes no model selection from zames, so the command is
|
|
2242
|
+
// hidden from the hints and only explains itself when typed by hand.
|
|
2243
|
+
console.log(theme.system(t('qwen.model_deepseek_only')));
|
|
2212
2244
|
continue;
|
|
2213
2245
|
}
|
|
2214
2246
|
const qb = browser;
|
|
@@ -3501,6 +3533,11 @@ async function main() {
|
|
|
3501
3533
|
if (mcpPool)
|
|
3502
3534
|
await mcpPool.close().catch(() => { });
|
|
3503
3535
|
await transcript.close();
|
|
3536
|
+
// Surface a transcript that hit backpressure (events buffered in memory, not
|
|
3537
|
+
// on disk) instead of leaving it silent.
|
|
3538
|
+
const trNote = transcript.healthNote();
|
|
3539
|
+
if (trNote)
|
|
3540
|
+
console.log(theme.warn(trNote));
|
|
3504
3541
|
// One dim line so the operator can find the log and the session after
|
|
3505
3542
|
// the run (previously the path was only available via /transcript).
|
|
3506
3543
|
console.log(theme.dim(t('msg.exit_summary', {
|
package/dist/input/layout.js
CHANGED
|
@@ -367,3 +367,32 @@ export function formatCompactTokens(tokens) {
|
|
|
367
367
|
}
|
|
368
368
|
// Re-exported for the editor's layout selftest (node src/input.js --selftest).
|
|
369
369
|
export { NL, CR, ESC, PASTE_START, PASTE_END, safeJson };
|
|
370
|
+
/**
|
|
371
|
+
* Split off a trailing PARTIAL PASTE marker so a control sequence fragmented
|
|
372
|
+
* across two terminal reads is reassembled by the caller instead of leaking
|
|
373
|
+
* into the buffer as literal text. A bracketed paste sends `ESC[200~` …
|
|
374
|
+
* `ESC[201~`; a read can end anywhere inside one of those markers.
|
|
375
|
+
*
|
|
376
|
+
* Returns `{ head, tail }` where `tail` is the held-back partial marker (or
|
|
377
|
+
* null when the string does not end inside a marker). Only tails that START
|
|
378
|
+
* with ESC and are a genuine PREFIX of PASTE_START / PASTE_END are held, so an
|
|
379
|
+
* ordinary trailing `[2` typed by hand is NOT swallowed.
|
|
380
|
+
*/
|
|
381
|
+
export function splitPasteTail(s) {
|
|
382
|
+
const str = String(s ?? '');
|
|
383
|
+
if (!str)
|
|
384
|
+
return { head: str, tail: null };
|
|
385
|
+
// The longest marker prefix to consider is one char short of the marker.
|
|
386
|
+
const markers = [PASTE_START, PASTE_END];
|
|
387
|
+
const maxHold = Math.max(...markers.map((m) => m.length)) - 1;
|
|
388
|
+
const upto = Math.min(maxHold, str.length - 1);
|
|
389
|
+
for (let len = upto; len >= 1; len--) {
|
|
390
|
+
const tail = str.slice(str.length - len);
|
|
391
|
+
if (!tail.startsWith(ESC))
|
|
392
|
+
continue;
|
|
393
|
+
if (markers.some((m) => m.length > len && m.startsWith(tail))) {
|
|
394
|
+
return { head: str.slice(0, str.length - len), tail };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { head: str, tail: null };
|
|
398
|
+
}
|
package/dist/input.js
CHANGED
|
@@ -4,12 +4,12 @@ import { contentWidth } from './width.js';
|
|
|
4
4
|
import { randomThinkingPhrase, stripEllipsis, DOTS, renderDots, } from './spinner.js';
|
|
5
5
|
import { translate } from './i18n.js';
|
|
6
6
|
import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
|
|
7
|
-
import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, } from './input/layout.js';
|
|
7
|
+
import { NL, CR, ESC, PASTE_START, PASTE_END, SUGGEST_PAGE, pasteReplacement, expandPastes, safeJson, visRows, visLen, charWidth, layoutInput, formatTokenStatus, tokenStatusLevel, truncateToWidth, wrapToWidth, splitPasteTail, CONTEXT_LIMIT, } from './input/layout.js';
|
|
8
8
|
// The pure layout/formatting helpers (visual rows, cursor position, visible
|
|
9
9
|
// width, paste markers, token formatting) live in ./input/layout.js. They are
|
|
10
10
|
// re-exported here so every existing import from './input.js' keeps working,
|
|
11
11
|
// and so tests can import them from either module (C3 step 3).
|
|
12
|
-
export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
|
|
12
|
+
export { PASTE_MIN_LINES, SUGGEST_PAGE, isSubsequence, countPasteLines, formatPasteMarker, pasteReplacement, expandPastes, visRows, visLen, charWidth, layoutInput, tokenStatusLevel, formatTokenStatus, formatCompactTokens, splitPasteTail, truncateToWidth, wrapToWidth, CONTEXT_LIMIT, CONTEXT_YELLOW_PCT, CONTEXT_RED_PCT, } from './input/layout.js';
|
|
13
13
|
// PURE slash-command suggestion logic (match / paging / completion), split out
|
|
14
14
|
// of the class so the fuzzy match is unit-tested without driving the editor.
|
|
15
15
|
import { matchSlashCommands, suggestWindow, completeSlashCommand, } from './input/suggest.js';
|
|
@@ -99,6 +99,12 @@ export class LineEditor {
|
|
|
99
99
|
// head of a control sequence split across two reads. We hold it briefly and
|
|
100
100
|
// fire onEscape only if no tail arrives.
|
|
101
101
|
_escPending;
|
|
102
|
+
// Tail of a control sequence split across two `data` chunks. Only the
|
|
103
|
+
// PASTE_START / PASTE_END markers need it: a chunk that ends on `ESC[2` etc.
|
|
104
|
+
// is stored here and re-joined with the next chunk, so a bracketed paste whose
|
|
105
|
+
// start marker was fragmented is still recognized (otherwise the paste bytes
|
|
106
|
+
// leaked into the buffer as literal text).
|
|
107
|
+
_ctrlTail;
|
|
102
108
|
_escTimer;
|
|
103
109
|
// History of sent messages (for the up/down arrows).
|
|
104
110
|
history;
|
|
@@ -219,6 +225,7 @@ export class LineEditor {
|
|
|
219
225
|
this._resizeTimer = null;
|
|
220
226
|
this._escPending = false;
|
|
221
227
|
this._escTimer = null;
|
|
228
|
+
this._ctrlTail = '';
|
|
222
229
|
this._onResize = () => {
|
|
223
230
|
// Debounce: a window drag fires a burst of SIGWINCH events. We wait a
|
|
224
231
|
// short moment, then redraw the block at the NEW width.
|
|
@@ -1574,6 +1581,20 @@ export class LineEditor {
|
|
|
1574
1581
|
}
|
|
1575
1582
|
s = ESC + s;
|
|
1576
1583
|
}
|
|
1584
|
+
// Reattach a control-sequence tail held from the PREVIOUS chunk (a
|
|
1585
|
+
// PASTE_START/PASTE_END marker split across reads). See _ctrlTail.
|
|
1586
|
+
if (this._ctrlTail) {
|
|
1587
|
+
s = this._ctrlTail + s;
|
|
1588
|
+
this._ctrlTail = '';
|
|
1589
|
+
}
|
|
1590
|
+
// A chunk may end in the MIDDLE of a PASTE marker (`ESC[2`, `ESC[20`, …).
|
|
1591
|
+
// Hold that tail back so the next chunk completes it; otherwise the
|
|
1592
|
+
// fragment is inserted as literal text and the paste is not bracketed.
|
|
1593
|
+
const held = splitPasteTail(s);
|
|
1594
|
+
if (held.tail) {
|
|
1595
|
+
this._ctrlTail = held.tail;
|
|
1596
|
+
s = held.head;
|
|
1597
|
+
}
|
|
1577
1598
|
if (!this._inPaste && s === ESC && !this._searchMode) {
|
|
1578
1599
|
// Bare ESC in its own chunk: wait a moment for the possible tail of a
|
|
1579
1600
|
// control sequence before treating it as the Escape key.
|
package/dist/qwen-browser.js
CHANGED
|
@@ -21,12 +21,13 @@ import os from 'os';
|
|
|
21
21
|
import { chromium } from 'playwright';
|
|
22
22
|
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
23
23
|
import { rewriteFailedAttachments } from './commands.js';
|
|
24
|
+
import { awaitUploadPreview, readAttachBuffers } from './attach-flow.js';
|
|
24
25
|
import { theme } from './theme.js';
|
|
25
26
|
import { scrapeQwenChatList, scrapeQwenChatMessages, parseQwenChatList, parseQwenHistory, } from './qwen-chats.js';
|
|
26
27
|
import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, sanitizeHeadlessUA, cleanSingletonFiles, profileLooksLocked, nukeProfile, AUTH_MARKER_FILE, } from './browser-pure.js';
|
|
27
28
|
import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
|
|
28
29
|
import { probeUiHealth, } from './ui-health.js';
|
|
29
|
-
import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
|
|
30
|
+
import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, AGE_GATE_YEAR_SELECT, AGE_GATE_YEAR_OPTION, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
|
|
30
31
|
import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
|
|
31
32
|
// A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
|
|
32
33
|
// contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
|
|
@@ -40,6 +41,18 @@ function escapeRegExp(s) {
|
|
|
40
41
|
const CHAT_RE = /chat\.qwen\.ai/i;
|
|
41
42
|
const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
|
|
42
43
|
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
44
|
+
// Cap the stored RAW SSE body. The truncated/no-answer detectors only read the
|
|
45
|
+
// markers (`quasi_status`, `finish_reason`) and the SLICE shown in errors
|
|
46
|
+
// (first/last 300 chars), so keeping a whole multi-MB stream in memory for the
|
|
47
|
+
// rest of the turn is wasteful. The first 64 KB is plenty for every marker we
|
|
48
|
+
// match; the tail (used in error messages) is kept by cutting the middle.
|
|
49
|
+
const MAX_NET_BODY = 64 * 1024;
|
|
50
|
+
function capNetBody(body) {
|
|
51
|
+
if (body.length <= MAX_NET_BODY)
|
|
52
|
+
return body;
|
|
53
|
+
const head = MAX_NET_BODY - 1024;
|
|
54
|
+
return body.slice(0, head) + '\n...[truncated]...\n' + body.slice(-1024);
|
|
55
|
+
}
|
|
43
56
|
export class QwenBrowser {
|
|
44
57
|
providerName = 'qwen';
|
|
45
58
|
headless;
|
|
@@ -268,7 +281,14 @@ export class QwenBrowser {
|
|
|
268
281
|
}
|
|
269
282
|
this.page = this.context.pages()[0] || (await this.context.newPage());
|
|
270
283
|
this._installNetHook();
|
|
271
|
-
|
|
284
|
+
// A slow network (or a cold CDN) can make the initial navigation exceed
|
|
285
|
+
// Playwright's 30s goto timeout. That must NOT abort launch(): the page is
|
|
286
|
+
// usually already usable, and every later step (_awaitInput, waitForLogin)
|
|
287
|
+
// polls. Bailing out here turned a transient slowness into a hard startup
|
|
288
|
+
// failure.
|
|
289
|
+
await this.page
|
|
290
|
+
.goto(CHAT_URL, { waitUntil: 'domcontentloaded' })
|
|
291
|
+
.catch(() => { });
|
|
272
292
|
await this._dismissOverlays();
|
|
273
293
|
}
|
|
274
294
|
async restart() {
|
|
@@ -292,6 +312,22 @@ export class QwenBrowser {
|
|
|
292
312
|
.catch(() => { });
|
|
293
313
|
const gate = this.page.locator(AGE_GATE_MODAL);
|
|
294
314
|
if (await gate.count().catch(() => 0)) {
|
|
315
|
+
// The gate has a YEAR dropdown (default already set) plus Continue.
|
|
316
|
+
// A raw click on Continue can be a no-op until a year is committed,
|
|
317
|
+
// so open the dropdown and pick an option FIRST, then Continue — and
|
|
318
|
+
// finally verify the modal actually closed. Verified live: the year
|
|
319
|
+
// must be selected via the trigger (clicking the label does nothing).
|
|
320
|
+
await this.page
|
|
321
|
+
.locator(AGE_GATE_YEAR_SELECT)
|
|
322
|
+
.first()
|
|
323
|
+
.click({ timeout: 2000 })
|
|
324
|
+
.catch(() => { });
|
|
325
|
+
await this.page.waitForTimeout(400);
|
|
326
|
+
const opt = this.page.locator(AGE_GATE_YEAR_OPTION).first();
|
|
327
|
+
if (await opt.count().catch(() => 0)) {
|
|
328
|
+
await opt.click({ timeout: 2000 }).catch(() => { });
|
|
329
|
+
await this.page.waitForTimeout(300);
|
|
330
|
+
}
|
|
295
331
|
await this.page
|
|
296
332
|
.locator(AGE_GATE_CONTINUE)
|
|
297
333
|
.first()
|
|
@@ -360,7 +396,7 @@ export class QwenBrowser {
|
|
|
360
396
|
// tomorrow).
|
|
361
397
|
if (isGuestLimitText(body)) {
|
|
362
398
|
this._netGuestLimit = true;
|
|
363
|
-
this._netBody = body;
|
|
399
|
+
this._netBody = capNetBody(body);
|
|
364
400
|
this._netCaptureAt = Date.now();
|
|
365
401
|
}
|
|
366
402
|
const isAnswerUrl = ANSWER_URL_RE.test(url);
|
|
@@ -377,7 +413,7 @@ export class QwenBrowser {
|
|
|
377
413
|
// sent it by hand. Bump the throttle so our next send waits.
|
|
378
414
|
this._lastSentAt = Date.now();
|
|
379
415
|
}
|
|
380
|
-
this._netBody = body;
|
|
416
|
+
this._netBody = capNetBody(body);
|
|
381
417
|
const cid = body.match(UUID_RE);
|
|
382
418
|
if (cid)
|
|
383
419
|
this._netChatId = cid[0];
|
|
@@ -1323,6 +1359,11 @@ export class QwenBrowser {
|
|
|
1323
1359
|
if (e instanceof QwenGuestLimitError) {
|
|
1324
1360
|
throw new Error(this._t('qwen.guest_limit'));
|
|
1325
1361
|
}
|
|
1362
|
+
if (e instanceof QwenAgeGateError) {
|
|
1363
|
+
// Not retryable: a resend hits the SAME gate. Fail at once with a
|
|
1364
|
+
// clear "sign in" message instead of burning the retry budget.
|
|
1365
|
+
throw new Error(this._t('qwen.age_gate_block'));
|
|
1366
|
+
}
|
|
1326
1367
|
if (/rate limit|too frequent/i.test(msg)) {
|
|
1327
1368
|
if (rateLimitRetries >= this.maxRateLimitRetries)
|
|
1328
1369
|
break;
|
|
@@ -1400,21 +1441,7 @@ export class QwenBrowser {
|
|
|
1400
1441
|
// the input). Returns the files that FAILED so the prompt can rewrite their
|
|
1401
1442
|
// markers. Mirrors DeepSeekBrowser._attachFiles.
|
|
1402
1443
|
async _attachFiles(files) {
|
|
1403
|
-
const failed =
|
|
1404
|
-
const bufPayload = [];
|
|
1405
|
-
for (const f of files) {
|
|
1406
|
-
try {
|
|
1407
|
-
const buffer = await fs.readFile(f.path);
|
|
1408
|
-
bufPayload.push({ name: f.name, mimeType: f.mime, buffer });
|
|
1409
|
-
}
|
|
1410
|
-
catch (e) {
|
|
1411
|
-
failed.push({ path: f.path, name: f.name });
|
|
1412
|
-
this._notice(theme.warn(this._t('qwen.attach_read_failed', {
|
|
1413
|
-
name: f.name,
|
|
1414
|
-
error: e.message,
|
|
1415
|
-
})));
|
|
1416
|
-
}
|
|
1417
|
-
}
|
|
1444
|
+
const { payload: bufPayload, failed } = await readAttachBuffers(files, (name, error) => this._notice(theme.warn(this._t('qwen.attach_read_failed', { name, error }))));
|
|
1418
1445
|
if (!bufPayload.length)
|
|
1419
1446
|
return failed;
|
|
1420
1447
|
// Primary flow (verified live): click the "+" (`.mode-select-open`) to
|
|
@@ -1459,27 +1486,10 @@ export class QwenBrowser {
|
|
|
1459
1486
|
failed.push({ path: f.path, name: f.name });
|
|
1460
1487
|
return failed;
|
|
1461
1488
|
}
|
|
1462
|
-
// Wait for the upload chip (the file name above the input)
|
|
1463
|
-
//
|
|
1464
|
-
// cannot appear on the page for any other reason.
|
|
1489
|
+
// Wait for the upload chip (the file name above the input). Delegates to the
|
|
1490
|
+
// shared helper so both providers use the SAME preview policy.
|
|
1465
1491
|
async _awaitUploadPreview(names, timeoutMs) {
|
|
1466
|
-
|
|
1467
|
-
if (!clean.length) {
|
|
1468
|
-
await this.page.waitForTimeout(timeoutMs);
|
|
1469
|
-
return;
|
|
1470
|
-
}
|
|
1471
|
-
const deadline = Date.now() + timeoutMs;
|
|
1472
|
-
while (Date.now() < deadline) {
|
|
1473
|
-
for (const name of clean) {
|
|
1474
|
-
const n = await this.page
|
|
1475
|
-
.getByText(name, { exact: false })
|
|
1476
|
-
.count()
|
|
1477
|
-
.catch(() => 0);
|
|
1478
|
-
if (n > 0)
|
|
1479
|
-
return;
|
|
1480
|
-
}
|
|
1481
|
-
await this.page.waitForTimeout(150);
|
|
1482
|
-
}
|
|
1492
|
+
await awaitUploadPreview(this.page, names, timeoutMs);
|
|
1483
1493
|
}
|
|
1484
1494
|
async _setInputText(input, text) {
|
|
1485
1495
|
const tag = await input.evaluate((el) => el.tagName.toLowerCase());
|
|
@@ -1572,6 +1582,11 @@ export class QwenBrowser {
|
|
|
1572
1582
|
const preSendSignal = await this._chatSignal();
|
|
1573
1583
|
let sent = false;
|
|
1574
1584
|
while (!sent && Date.now() < sendDeadline) {
|
|
1585
|
+
// The age-confirmation gate can POP UP on the FIRST send (it is not
|
|
1586
|
+
// present at page load). While it is on screen it swallows the click on
|
|
1587
|
+
// the send button, so the message never goes out. Dismiss it BEFORE each
|
|
1588
|
+
// send attempt; a no-op once it is gone.
|
|
1589
|
+
await this._dismissOverlays();
|
|
1575
1590
|
// (1) the send button, if present/enabled/visible. We remember whether a
|
|
1576
1591
|
// click was actually issued: pressing Enter in the SAME round could send
|
|
1577
1592
|
// the message a SECOND time if the click already worked but the input
|
|
@@ -1626,8 +1641,19 @@ export class QwenBrowser {
|
|
|
1626
1641
|
this._lastSentAt = Date.now();
|
|
1627
1642
|
if (!sent) {
|
|
1628
1643
|
// The text is still in the input after the whole deadline: the message
|
|
1629
|
-
// was NOT sent.
|
|
1630
|
-
//
|
|
1644
|
+
// was NOT sent. A common cause is the age-confirmation gate that pops up
|
|
1645
|
+
// for a GUEST and (verified live) does not always close programmatically
|
|
1646
|
+
// — it swallows the click on the send button. Report that explicitly so
|
|
1647
|
+
// the operator knows to sign in instead of seeing a generic error.
|
|
1648
|
+
const gate = await this.page
|
|
1649
|
+
.locator(AGE_GATE_MODAL)
|
|
1650
|
+
.count()
|
|
1651
|
+
.catch(() => 0);
|
|
1652
|
+
if (gate > 0) {
|
|
1653
|
+
throw new QwenAgeGateError('age-confirmation modal blocks the send');
|
|
1654
|
+
}
|
|
1655
|
+
// Throw so ask() retries the send instead of waiting out the full answer
|
|
1656
|
+
// timeout for a turn that never started.
|
|
1631
1657
|
throw new Error(this._t('qwen.send_button_missing'));
|
|
1632
1658
|
}
|
|
1633
1659
|
// Wait for the answer to start, then for it to settle.
|
|
@@ -1729,6 +1755,14 @@ class QwenGuestLimitError extends Error {
|
|
|
1729
1755
|
this.name = 'QwenGuestLimitError';
|
|
1730
1756
|
}
|
|
1731
1757
|
}
|
|
1758
|
+
// The guest age-confirmation gate swallowed the send. NOT retryable: a resend
|
|
1759
|
+
// hits the same gate, so ask() fails at once instead of burning its retries.
|
|
1760
|
+
class QwenAgeGateError extends Error {
|
|
1761
|
+
constructor(detail) {
|
|
1762
|
+
super('age gate: ' + detail);
|
|
1763
|
+
this.name = 'QwenAgeGateError';
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1732
1766
|
class QwenIncompleteError extends Error {
|
|
1733
1767
|
constructor(detail) {
|
|
1734
1768
|
super('generation incomplete: ' + detail);
|
package/dist/qwen-chats.js
CHANGED
|
@@ -132,26 +132,36 @@ export function parseQwenChatList(json) {
|
|
|
132
132
|
export function parseQwenHistory(json) {
|
|
133
133
|
const root = json;
|
|
134
134
|
const data = root && typeof root === 'object' ? root.data : null;
|
|
135
|
+
// The REAL shape (verified live) is a MAP, not an array:
|
|
136
|
+
// data.chat.history.messages = { <id>: { role, content, timestamp, ... } }
|
|
137
|
+
// An array is accepted too (older shape).
|
|
135
138
|
let arr = null;
|
|
139
|
+
const asArr = (v) => Array.isArray(v) ? v : v && typeof v === 'object' ? Object.values(v) : null;
|
|
136
140
|
if (Array.isArray(data))
|
|
137
141
|
arr = data;
|
|
138
142
|
else if (data && typeof data === 'object') {
|
|
139
143
|
const d = data;
|
|
140
|
-
if (
|
|
141
|
-
arr = d.messages;
|
|
144
|
+
if (d.messages)
|
|
145
|
+
arr = asArr(d.messages);
|
|
142
146
|
else if (d.chat && typeof d.chat === 'object') {
|
|
143
147
|
const chat = d.chat;
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
const hist = chat.history && typeof chat.history === 'object'
|
|
149
|
+
? chat.history
|
|
150
|
+
: null;
|
|
151
|
+
if (hist && hist.messages)
|
|
152
|
+
arr = asArr(hist.messages);
|
|
153
|
+
else if (chat.messages)
|
|
154
|
+
arr = asArr(chat.messages);
|
|
146
155
|
}
|
|
147
156
|
}
|
|
148
|
-
if (!
|
|
157
|
+
if (!arr)
|
|
149
158
|
return [];
|
|
159
|
+
// The map is UNORDERED; `timestamp` (seconds) restores the dialogue order.
|
|
160
|
+
const sorted = arr
|
|
161
|
+
.filter((x) => !!x && typeof x === 'object')
|
|
162
|
+
.sort((a, b) => (Number(a.timestamp) || 0) - (Number(b.timestamp) || 0));
|
|
150
163
|
const out = [];
|
|
151
|
-
for (const
|
|
152
|
-
if (!raw || typeof raw !== 'object')
|
|
153
|
-
continue;
|
|
154
|
-
const m = raw;
|
|
164
|
+
for (const m of sorted) {
|
|
155
165
|
// Role detection is DEFENSIVE: the field has been `role` (string,
|
|
156
166
|
// case-insensitive), a numeric flag, or `is_user`. Anything that is not
|
|
157
167
|
// clearly the user is treated as the assistant (the restored dialogue
|
|
@@ -175,6 +185,15 @@ export function parseQwenHistory(json) {
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
}
|
|
188
|
+
// `content_list` (phase-split output) is used when `content` is empty.
|
|
189
|
+
if (!text && Array.isArray(m.content_list)) {
|
|
190
|
+
for (const part of m.content_list) {
|
|
191
|
+
if (part && typeof part.content === 'string')
|
|
192
|
+
text += part.content;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// `reasoning_content` is the THINK phase and must never leak into the
|
|
196
|
+
// restored dialogue as the answer.
|
|
178
197
|
text = text.trim();
|
|
179
198
|
if (text)
|
|
180
199
|
out.push({ role, text });
|
package/dist/qwen-net-capture.js
CHANGED
|
@@ -199,7 +199,11 @@ export function isRateLimitText(body) {
|
|
|
199
199
|
const b = String(body || '');
|
|
200
200
|
if (!b)
|
|
201
201
|
return false;
|
|
202
|
-
|
|
202
|
+
// `quota_limit` is a SEPARATE code Qwen returns in the SSE error field
|
|
203
|
+
// ("The service is currently experiencing high demand. Please try again
|
|
204
|
+
// later.") — a TRANSIENT capacity limit, so it belongs with the rate-limit
|
|
205
|
+
// family (back off and retry), not with the guest DAILY limit.
|
|
206
|
+
return /too many requests|rate limit|too frequent|please try again later|quota_limit|high demand|слишком часто/i.test(b);
|
|
203
207
|
}
|
|
204
208
|
// The GUEST daily limit is a DIFFERENT failure from a transient rate limit:
|
|
205
209
|
// Qwen answers `RateLimited` with "You've reached the guest chat limit for
|
package/dist/qwen-ui.js
CHANGED
|
@@ -80,7 +80,13 @@ export const MODEL_TRIGGER_SELECTOR = '.wms-trigger';
|
|
|
80
80
|
// A first-time visitor must confirm their birth year before chatting. The
|
|
81
81
|
// dialog is an ant-modal; the Continue button is the black footer button.
|
|
82
82
|
export const AGE_GATE_MODAL = '.age-confirmation-modal';
|
|
83
|
+
// The Continue button inside the age gate (the black footer button).
|
|
83
84
|
export const AGE_GATE_CONTINUE = '.age-confirmation-modal .qwen-chat-v2-btn-black';
|
|
85
|
+
// The year dropdown TRIGGER inside the age gate. It must be clicked to open
|
|
86
|
+
// the year menu; clicking the label alone does not select a year.
|
|
87
|
+
export const AGE_GATE_YEAR_SELECT = '.age-confirmation-year .qwen-chat-v2-dropdown-menu-select';
|
|
88
|
+
// A year OPTION in the opened dropdown (the menu item text is the year).
|
|
89
|
+
export const AGE_GATE_YEAR_OPTION = '.qwen-chat-v2-dropdown-menu-item';
|
|
84
90
|
// The cookie banner's accept button (matched by its visible label).
|
|
85
91
|
export const COOKIE_ACCEPT_RE = /^accept all cookies$/i;
|
|
86
92
|
// ---------- messages / answers ----------
|
package/dist/shell.js
CHANGED
|
@@ -43,6 +43,15 @@ export function assertCommandInsideRoot(root, command) {
|
|
|
43
43
|
throw new Error('Sandbox: leaving ' + root + ' is forbidden (cd ' + raw + ')');
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
// chroot switches the process root outright — always a barrier violation.
|
|
47
|
+
// NOTE: `sh -c '…'` and an in-process `chdir` are deliberately NOT blocked:
|
|
48
|
+
// `bash -c` / `nohup bash -c` is a legitimate way to run a background job,
|
|
49
|
+
// and a blanket ban broke real commands (the operator runs scripts that way).
|
|
50
|
+
// They remain a documented best-effort hole (a static scan cannot follow a
|
|
51
|
+
// runtime target); full isolation needs bwrap/containers.
|
|
52
|
+
if (/\bchroot\b/i.test(cmd)) {
|
|
53
|
+
throw new Error('Sandbox: chroot is forbidden');
|
|
54
|
+
}
|
|
46
55
|
}
|
|
47
56
|
function formatExecResult(err, stdout, stderr, timeout, okExitCodes) {
|
|
48
57
|
const out = (stdout || '').toString();
|
package/dist/tools.js
CHANGED
|
@@ -40,11 +40,11 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
40
40
|
};
|
|
41
41
|
const req = (v, name) => {
|
|
42
42
|
if (v === undefined || v === null) {
|
|
43
|
-
throw new Error(`Missing required argument: ${name}
|
|
43
|
+
throw new Error(`Missing required argument: ${name}. Add "${name}" to the tool call arguments and retry.`);
|
|
44
44
|
}
|
|
45
45
|
const s = String(v);
|
|
46
46
|
if (s === '' || s === 'undefined' || s === 'null') {
|
|
47
|
-
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}
|
|
47
|
+
throw new Error(`Invalid required argument ${name}: ${JSON.stringify(v)}. Provide a non-empty value for "${name}" and retry.`);
|
|
48
48
|
}
|
|
49
49
|
return s;
|
|
50
50
|
};
|
package/dist/transcript.js
CHANGED
|
@@ -49,10 +49,19 @@ export class Transcript {
|
|
|
49
49
|
if (!this.enabled || !this.stream)
|
|
50
50
|
return;
|
|
51
51
|
try {
|
|
52
|
-
|
|
52
|
+
// `write() === false` means the internal buffer is FULL (slow disk /
|
|
53
|
+
// network mount). Node keeps buffering in memory, so a long run of fast
|
|
54
|
+
// events can grow unboundedly; we COUNT the backpressured writes so
|
|
55
|
+
// close() can report them. We still write (dropping trace lines silently
|
|
56
|
+
// would be worse), but the counter makes the condition visible.
|
|
57
|
+
const ok = this.stream.write(line);
|
|
58
|
+
if (!ok)
|
|
59
|
+
this.backpressure++;
|
|
53
60
|
}
|
|
54
61
|
catch { }
|
|
55
62
|
}
|
|
63
|
+
/** Number of writes that hit backpressure (write() === false). */
|
|
64
|
+
backpressure = 0;
|
|
56
65
|
// Flush the stream and resolve once the data reached the file. The callers
|
|
57
66
|
// run on process.exit paths (one-shot --task, browser error, clean exit), and
|
|
58
67
|
// end() without awaiting the callback lets the process die before the buffer
|
|
@@ -70,4 +79,16 @@ export class Transcript {
|
|
|
70
79
|
stream.end(() => resolve());
|
|
71
80
|
});
|
|
72
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* A one-line health note for the operator, or null when the stream stayed
|
|
84
|
+
* healthy. Callers print it on exit so a slow/broken transcript file (the
|
|
85
|
+
* events were buffered in memory instead of reaching disk) is not silent.
|
|
86
|
+
*/
|
|
87
|
+
healthNote() {
|
|
88
|
+
if (this.backpressure === 0)
|
|
89
|
+
return null;
|
|
90
|
+
return ('transcript: ' +
|
|
91
|
+
this.backpressure +
|
|
92
|
+
' write(s) hit backpressure (slow disk?) — events may be delayed');
|
|
93
|
+
}
|
|
73
94
|
}
|
package/dist/xml-toolcall.js
CHANGED
|
@@ -18,6 +18,26 @@ function readAttr(attrs, name) {
|
|
|
18
18
|
const m = attrs.match(re);
|
|
19
19
|
return m ? m[2] : null;
|
|
20
20
|
}
|
|
21
|
+
// Read EVERY key="value" pair from an <invoke ...> tag. The model sometimes
|
|
22
|
+
// emits a tool argument as an ATTRIBUTE of the invoke tag itself (e.g.
|
|
23
|
+
// `<invoke name="Write" path="chess.html">` with only the big `content` as a
|
|
24
|
+
// <parameter> child). readAttr() kept only the tool name, so such an argument
|
|
25
|
+
// was silently DROPPED and the tool failed with 'Missing required argument'.
|
|
26
|
+
// Keys are returned in source order so the first occurrence wins.
|
|
27
|
+
function readAllAttrs(attrs) {
|
|
28
|
+
const Q = String.fromCharCode(34);
|
|
29
|
+
const out = {};
|
|
30
|
+
const re = new RegExp('([A-Za-z_][A-Za-z0-9_]*)[ ]*=[ ]*[' + Q + ']([^' + Q + ']*)[' + Q + ']', 'g');
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(attrs)) !== null) {
|
|
33
|
+
const key = m[1];
|
|
34
|
+
if (key === 'name')
|
|
35
|
+
continue;
|
|
36
|
+
if (!(key in out))
|
|
37
|
+
out[key] = unescapeXml(m[2]);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
21
41
|
// The model sometimes produces a "hybrid" call: the tool name is an attribute
|
|
22
42
|
// AND the arguments are inline JSON in the SAME opening tag, without any
|
|
23
43
|
// <parameter> children. A real case from the transcript:
|
|
@@ -127,6 +147,15 @@ export function parseXmlToolCalls(text) {
|
|
|
127
147
|
const closeMatch = closeRe.exec(rest);
|
|
128
148
|
const body = closeMatch ? rest.slice(0, closeMatch.index) : rest;
|
|
129
149
|
const args = parseParameters(body);
|
|
150
|
+
// Arguments may ALSO arrive as attributes of the invoke tag itself
|
|
151
|
+
// (`<invoke name="Write" path="chess.html">`); merge them in, letting an
|
|
152
|
+
// explicit <parameter> of the same name win (it carries string=true and
|
|
153
|
+
// the exact value). This is what stops `path` from being silently lost.
|
|
154
|
+
const attrArgs = readAllAttrs(attrs);
|
|
155
|
+
for (const k of Object.keys(attrArgs)) {
|
|
156
|
+
if (!(k in args))
|
|
157
|
+
args[k] = attrArgs[k];
|
|
158
|
+
}
|
|
130
159
|
// Hybrid form: the arguments are inline JSON in the SAME invoke tag
|
|
131
160
|
// (e.g. `<|DSML|invoke name="GitAdd", "args" {"paths": "..."}>`), with no
|
|
132
161
|
// <parameter> children. Fall back to the raw invoke head + body.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.73.
|
|
3
|
+
"version": "2.73.9",
|
|
4
4
|
"description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/qwen-smoke.mts
CHANGED
|
@@ -163,6 +163,38 @@ async function main() {
|
|
|
163
163
|
} catch (e) {
|
|
164
164
|
record('toolCall', false, (e as Error).message)
|
|
165
165
|
}
|
|
166
|
+
|
|
167
|
+
// History restore: after the sends above the chat must have messages.
|
|
168
|
+
try {
|
|
169
|
+
const id = await browser.getCurrentChatId()
|
|
170
|
+
const msgs = id ? await browser.fetchChatMessages(id) : []
|
|
171
|
+
record(
|
|
172
|
+
'history',
|
|
173
|
+
msgs.length > 0,
|
|
174
|
+
'id=' + (id || '-') + ' msgs=' + msgs.length,
|
|
175
|
+
)
|
|
176
|
+
} catch (e) {
|
|
177
|
+
record('history', false, (e as Error).message)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Attachments: a SIGNED-IN session uploads a file; a GUEST cannot, so a
|
|
181
|
+
// guest failure is reported as a SKIP (not a failure) — the guest limit is
|
|
182
|
+
// a site restriction, not a regression.
|
|
183
|
+
try {
|
|
184
|
+
const tmpFile = path.join(SMOKE_HOME, 'zames-smoke-note.txt')
|
|
185
|
+
fs.writeFileSync(tmpFile, 'The secret word is BANANA')
|
|
186
|
+
const failed = await browser._attachFiles([
|
|
187
|
+
{ path: tmpFile, name: 'zames-smoke-note.txt', mime: 'text/plain' },
|
|
188
|
+
])
|
|
189
|
+
const attached = failed.length === 0
|
|
190
|
+
const isGuest = !(await browser.isLoggedIn().catch(() => false))
|
|
191
|
+
if (attached) record('attachments', true, '')
|
|
192
|
+
else if (isGuest)
|
|
193
|
+
record('attachments', true, 'skipped (guest cannot upload)')
|
|
194
|
+
else record('attachments', false, JSON.stringify(failed))
|
|
195
|
+
} catch (e) {
|
|
196
|
+
record('attachments', false, (e as Error).message)
|
|
197
|
+
}
|
|
166
198
|
}
|
|
167
199
|
|
|
168
200
|
await browser.close().catch(() => {})
|