zames_pro 2.82.48 → 2.82.50

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 CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.82.50] - 2026-10-23
11
+
12
+ ### Fixed
13
+
14
+ - **i18n:** a prototype member is not a translation
15
+ - **path-token:** split semicolon-separated @file lists
16
+ - **tui:** keep a tall log entry inside the viewport when scrolling
17
+ - **resume:** refuse a session owned by another provider
18
+ - **tui:** skip OSC sequences when measuring log rows
19
+ - **layout:** wrapToWidth keeps every line within the width
20
+ - **tools:** trim allowed-tools entries before matching
21
+ - **config:** getByPath must not walk the prototype chain
22
+ - **context:** strip a BOM before parsing the frontmatter
23
+ - **telemetry:** a provider id must not escape the log directory
24
+ - **throttle:** a non-finite retryAfterMs must not block forever
25
+ - **auth:** do not migrate a legacy GLM marker to deepseek
26
+ - **qwen:** accept a bare array under chat.history
27
+ - **qwen:** a quota_limit mention inside an answer is not a rate limit
28
+ - **glm:** a quota phrase inside an answer is not a quota error
29
+ - **glm:** order the restored dialogue by an ISO timestamp too
30
+ - **attach:** a throwing preview lookup must not abort the attach
31
+ - **handoff:** clear the side handoff files too
32
+ - **loop:** resolve a padded or upper-cased tool name
33
+ - **mcp:** honour a profile flag given inside the command
34
+ - **config:** re-derive the ask deadline when the answer timeout changes live
35
+ - **diff:** compare lines with an LCS instead of by position
36
+ - **goal:** a verdict must not match the head of a word
37
+ - **telemetry:** ignore a send event dated in the future
38
+ - **tui:** show a sandbox refusal as a failed tool call
39
+ - **config:** the list does not mask a whitespace-only password
40
+ - **attach:** never treat a URL as a local file path
41
+ - **chats:** reject a negative accumulated_token_usage on restore
42
+ - **qwen:** never return a thinking-phase object as the answer
43
+ - **glm:** reject a negative token usage
44
+ - **undo:** tolerate a corrupt undo index
45
+ - **permissions:** match a command rule in nested MCP args
46
+ - **status:** fall back to the default context window on an invalid limit
47
+ - **input:** count blank lines at the end of a paste
48
+
49
+ ## [2.82.49] - 2026-10-22
50
+
51
+ ### Fixed
52
+
53
+ - **goal:** /goal on no longer overwrites the goal text
54
+
10
55
  ## [2.82.48] - 2026-10-22
11
56
 
12
57
  ### Fixed
@@ -958,7 +958,17 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
958
958
  }
959
959
  continue;
960
960
  }
961
- const tool = tools.find((t) => t.name === call.tool);
961
+ // Resolve the tool name TOLERANTLY: the model pads it with spaces or
962
+ // a newline (`"tool": " Bash "`) and sometimes changes its case
963
+ // (`bash`). An exact match reported `Unknown tool: Bash ` and the model
964
+ // simply repeated the same call, burning the retry budget. The first
965
+ // exact match still wins, so a name that really is distinct is not
966
+ // merged.
967
+ const wantName = String(call.tool ?? '')
968
+ .trim()
969
+ .toLowerCase();
970
+ const tool = tools.find((t) => t.name === call.tool) ??
971
+ tools.find((t) => t.name.trim().toLowerCase() === wantName);
962
972
  if (!tool) {
963
973
  const err = `Unknown tool: ${call.tool}`;
964
974
  safeToolResult(err);
@@ -36,9 +36,13 @@ export async function awaitUploadPreview(page, names, timeoutMs) {
36
36
  const deadline = Date.now() + timeoutMs;
37
37
  while (Date.now() < deadline) {
38
38
  for (const name of clean) {
39
- const n = await page
40
- .getByText(name, { exact: false })
41
- .count()
39
+ // The lookup itself can THROW synchronously (Playwright: "Execution
40
+ // context was destroyed" while the page navigates). `.catch` handles a
41
+ // rejected promise, not a synchronous throw — without the try/catch the
42
+ // error escaped into `_attachFiles` and the whole attach failed. A
43
+ // lookup error means "not visible yet", exactly like a failed count.
44
+ const n = await Promise.resolve()
45
+ .then(() => page.getByText(name, { exact: false }).count())
42
46
  .catch(() => 0);
43
47
  if (n > 0)
44
48
  return;
@@ -27,6 +27,14 @@ export async function resolveAttachPath(workdir, raw) {
27
27
  s = s.slice(1, -1);
28
28
  }
29
29
  s = s.replace(/\\ /g, ' ');
30
+ // A URL is never a local file. `isImageName` alone would still accept
31
+ // `https://example.com/a.png` (it only looks at the extension), and the
32
+ // RELATIVE candidate `path.resolve(workdir, 'https://…')` then becomes
33
+ // `https:/…` — a project that happens to have such a directory silently
34
+ // attached the URL as a file. A URL preceded by prose words is covered too
35
+ // (the same rule as looksLikeFilePath).
36
+ if (/(?:^|\s)[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(s))
37
+ return null;
30
38
  if (!looksLikeFilePath(s) && !isImageName(s))
31
39
  return null;
32
40
  const candidates = path.isAbsolute(s)
@@ -186,6 +186,15 @@ export function looksLikeFilePath(text) {
186
186
  return false;
187
187
  if (/[\r\n]/.test(s))
188
188
  return false;
189
+ // A URL is not a file path. It contains `/` and ends in a short extension,
190
+ // so it used to pass, and resolveAttachPath then resolved it RELATIVELY
191
+ // (`https://…` -> `https:/…`): a project that happened to contain such a
192
+ // directory silently attached the URL as a local file instead of keeping
193
+ // the text. Any `scheme://` head is rejected here — INCLUDING one that sits
194
+ // after prose words (`see https://x/a.png`), since the whole trimmed text is
195
+ // handed to this check.
196
+ if (/(?:^|\s)[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(s))
197
+ return false;
189
198
  // A bare file name with an image/known extension.
190
199
  if (looksLikeFileName(s))
191
200
  return true;
@@ -27,7 +27,14 @@ export function parseHistoryMessages(messages) {
27
27
  const list = [];
28
28
  let usage = null;
29
29
  for (const raw of messages) {
30
- if (raw && typeof raw.accumulated_token_usage === 'number') {
30
+ // A token counter is a FINITE NON-NEGATIVE number. A negative value is a
31
+ // nonsense payload; it used to become browser._lastTokenUsage as-is, so
32
+ // the context badge vanished (tokenStatusLevel rejects a negative count)
33
+ // and the auto-compact percentage went negative and never fired.
34
+ if (raw &&
35
+ typeof raw.accumulated_token_usage === 'number' &&
36
+ Number.isFinite(raw.accumulated_token_usage) &&
37
+ raw.accumulated_token_usage >= 0) {
31
38
  usage = raw.accumulated_token_usage;
32
39
  }
33
40
  // Roles are matched case-insensitively, and a turn that is neither the
@@ -245,8 +245,14 @@ export function mergeAuthMarker(raw, provider, at) {
245
245
  providers[k] = v || {};
246
246
  }
247
247
  else if (j && typeof j === 'object') {
248
- if (j.provider === 'qwen')
249
- providers.qwen = { at: j.at };
248
+ // A legacy record carries ONE provider name. Only Qwen and GLM ever wrote
249
+ // one (`{provider:'qwen'|'glm', at}`); a BARE `{at}` was DeepSeek. Treating
250
+ // every non-qwen name as DeepSeek filed a legacy GLM session under
251
+ // `deepseek`, so /doctor reported a DeepSeek session and the GLM one
252
+ // vanished.
253
+ const p = typeof j.provider === 'string' ? j.provider : '';
254
+ if (p === 'qwen' || p === 'glm')
255
+ providers[p] = { at: j.at };
250
256
  else if (typeof j.at === 'string')
251
257
  providers.deepseek = { at: j.at };
252
258
  }
package/dist/commands.js CHANGED
@@ -708,6 +708,11 @@ export function parseGoalCommand(text) {
708
708
  return { sub: 'show' };
709
709
  if (rest.toLowerCase() === 'clear' || rest.toLowerCase() === 'off')
710
710
  return { sub: 'clear' };
711
+ // `/goal on` is the natural way to RE-ENABLE a goal after `/goal clear`;
712
+ // treating it as new goal text replaced the goal with the literal word
713
+ // `on`, which no work can satisfy. With no goal set it is a no-op.
714
+ if (rest.toLowerCase() === 'on')
715
+ return { sub: 'show' };
711
716
  return { sub: 'set', goal: rest };
712
717
  }
713
718
  // Replace [image#N] / [file#N] markers in a task with the REAL file path, so
@@ -14,7 +14,10 @@ export function configListValue(field, value, t) {
14
14
  return t('cfg.menu.default');
15
15
  if (typeof value === 'boolean')
16
16
  return value ? t('common.on') : t('common.off');
17
- if (/password/i.test(field.path) && String(value).length > 0)
17
+ // Mask a real secret, but NOT a whitespace-only one — the same rule as
18
+ // configGetValue(): a blank password cannot log in, and masking it makes
19
+ // the list claim a password is configured while auto-login silently fails.
20
+ if (/password/i.test(field.path) && String(value).trim().length > 0)
18
21
  return '********';
19
22
  return JSON.stringify(value);
20
23
  }
@@ -31,6 +31,16 @@ export function applyBrowserLiveConfig(path, value, browser) {
31
31
  if (!field || !(field in browser))
32
32
  return false;
33
33
  browser[field] = value;
34
+ // `answerTimeoutMs` is DERIVED into the watchdog (`askDeadlineMs` = the
35
+ // answer timeout + 60s) by the Qwen/GLM constructors. Pushing only the field
36
+ // left the deadline at its old value, so a LARGER timeout made the agent
37
+ // give up before the browser's own timeout ever fired. Re-derive it the same
38
+ // way the constructor does.
39
+ if (field === 'answerTimeoutMs' && 'askDeadlineMs' in browser) {
40
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
41
+ browser.askDeadlineMs = value + 60_000;
42
+ }
43
+ }
34
44
  return true;
35
45
  }
36
46
  /** Push undo.* / checkpoint.* changes onto the live stores. The stores copy
package/dist/config.js CHANGED
@@ -601,11 +601,23 @@ export function getConfigField(path) {
601
601
  }
602
602
  export function getByPath(obj, path) {
603
603
  const parts = path.split('.');
604
+ // Same guard as setByPath: a dotted path must never reach the PROTOTYPE
605
+ // chain. `__proto__` used to return Object.prototype (an object), and
606
+ // `constructor` / `toString` the corresponding FUNCTION — the /config list
607
+ // then rendered `{}` for a field whose path did not exist instead of the
608
+ // default marker. Reading those keys is never meaningful for a config path.
609
+ // `toString`, `valueOf`, `hasOwnProperty`, ... are inherited METHODS, not
610
+ // config values: reading them through a dotted path returned a FUNCTION.
611
+ // Only an OWN property counts.
612
+ if (parts.some((p) => UNSAFE_PATH_KEYS.has(p)))
613
+ return undefined;
604
614
  let cur = obj;
605
615
  for (const p of parts) {
606
616
  if (cur === null || cur === undefined || typeof cur !== 'object') {
607
617
  return undefined;
608
618
  }
619
+ if (!Object.prototype.hasOwnProperty.call(cur, p))
620
+ return undefined;
609
621
  cur = cur[p];
610
622
  }
611
623
  return cur;
@@ -0,0 +1,6 @@
1
+ import { formatContextSources } from './commands.js';
2
+ import { translate } from './i18n.js';
3
+ /** Render the `/context` report for already-loaded sources. */
4
+ export function renderContextReport(src, opts = {}, t = translate('en')) {
5
+ return formatContextSources(src.agents || [], src.memory || [], src.skills || [], src.commands || [], opts, t);
6
+ }
package/dist/context.js CHANGED
@@ -245,7 +245,14 @@ const PROJECT_SKILL_DIRS = [
245
245
  'skills',
246
246
  ];
247
247
  function parseFrontmatter(raw) {
248
- const norm = raw.replace(CRLF_RE, NL);
248
+ // A leading BOM must be dropped BEFORE the `---` check: Windows editors and
249
+ // generators write one, and without this the frontmatter was not recognised
250
+ // at all (description / argument-hint / arguments silently lost).
251
+ // `skillBody` already strips it; the two must agree.
252
+ const bom = String.fromCharCode(0xfeff);
253
+ const norm = String(raw ?? '')
254
+ .replace(new RegExp('^' + bom), '')
255
+ .replace(CRLF_RE, NL);
249
256
  if (!norm.startsWith('---'))
250
257
  return {};
251
258
  const end = norm.indexOf(NL + '---', 3);
package/dist/diff.js CHANGED
@@ -2,22 +2,65 @@ import { theme } from './theme.js';
2
2
  export function unifiedDiff(a, b, { label = '' } = {}) {
3
3
  const la = a.split(String.fromCharCode(10));
4
4
  const lb = b.split(String.fromCharCode(10));
5
- const n = Math.max(la.length, lb.length);
6
5
  const out = [];
7
6
  if (label)
8
7
  out.push('... ' + label);
9
- for (let i = 0; i < n; i++) {
10
- const x = la[i];
11
- const y = lb[i];
12
- if (x === y)
8
+ // A LINE diff, not a positional compare: an insertion at the top of a
9
+ // file shifts every following line, and comparing line i with line i
10
+ // reported the WHOLE file as removed AND added (a 20-line file with one
11
+ // new first line produced 41 diff lines). The undo/`/diff` preview became
12
+ // unreadable and the model was told everything changed. A longest-common-
13
+ // subsequence walk emits only the lines that really differ.
14
+ for (const op of diffOps(la, lb)) {
15
+ if (op.kind === 'same')
13
16
  continue;
14
- if (x !== undefined)
15
- out.push('-' + x);
16
- if (y !== undefined)
17
- out.push('+' + y);
17
+ out.push((op.kind === 'del' ? '-' : '+') + op.line);
18
18
  }
19
19
  return out.join(String.fromCharCode(10));
20
20
  }
21
+ /**
22
+ * Longest-common-subsequence line diff (Hunt–Szymanski style table, O(n*m)
23
+ * memory). Files here are a handful of lines (a single edit preview), so the
24
+ * simple dynamic-programming table is fine and keeps the module dependency-
25
+ * free. A change pair is emitted as `-old` then `+new`, like a real diff.
26
+ */
27
+ function diffOps(a, b) {
28
+ const n = a.length;
29
+ const m = b.length;
30
+ // lcs[i][j] = length of the LCS of a[i..] and b[j..].
31
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
32
+ for (let i = n - 1; i >= 0; i--) {
33
+ for (let j = m - 1; j >= 0; j--) {
34
+ lcs[i][j] =
35
+ a[i] === b[j]
36
+ ? lcs[i + 1][j + 1] + 1
37
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
38
+ }
39
+ }
40
+ const ops = [];
41
+ let i = 0;
42
+ let j = 0;
43
+ while (i < n && j < m) {
44
+ if (a[i] === b[j]) {
45
+ ops.push({ kind: 'same', line: a[i] });
46
+ i++;
47
+ j++;
48
+ }
49
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
50
+ ops.push({ kind: 'del', line: a[i] });
51
+ i++;
52
+ }
53
+ else {
54
+ ops.push({ kind: 'add', line: b[j] });
55
+ j++;
56
+ }
57
+ }
58
+ while (i < n)
59
+ ops.push({ kind: 'del', line: a[i++] });
60
+ while (j < m)
61
+ ops.push({ kind: 'add', line: b[j++] });
62
+ return ops;
63
+ }
21
64
  export function colorDiff(diffText) {
22
65
  return diffText
23
66
  .split(String.fromCharCode(10))
package/dist/glm-chats.js CHANGED
@@ -157,9 +157,26 @@ export function parseGlmHistory(json) {
157
157
  : null;
158
158
  if (!arr)
159
159
  return [];
160
+ // The map is UNORDERED and `timestamp` restores the dialogue order. The
161
+ // value is a NUMBER or a numeric string in the builds seen so far, but an
162
+ // ISO stamp (`2026-10-23T10:00:00Z`) makes `Number(...)` NaN, and a NaN
163
+ // comparator keeps the INSERTION order — a scrambled dialogue. Fall back to
164
+ // Date.parse for a non-numeric string.
165
+ const stampOf = (m) => {
166
+ const raw = m.timestamp;
167
+ const n = Number(raw);
168
+ if (Number.isFinite(n) && n !== 0)
169
+ return n;
170
+ if (typeof raw === 'string') {
171
+ const t = Date.parse(raw);
172
+ if (Number.isFinite(t))
173
+ return t;
174
+ }
175
+ return 0;
176
+ };
160
177
  const sorted = arr
161
178
  .filter((x) => !!x && typeof x === 'object')
162
- .sort((a, b) => (Number(a.timestamp) || 0) - (Number(b.timestamp) || 0));
179
+ .sort((a, b) => stampOf(a) - stampOf(b));
163
180
  const out = [];
164
181
  for (const m of sorted) {
165
182
  // The role comes from `role` (confirmed by the live fixture). Only the
@@ -172,6 +172,10 @@ export function extractAnswer(body) {
172
172
  return sse;
173
173
  return extractFromJson(body);
174
174
  }
175
+ /** A usable token count: a FINITE NON-NEGATIVE number. */
176
+ function isUsageCount(v) {
177
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
178
+ }
175
179
  /**
176
180
  * The CONTEXT size (tokens) from `usage.prompt_tokens` - the prompt size for
177
181
  * the turn, the same idea as DeepSeek's accumulated_token_usage. Falls back to
@@ -186,9 +190,14 @@ export function extractTokenUsage(body) {
186
190
  if (!usage || typeof usage !== 'object')
187
191
  continue;
188
192
  const u = usage;
189
- if (typeof u.prompt_tokens === 'number' && Number.isFinite(u.prompt_tokens))
193
+ // A token count is a FINITE NON-NEGATIVE number. A negative payload (a
194
+ // nonsense reply, or a wrapped `int` in a proxy build) used to become
195
+ // `browser._lastTokenUsage` as-is: the context badge disappeared
196
+ // (tokenStatusLevel rejects it) while the auto-compact percentage went
197
+ // negative and never fired. Treat it as "no usage", like a non-number.
198
+ if (isUsageCount(u.prompt_tokens))
190
199
  lastPrompt = u.prompt_tokens;
191
- if (typeof u.total_tokens === 'number' && Number.isFinite(u.total_tokens))
200
+ if (isUsageCount(u.total_tokens))
192
201
  lastTotal = u.total_tokens;
193
202
  }
194
203
  if (lastPrompt !== null)
@@ -205,9 +214,9 @@ export function extractTokenUsage(body) {
205
214
  : null;
206
215
  const usage = ((envelope ? envelope.usage : undefined) ?? obj?.usage);
207
216
  if (usage) {
208
- if (typeof usage.prompt_tokens === 'number')
217
+ if (isUsageCount(usage.prompt_tokens))
209
218
  return usage.prompt_tokens;
210
- if (typeof usage.total_tokens === 'number')
219
+ if (isUsageCount(usage.total_tokens))
211
220
  return usage.total_tokens;
212
221
  }
213
222
  }
@@ -279,7 +288,16 @@ const ERROR_PAYLOAD_RE = /"type"\s*:\s*"error"|"error"\s*:|\{\s*"code"\s*:/i;
279
288
  * message instead of retrying for the whole budget.
280
289
  */
281
290
  export function isQuotaExhaustedText(body) {
282
- return /insufficient_quota|quota (?:exceeded|exhausted)|exceeded your current quota/i.test(String(body || ''));
291
+ const b = String(body || '');
292
+ if (!b)
293
+ return false;
294
+ // Same guard as every other z.ai classifier: a normal ANSWER may legitimately
295
+ // discuss `insufficient_quota` / "exceeded your current quota" (the operator
296
+ // asked how to handle that API error). Without this the answer was taken for
297
+ // a refusal and a good turn was abandoned with `glm.quota_exhausted`.
298
+ if (!ERROR_PAYLOAD_RE.test(b) && hasAnswerContent(b))
299
+ return false;
300
+ return /insufficient_quota|quota (?:exceeded|exhausted)|exceeded your current quota/i.test(b);
283
301
  }
284
302
  export function isRateLimitText(body) {
285
303
  const b = String(body || '');
package/dist/goal.js CHANGED
@@ -96,7 +96,12 @@ export function parseGoalVerdict(text) {
96
96
  // separator is checked on the character FOLLOWING the matched value (a
97
97
  // lookahead INSIDE the value pattern was useless: the value alternatives
98
98
  // always end on a word boundary of their own).
99
- const label = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)/gi;
99
+ // A right boundary is REQUIRED: without it `VERDICT: METS` /
100
+ // `METADATA` parsed as MET, and MET CLEARS the goal — an unfinished task
101
+ // looked done. `\b` alone is not enough: in `MET-ish` the hyphen is a word
102
+ // boundary, so a hyphen/underscore directly after the value is rejected
103
+ // explicitly (a hyphen is only legal INSIDE `NOT-MET`).
104
+ const label = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)(?![\w-])/gi;
100
105
  let m;
101
106
  let status = null;
102
107
  while ((m = label.exec(t)) !== null) {
@@ -183,4 +183,8 @@ export const selfReviewMessages = {
183
183
  ru: 'Использование: /resume-id <chat id>',
184
184
  en: 'Usage: /resume-id <chat id>',
185
185
  },
186
+ 'sessions.provider_mismatch': {
187
+ ru: 'Сессия принадлежит провайдеру {owner}, а активен {active}: чат не откроется. Переключись командой /provider {owner} и повтори.',
188
+ en: 'This session belongs to {owner}, but {active} is active: the chat will not open. Switch with /provider {owner} and retry.',
189
+ },
186
190
  };
package/dist/i18n.js CHANGED
@@ -55,7 +55,13 @@ export const CATALOG = {
55
55
  export function translate(locale) {
56
56
  const loc = isLocale(locale) ? locale : DEFAULT_LOCALE;
57
57
  return (key, params) => {
58
- const entry = CATALOG[key];
58
+ // A plain-object lookup ALSO resolves Object.prototype members, so a key
59
+ // like 'toString' returned the inherited FUNCTION: the caller got
60
+ // `undefined` (no params) or a TypeError (`.replace is not a function`).
61
+ // Only an OWN entry is a translation.
62
+ const entry = Object.prototype.hasOwnProperty.call(CATALOG, key)
63
+ ? CATALOG[key]
64
+ : undefined;
59
65
  const template = entry ? entry[loc] || entry[DEFAULT_LOCALE] : key;
60
66
  if (!params)
61
67
  return template;