zames_pro 2.78.3 → 2.80.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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.80.0] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - **commands:** keep an unsupplied position as written in a custom command
15
+ - **subagent:** count a subagent run only once it actually starts
16
+ - **attach:** do not cut an inlined @file inside a surrogate pair
17
+ - **checkpoint:** a bare out-of-range rewind index no longer matches a stamp prefix
18
+ - **git:** split a space-separated paths argument in GitAdd
19
+ - **mcp:** keep an MCP tool result inside its size cap
20
+ - **hooks:** never cut a hook denial or notice inside a surrogate pair
21
+ - **sessions:** escape a literal underscore in the session file name
22
+
23
+ ## [2.79.0] - 2026-10-19
24
+
25
+ ### Fixed
26
+
27
+ - **qwen:** match the history 'from' role case-insensitively and accept human
28
+ - **browser-chats:** match the history role case-insensitively and drop service turns
29
+ - **extratools:** apply multi-hunk ApplyPatch updates and honour @@ line numbers
30
+ - **web:** drop non-http(s) links from WebSearch results
31
+ - **web:** judge 6to4 and Teredo addresses by the embedded IPv4
32
+ - **permissions:** match path rules against arrays and nested MCP args
33
+ - **scheduler:** reject empty and malformed cron field tokens
34
+ - **qwen:** read numeric and map-keyed ids in the chat list
35
+ - **xml-toolcall:** a stray brace before inline JSON no longer eats the args
36
+ - **xml-toolcall:** keep a </parameter> literal inside a parameter value
37
+ - **qwen:** detect a finished status carried on the choice, not only the delta
38
+ - **config:** reject an empty value for a number setting
39
+ - **goal:** an echoed label set is not the evaluator verdict
40
+ - **input:** expand each paste into its own marker occurrence
41
+ - **compact:** a summary that mentions DSML in prose is usable
42
+
10
43
  ## [2.78.3] - 2026-10-18
11
44
 
12
45
  ### Fixed
@@ -94,7 +94,15 @@ export async function inlineAtRefs(workdir, text) {
94
94
  let body = raw;
95
95
  let note = '';
96
96
  if (body.length > AT_REF_FILE_CAP) {
97
- body = body.slice(0, AT_REF_FILE_CAP);
97
+ let cut = AT_REF_FILE_CAP;
98
+ // Never end the inlined block on a lone HIGH surrogate: the cut is in
99
+ // UTF-16 units, so an emoji / astral CJK char on the boundary would
100
+ // reach the model as a broken code point (same rule as
101
+ // truncateToolResult / capReport / cutHookText).
102
+ const last = body.charCodeAt(cut - 1);
103
+ if (last >= 0xd800 && last <= 0xdbff)
104
+ cut--;
105
+ body = body.slice(0, cut);
98
106
  note = NL + NL + '(truncated at ' + AT_REF_FILE_CAP + ' chars)';
99
107
  }
100
108
  total += body.length;
@@ -30,7 +30,15 @@ export function parseHistoryMessages(messages) {
30
30
  if (raw && typeof raw.accumulated_token_usage === 'number') {
31
31
  usage = raw.accumulated_token_usage;
32
32
  }
33
- const role = raw && raw.role === 'ASSISTANT' ? 'assistant' : 'user';
33
+ // Roles are matched case-insensitively, and a turn that is neither the
34
+ // operator nor the assistant (SYSTEM, TOOL, a future role) is DROPPED: the
35
+ // old `role === 'ASSISTANT'` fallback read every other turn as the
36
+ // operator's, so a lowercase assistant answer was printed as the operator's
37
+ // text in the restored dialogue.
38
+ const rawRole = String((raw && raw.role) || '').toUpperCase();
39
+ if (rawRole !== 'ASSISTANT' && rawRole !== 'USER')
40
+ continue;
41
+ const role = rawRole === 'ASSISTANT' ? 'assistant' : 'user';
34
42
  const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
35
43
  let text = '';
36
44
  for (const fr of (raw && raw.fragments) || []) {
@@ -50,10 +50,15 @@ export function pickRecord(records, selector) {
50
50
  return null;
51
51
  if (/^\d+$/.test(sel)) {
52
52
  const n = Number(sel);
53
- // A small number is an index (1 = newest). An out-of-range number is
54
- // still tried as a stamp prefix below, so `/rewind 1730000` works.
53
+ // A small number is an index (1 = newest).
55
54
  if (n >= 1 && n <= records.length)
56
55
  return records[n - 1];
56
+ // A stamp is epoch-ms (~13 digits), but older files and tests may hold
57
+ // a SHORT one, so the prefix form stays. What must NOT happen is a bare
58
+ // out-of-range INDEX matching a stamp by its first digit (`/rewind 9`
59
+ // with 5 checkpoints): the prefix form therefore needs TWO digits.
60
+ if (sel.length < 2)
61
+ return null;
57
62
  }
58
63
  return records.find((r) => String(r.stamp).startsWith(sel)) || null;
59
64
  }
package/dist/commands.js CHANGED
@@ -544,15 +544,26 @@ export function isUsableCompactSummary(text) {
544
544
  return false;
545
545
  if (/^\(прервано пользователем\)$/.test(t))
546
546
  return false;
547
+ // A tool call must be RECOGNIZED as one: a plain word `DSML` in prose (the
548
+ // summary describing the feature) is not noise. Requiring the tag/DSML
549
+ // syntax keeps the guard on real protocol output without rejecting a valid
550
+ // summary that merely mentions the feature (which made every retry fail and
551
+ // forced the local fallback).
547
552
  if (parseAssistantToolCall(t))
548
553
  return false;
549
- if (/DSML/i.test(t))
554
+ // A REAL DSML call carries the marker punctuation (`<|DSML|invoke`); a
555
+ // summary may MENTION the feature by name in prose. Matching the bare word
556
+ // rejected a perfectly good summary and every retry failed the same way.
557
+ if (/<\s*[\uFF5C|]\s*DSML/i.test(t))
550
558
  return false;
551
559
  // A tool-call body without a recognizable head (a cut-off stream) still
552
560
  // carries "args": { ... }.
553
561
  if (/"args"\s*:\s*\{/.test(t))
554
562
  return false;
555
- if (/<ds_safety>/i.test(t))
563
+ // A REAL safety block is the marker, not a sentence that mentions it: the
564
+ // closing form `</ds_safety>` is what a summary would NOT write in prose
565
+ // (a summary may say "no <ds_safety> marker appeared").
566
+ if (/<\/ds_safety\s*>/i.test(t))
556
567
  return false;
557
568
  return true;
558
569
  }
@@ -762,7 +773,11 @@ function replaceDollarTokens(s, parts, names) {
762
773
  continue;
763
774
  }
764
775
  if (isAllDigits(tok)) {
765
- res += parts[Number(tok) - 1] ?? '';
776
+ const idx = Number(tok) - 1;
777
+ // A position that was NOT supplied stays as written: `$100` in prose
778
+ // is a PRICE, not "position 100", and replacing it with an empty
779
+ // string silently deleted the number from the prompt.
780
+ res += idx >= 0 && idx < parts.length ? parts[idx] : '$' + tok;
766
781
  }
767
782
  else {
768
783
  const idx = names.indexOf(tok);
package/dist/config.js CHANGED
@@ -527,6 +527,12 @@ function parseConfigValue(field, raw) {
527
527
  throw new Error('boolean');
528
528
  }
529
529
  case 'number': {
530
+ // `Number('')` and `Number(' ')` are 0, NOT NaN: an empty value for a
531
+ // field with min 0 passed validation and silently BECAME 0 (an operator
532
+ // clearing the edit buffer turned retries/backups OFF instead of being
533
+ // told the value is invalid).
534
+ if (raw.trim() === '')
535
+ throw new Error('number');
530
536
  const n = Number(raw);
531
537
  if (!Number.isFinite(n))
532
538
  throw new Error('number');
@@ -100,81 +100,119 @@ export function parsePatch(text) {
100
100
  continue;
101
101
  }
102
102
  if (current)
103
- current.lines.push(line);
103
+ current.lines.push(line.replace(/\r$/, ''));
104
104
  }
105
105
  if (!ops.length)
106
106
  return { ops: [], error: 'Patch contains no file operations.' };
107
107
  return { ops };
108
108
  }
109
- /**
110
- * Apply an update hunk to existing content using context matching.
111
- * The hunk is a list of lines prefixed with ' ' (context), '-' (remove),
112
- * '+' (add). We locate the context block by exact match, then by ignoring
113
- * trailing whitespace, then by ignoring all whitespace (like Codex).
114
- */
115
- export function applyUpdateHunk(content, hunkLines) {
116
- const hasCRLF = content.includes('\r\n');
117
- const fileLines = content.split(/\r?\n/);
118
- const oldLines = [];
119
- const newLines = [];
109
+ /** Split a hunk list into per-`@@` blocks, remembering each header position. */
110
+ function splitHunks(hunkLines) {
111
+ const blocks = [{ oldStart: null, lines: [] }];
120
112
  for (const l of hunkLines) {
121
- if (l.startsWith('@@'))
113
+ if (l.startsWith('@@')) {
114
+ const m = l.match(/^@@\s*-(\d+)/);
115
+ blocks.push({ oldStart: m ? Number(m[1]) : null, lines: [] });
122
116
  continue;
123
- if (l.startsWith('+')) {
124
- newLines.push(l.slice(1));
125
- }
126
- else if (l.startsWith('-')) {
127
- oldLines.push(l.slice(1));
128
- }
129
- else if (l.startsWith(' ')) {
130
- oldLines.push(l.slice(1));
131
- newLines.push(l.slice(1));
132
- }
133
- else if (l === '') {
134
- oldLines.push('');
135
- newLines.push('');
136
117
  }
137
- else {
138
- oldLines.push(l);
139
- newLines.push(l);
140
- }
141
- }
142
- if (!oldLines.length) {
143
- return {
144
- ok: false,
145
- error: 'Hunk has no context or removed lines to anchor on.',
146
- };
118
+ blocks[blocks.length - 1].lines.push(l);
147
119
  }
148
- const norm = (s, mode) => {
149
- if (mode === 'exact')
150
- return s;
151
- if (mode === 'trim')
152
- return s.replace(/[ \t]+$/, '');
153
- return s.replace(/\s+/g, '');
154
- };
120
+ return blocks.filter((b) => b.lines.length > 0);
121
+ }
122
+ const hunkNorm = (s, mode) => {
123
+ if (mode === 'exact')
124
+ return s;
125
+ if (mode === 'trim')
126
+ return s.replace(/[ \t]+$/, '');
127
+ return s.replace(/\s+/g, '');
128
+ };
129
+ /**
130
+ * Find where a block's old side starts in `fileLines`.
131
+ * `from` is the lowest index allowed (hunks must not overlap / go backwards).
132
+ * When the header named a line, that position is preferred: repeated context
133
+ * is otherwise ambiguous and the wrong occurrence gets patched.
134
+ */
135
+ function locateBlock(fileLines, oldLines, oldStart, from) {
136
+ const maxStart = fileLines.length - oldLines.length;
137
+ if (maxStart < from)
138
+ return -1;
155
139
  for (const mode of ['exact', 'trim', 'ws']) {
156
- const anchor = oldLines.map((l) => norm(l, mode));
157
- for (let i = 0; i + anchor.length <= fileLines.length; i++) {
158
- let match = true;
140
+ const anchor = oldLines.map((l) => hunkNorm(l, mode));
141
+ const matches = (i) => {
159
142
  for (let j = 0; j < anchor.length; j++) {
160
- if (norm(fileLines[i + j], mode) !== anchor[j]) {
161
- match = false;
162
- break;
163
- }
143
+ if (hunkNorm(fileLines[i + j], mode) !== anchor[j])
144
+ return false;
164
145
  }
165
- if (!match)
146
+ return true;
147
+ };
148
+ if (oldStart !== null) {
149
+ const hinted = oldStart - 1;
150
+ if (hinted >= from && hinted <= maxStart && matches(hinted))
151
+ return hinted;
152
+ }
153
+ for (let i = from; i <= maxStart; i++) {
154
+ if (matches(i))
155
+ return i;
156
+ }
157
+ }
158
+ return -1;
159
+ }
160
+ /**
161
+ * Apply an update hunk to existing content using context matching.
162
+ * The hunk is a list of lines prefixed with ' ' (context), '-' (remove),
163
+ * '+' (add). Several `@@` hunks for one file are applied in order, top to
164
+ * bottom. A block is located by exact match, then ignoring trailing
165
+ * whitespace, then ignoring all whitespace (like Codex).
166
+ */
167
+ export function applyUpdateHunk(content, hunkLines) {
168
+ const hasCRLF = content.includes('\r\n');
169
+ const eol = hasCRLF ? '\r\n' : NL;
170
+ let fileLines = content.split(/\r?\n/);
171
+ for (const block of splitHunks(hunkLines)) {
172
+ const oldLines = [];
173
+ const newLines = [];
174
+ for (const l of block.lines) {
175
+ // A blank hunk line is the newline BEFORE "*** End Patch", not a
176
+ // context line: counting it as context demanded an extra blank file
177
+ // line and made every realistic multi-hunk patch fail.
178
+ if (l === '')
166
179
  continue;
167
- const before = fileLines.slice(0, i);
168
- const after = fileLines.slice(i + anchor.length);
169
- const joined = [...before, ...newLines, ...after].join(hasCRLF ? '\r\n' : NL);
170
- return { ok: true, content: joined };
180
+ if (l.startsWith('+')) {
181
+ newLines.push(l.slice(1));
182
+ }
183
+ else if (l.startsWith('-')) {
184
+ oldLines.push(l.slice(1));
185
+ }
186
+ else if (l.startsWith(' ')) {
187
+ oldLines.push(l.slice(1));
188
+ newLines.push(l.slice(1));
189
+ }
190
+ else {
191
+ oldLines.push(l);
192
+ newLines.push(l);
193
+ }
194
+ }
195
+ if (!oldLines.length) {
196
+ return {
197
+ ok: false,
198
+ error: 'Hunk has no context or removed lines to anchor on.',
199
+ };
171
200
  }
201
+ const at = locateBlock(fileLines, oldLines, block.oldStart, 0);
202
+ if (at === -1) {
203
+ return {
204
+ ok: false,
205
+ error: 'Context not found in the file (hunk did not match): ' +
206
+ oldLines[0].slice(0, 60),
207
+ };
208
+ }
209
+ fileLines = [
210
+ ...fileLines.slice(0, at),
211
+ ...newLines,
212
+ ...fileLines.slice(at + oldLines.length),
213
+ ];
172
214
  }
173
- return {
174
- ok: false,
175
- error: 'Context not found in the file (hunk did not match): ' +
176
- oldLines[0].slice(0, 60),
177
- };
215
+ return { ok: true, content: fileLines.join(eol) };
178
216
  }
179
217
  // ---------- factory ----------
180
218
  export function createExtraTools(workdir, { undo, todos } = {}) {
package/dist/gitTools.js CHANGED
@@ -44,6 +44,39 @@ export function runGitArgs(args, cwd, timeout = 15_000) {
44
44
  });
45
45
  });
46
46
  }
47
+ // Split a `paths` argument into individual pathspecs. The parameter is
48
+ // declared as a STRING, so models send "a.txt b.txt" or a quoted path with a
49
+ // space ("my file.txt"); git needs each path as its OWN argument.
50
+ export function splitPaths(input) {
51
+ const out = [];
52
+ const s = String(input || '');
53
+ let cur = '';
54
+ let quote = null;
55
+ for (let i = 0; i < s.length; i++) {
56
+ const ch = s[i];
57
+ if (quote) {
58
+ if (ch === quote)
59
+ quote = null;
60
+ else
61
+ cur += ch;
62
+ continue;
63
+ }
64
+ if (ch === '"' || ch === "'") {
65
+ quote = ch;
66
+ continue;
67
+ }
68
+ if (ch === ' ' || ch === '\t' || ch === '\n') {
69
+ if (cur)
70
+ out.push(cur);
71
+ cur = '';
72
+ continue;
73
+ }
74
+ cur += ch;
75
+ }
76
+ if (cur)
77
+ out.push(cur);
78
+ return out;
79
+ }
47
80
  export async function getGitContext(workdir) {
48
81
  const probe = await runGitArgs(['rev-parse', '--is-inside-work-tree'], workdir, 5000);
49
82
  if (probe.trim() !== 'true')
@@ -215,16 +248,27 @@ export function createGitTools(workdir) {
215
248
  if (err)
216
249
  return err;
217
250
  if (paths && String(paths).trim()) {
218
- const list = (Array.isArray(paths) ? paths : [paths])
251
+ // `paths` is declared as a STRING, so the model sends
252
+ // "a.txt b.txt". Passing it as ONE pathspec made git answer
253
+ // `did not match any files` and staged nothing. Split on
254
+ // whitespace, honour quotes (a path may contain a space), and
255
+ // keep the array form working.
256
+ const list = (Array.isArray(paths) ? paths : splitPaths(String(paths)))
219
257
  .map((v) => String(v).trim())
220
258
  .filter(Boolean);
221
259
  for (const p of list) {
222
260
  if (p.startsWith('-'))
223
261
  return `Error: invalid path: ${p}`;
224
262
  }
225
- return runGitArgs(['add', '--', ...list], workdir, 20_000);
263
+ const staged = await runGitArgs(['add', '--', ...list], workdir, 20_000);
264
+ // A successful `git add` prints NOTHING; the shared runner then
265
+ // answers the internal sentinel, which reads as a failure.
266
+ return staged.trim() === NO_OUTPUT_SENTINEL
267
+ ? 'Staged: ' + list.join(' ')
268
+ : staged;
226
269
  }
227
- return runGitArgs(['add', '-A'], workdir, 20_000);
270
+ const all = await runGitArgs(['add', '-A'], workdir, 20_000);
271
+ return all.trim() === NO_OUTPUT_SENTINEL ? 'Staged all changes.' : all;
228
272
  },
229
273
  },
230
274
  {
package/dist/goal.js CHANGED
@@ -86,11 +86,34 @@ export function parseGoalVerdict(text) {
86
86
  // — the goal loop then auto-continued until the turn budget although the
87
87
  // judge had already said NOT_MET. `MET` is tried LAST so it cannot match the
88
88
  // tail of `NOT MET`.
89
- const m = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)/i.exec(t);
90
- if (!m)
89
+ //
90
+ // The evaluator sometimes ECHOES the requested label set before the real
91
+ // verdict (`VERDICT: MET | NOT_MET | IMPOSSIBLE`). Taking the FIRST match
92
+ // read the echo's `MET` as the verdict while REASON said the opposite, and
93
+ // MET CLEARS the goal — an unfinished task was silently accepted as done.
94
+ // A verdict line with a separator after the value is an echo, not a verdict;
95
+ // scan EVERY `VERDICT:` occurrence and take the first REAL one. The
96
+ // separator is checked on the character FOLLOWING the matched value (a
97
+ // lookahead INSIDE the value pattern was useless: the value alternatives
98
+ // always end on a word boundary of their own).
99
+ const label = /VERDICT\s*[*_`]*\s*:\s*[*_`]*\s*(IMPOSSIBLE|NOT[_ -]?MET|MET)/gi;
100
+ let m;
101
+ let status = null;
102
+ while ((m = label.exec(t)) !== null) {
103
+ // Skip whitespace/backticks between the value and whatever follows.
104
+ let k = m.index + m[0].length;
105
+ while (k < t.length && /[\s*_`]/.test(t.charAt(k)))
106
+ k++;
107
+ const after = t.charAt(k);
108
+ if (after === '|' || after === '/')
109
+ continue;
110
+ const raw = m[1].toUpperCase();
111
+ status =
112
+ raw === 'MET' ? 'met' : raw === 'IMPOSSIBLE' ? 'impossible' : 'not_met';
113
+ break;
114
+ }
115
+ if (!status)
91
116
  return null;
92
- const raw = m[1].toUpperCase();
93
- const status = raw === 'MET' ? 'met' : raw === 'IMPOSSIBLE' ? 'impossible' : 'not_met';
94
117
  const rm = /REASON\s*[*_`]*\s*:\s*(.+)/i.exec(t);
95
118
  const reason = rm
96
119
  ? rm[1]
package/dist/hooks.js CHANGED
@@ -18,6 +18,19 @@ const HOOK_DENIAL_CAP = 4000;
18
18
  // Combined stdout of a PostToolUse / lifecycle hook. It lands in a tool result
19
19
  // or in the chat, so it is a notice, not a log dump.
20
20
  const HOOK_OUTPUT_CAP = 8000;
21
+ // Cut a hook string by UTF-16 units WITHOUT leaving a lone HIGH surrogate at
22
+ // the end: the text becomes a tool result or a chat notice, and a broken code
23
+ // point there is the same bug truncated elsewhere (same rule as
24
+ // truncateToolResult / capReport / summarizeDiagnostics).
25
+ function cutHookText(s, max) {
26
+ if (s.length <= max)
27
+ return s;
28
+ let cut = Math.max(0, max);
29
+ const last = s.charCodeAt(cut - 1);
30
+ if (last >= 0xd800 && last <= 0xdbff)
31
+ cut--;
32
+ return s.slice(0, cut);
33
+ }
21
34
  export function hooksConfigPath(workdir) {
22
35
  return path.join(workdir, '.zames', 'hooks.json');
23
36
  }
@@ -150,7 +163,7 @@ export async function runPreToolUse(hooks, tool, args, workdir) {
150
163
  const reason = (r.stderr || r.stdout).trim();
151
164
  // The denial text becomes a TOOL RESULT the model reads: a hook that
152
165
  // dumps a log must not flood the context with one call.
153
- const capped = reason.slice(0, HOOK_DENIAL_CAP);
166
+ const capped = cutHookText(reason, HOOK_DENIAL_CAP);
154
167
  const suffix = reason.length > HOOK_DENIAL_CAP
155
168
  ? '\n… [denial truncated: ' +
156
169
  (reason.length - HOOK_DENIAL_CAP) +
@@ -198,14 +211,14 @@ async function runEventHooks(hooks, event, workdir, tool, args, result) {
198
211
  // hundreds of KB and flood the context.
199
212
  if (total >= HOOK_OUTPUT_CAP)
200
213
  break;
201
- const piece = out.slice(0, HOOK_OUTPUT_CAP - total);
214
+ const piece = cutHookText(out, HOOK_OUTPUT_CAP - total);
202
215
  total += piece.length;
203
216
  parts.push(piece);
204
217
  }
205
218
  const joined = parts.join(String.fromCharCode(10));
206
219
  if (joined.length <= HOOK_OUTPUT_CAP)
207
220
  return joined;
208
- return (joined.slice(0, HOOK_OUTPUT_CAP) +
221
+ return (cutHookText(joined, HOOK_OUTPUT_CAP) +
209
222
  String.fromCharCode(10) +
210
223
  '… [hook output truncated]');
211
224
  }
@@ -59,15 +59,29 @@ export function pasteReplacement(raw) {
59
59
  return null;
60
60
  return { marker: formatPasteMarker(lines), text };
61
61
  }
62
- // Replace "[Pasted lines#N]" markers with the original pasted text (the first
63
- // occurrence in order — matches the order in which the pastes were inserted).
62
+ // Replace "[Pasted lines#N]" markers with the original pasted text. The marker
63
+ // carries ONLY the line count, so two pastes of the same length share it: a
64
+ // plain indexOf-per-paste substituted the FIRST paste's text into whichever
65
+ // marker survived (the operator deleted the first one) — the model then got
66
+ // the WRONG paste under a plausible marker. Replacing ONE occurrence per paste
67
+ // and tying each marker to the pasted text it was CREATED with fixes it for
68
+ // every order: an unmatched paste leaves the marker as text instead of
69
+ // swapping bodies.
64
70
  export function expandPastes(pastes, text) {
65
- let out = text;
66
- for (const p of pastes) {
67
- const idx = out.indexOf(p.marker);
68
- if (idx !== -1) {
69
- out = out.slice(0, idx) + p.text + out.slice(idx + p.marker.length);
70
- }
71
+ if (!pastes.length)
72
+ return text;
73
+ // Expand in REVERSE order of appearance: each paste consumes its OWN marker
74
+ // occurrence (the last one for the last paste), so a marker the operator
75
+ // deleted cannot shift another paste's text into the wrong slot. With a
76
+ // single occurrence left, the LAST paste owns it — the operator deleted the
77
+ // EARLIER marker, so the later paste's body is the right fill.
78
+ let out = String(text);
79
+ for (let i = pastes.length - 1; i >= 0; i--) {
80
+ const p = pastes[i];
81
+ const idx = out.lastIndexOf(p.marker);
82
+ if (idx === -1)
83
+ continue;
84
+ out = out.slice(0, idx) + p.text + out.slice(idx + p.marker.length);
71
85
  }
72
86
  return out;
73
87
  }
package/dist/mcp.js CHANGED
@@ -183,7 +183,14 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
183
183
  const str = String(s);
184
184
  if (str.length <= limit)
185
185
  return str;
186
- let head = str.slice(0, limit);
186
+ // The MARKER is part of the result, so the head must leave room for it:
187
+ // appending it to a limit-long head produced a result WIDER than the cap
188
+ // (the cap exists to bound the context a single MCP call can consume).
189
+ // Reserve the WORST-CASE marker (a 10-digit count), so no result can
190
+ // exceed the cap whatever the omitted length turns out to be.
191
+ const MARKER = '\n[...truncated 9999999999 chars]';
192
+ const budget = Math.max(0, limit - MARKER.length);
193
+ let head = str.slice(0, budget);
187
194
  // Never leave a lone HIGH surrogate at the cut: an emoji / astral CJK char
188
195
  // sits on two UTF-16 units, and cutting between them hands the model a
189
196
  // broken code point (same rule as truncateToolResult / capReport).
@@ -194,7 +201,7 @@ export function truncateMcpResult(s, limit = MCP_RESULT_LIMIT) {
194
201
  }
195
202
  // Prefer a line boundary so a cut does not split a line in half.
196
203
  const nl = head.lastIndexOf(String.fromCharCode(10));
197
- const cut = nl > limit * 0.5 ? head.slice(0, nl + 1) : head;
204
+ const cut = nl > budget * 0.5 ? head.slice(0, nl + 1) : head;
198
205
  const omitted = str.length - cut.length;
199
206
  return cut + `\n[...truncated ${omitted} chars]`;
200
207
  }
@@ -80,9 +80,22 @@ function looksLikePath(v) {
80
80
  export function collectPathCandidates(tool, args) {
81
81
  const a = (args ?? {});
82
82
  const out = [];
83
+ // A path may arrive as a STRING or as an ARRAY of strings (`files: [...]`,
84
+ // `path: [...]`), and MCP tools nest their args (`{file: {path}}`). Accepting
85
+ // only a top-level string let a `path` deny rule be bypassed by wrapping the
86
+ // same path one level deeper or in an array.
87
+ const addValue = (v) => {
88
+ if (typeof v === 'string')
89
+ out.push(v);
90
+ else if (Array.isArray(v)) {
91
+ for (const x of v)
92
+ if (typeof x === 'string')
93
+ out.push(x);
94
+ }
95
+ };
83
96
  for (const k of PATH_KEYS) {
84
- if (typeof a[k] === 'string')
85
- out.push(a[k]);
97
+ if (k in a)
98
+ addValue(a[k]);
86
99
  }
87
100
  if (tool === 'ApplyPatch') {
88
101
  // Parse the patch and pull the file headers out of it.
@@ -96,13 +109,33 @@ export function collectPathCandidates(tool, args) {
96
109
  while ((m = re.exec(text)))
97
110
  out.push(m[1]);
98
111
  }
99
- // MCP tools (and any unknown tool): scan the string values for path-looking
100
- // entries. Conservative — only values that clearly look like paths.
112
+ // MCP tools (and any unknown tool): scan the values for path-looking
113
+ // entries — through ARRAYS and NESTED objects too, since an MCP schema is
114
+ // free to nest (`{file: {path}}`) or take `argv: ['cat', '/etc/x']`. Only
115
+ // values that clearly look like paths are taken (depth-bounded to avoid a
116
+ // pathological payload).
101
117
  if (tool.includes('__')) {
102
- for (const v of Object.values(a)) {
103
- if (typeof v === 'string' && looksLikePath(v))
104
- out.push(v);
105
- }
118
+ const walk = (v, depth) => {
119
+ if (depth > 4 || v === null || v === undefined)
120
+ return;
121
+ if (typeof v === 'string') {
122
+ if (looksLikePath(v))
123
+ out.push(v);
124
+ return;
125
+ }
126
+ if (Array.isArray(v)) {
127
+ for (const x of v)
128
+ walk(x, depth + 1);
129
+ return;
130
+ }
131
+ if (typeof v === 'object') {
132
+ for (const x of Object.values(v)) {
133
+ walk(x, depth + 1);
134
+ }
135
+ }
136
+ };
137
+ for (const v of Object.values(a))
138
+ walk(v, 0);
106
139
  }
107
140
  return out;
108
141
  }
@@ -101,18 +101,33 @@ export function parseQwenChatList(json) {
101
101
  data.list ??
102
102
  data.items)
103
103
  : null;
104
- if (!Array.isArray(arr))
104
+ // The payload has also been seen as a MAP keyed by chat id (the same
105
+ // convention as the history shape); the key then IS the id. Without this a
106
+ // legitimate list was rejected by the array guard and /chats printed
107
+ // "(none)" with no error.
108
+ const keyed = Array.isArray(arr)
109
+ ? null
110
+ : arr && typeof arr === 'object'
111
+ ? Object.entries(arr)
112
+ : null;
113
+ if (!Array.isArray(arr) && !keyed)
105
114
  return [];
115
+ const entries = Array.isArray(arr)
116
+ ? arr.map((c) => ['', c])
117
+ : keyed;
106
118
  const out = [];
107
- for (const c of arr) {
119
+ for (const [key, c] of entries) {
108
120
  if (!c || typeof c !== 'object')
109
121
  continue;
110
122
  const m = c;
111
- const id = typeof m.id === 'string'
112
- ? m.id
113
- : typeof m.chat_id === 'string'
114
- ? m.chat_id
115
- : '';
123
+ // A numeric id (`{id: 12345}`) used to be dropped as "no id"; it is a
124
+ // perfectly usable chat id once stringified.
125
+ const raw = m.id ?? m.chat_id;
126
+ const id = typeof raw === 'string'
127
+ ? raw
128
+ : typeof raw === 'number' && Number.isFinite(raw)
129
+ ? String(raw)
130
+ : key;
116
131
  if (!id)
117
132
  continue;
118
133
  const title = (typeof m.title === 'string' && m.title) ||
@@ -163,14 +178,23 @@ export function parseQwenHistory(json) {
163
178
  const out = [];
164
179
  for (const m of sorted) {
165
180
  // Role detection is DEFENSIVE: the field has been `role` (string,
166
- // case-insensitive), a numeric flag, or `is_user`. Anything that is not
181
+ // case-insensitive), a NUMERIC flag, or `is_user`. Anything that is not
167
182
  // clearly the user is treated as the assistant (the restored dialogue
168
- // cares about the distinction, not the exact enum).
183
+ // cares about the distinction, not the exact enum). A numeric flag uses
184
+ // 1 = user, 2 = assistant (the `String(1) === '1'` form was ignored and
185
+ // every user turn was attributed to the model).
169
186
  const roleStr = String(m.role ?? '').toLowerCase();
187
+ // `from` is matched case-insensitively and accepts Qwen's `human`
188
+ // spelling: the exact `from === 'user'` test read an uppercase/human
189
+ // turn as the ASSISTANT's answer, so the operator's own question was
190
+ // restored as the model's reply.
191
+ const fromStr = String(m.from ?? '').toLowerCase();
170
192
  const isUser = roleStr === 'user' ||
193
+ roleStr === '1' ||
171
194
  roleStr === '0' ||
172
195
  m.is_user === true ||
173
- m.from === 'user';
196
+ fromStr === 'user' ||
197
+ fromStr === 'human';
174
198
  const role = isUser ? 'user' : 'assistant';
175
199
  let text = '';
176
200
  if (typeof m.content === 'string')
@@ -184,7 +184,12 @@ export function isFinishedWithoutAnswer(body) {
184
184
  for (const ch of choices) {
185
185
  if (!ch || typeof ch !== 'object')
186
186
  continue;
187
- const delta = ch.delta;
187
+ const c = ch;
188
+ // The final chunk carries `status` on the CHOICE (see the format comment
189
+ // at the top of this file); older/other builds put it on `delta`.
190
+ if (c.status === 'finished')
191
+ finished = true;
192
+ const delta = c.delta;
188
193
  if (delta && delta.status === 'finished')
189
194
  finished = true;
190
195
  }
package/dist/scheduler.js CHANGED
@@ -56,7 +56,13 @@ function dayMatches(c, date) {
56
56
  function parseCronField(field, min, max) {
57
57
  const out = new Set();
58
58
  for (const part of field.split(',')) {
59
- const [range, stepStr] = part.split('/');
59
+ // An empty list item (`1,,2`, a trailing comma) used to fall through
60
+ // Number('') === 0 and silently ADD minute 0 to the schedule.
61
+ if (!part)
62
+ return null;
63
+ const [range, stepStr, extra] = part.split('/');
64
+ if (extra !== undefined)
65
+ return null;
60
66
  const step = stepStr === undefined ? 1 : Number(stepStr);
61
67
  if (!Number.isInteger(step) || step <= 0)
62
68
  return null;
@@ -67,11 +73,22 @@ function parseCronField(field, min, max) {
67
73
  hi = max;
68
74
  }
69
75
  else if (range.includes('-')) {
70
- const [a, b] = range.split('-');
76
+ // A bare number is required on BOTH sides: `split('-')` on `-5` gave
77
+ // ['', '5'] and Number('') === 0, so a typo `-5 * * * *` became the
78
+ // range 0-5 instead of being rejected. `1-2-3` was silently truncated
79
+ // to 1-2 the same way.
80
+ const segs = range.split('-');
81
+ if (segs.length !== 2)
82
+ return null;
83
+ const [a, b] = segs;
84
+ if (!/^\d+$/.test(a) || !/^\d+$/.test(b))
85
+ return null;
71
86
  lo = Number(a);
72
87
  hi = Number(b);
73
88
  }
74
89
  else {
90
+ if (!/^\d+$/.test(range))
91
+ return null;
75
92
  lo = Number(range);
76
93
  // `N/step` (a start with a step, no range) is valid cron: it means from
77
94
  // N up to the field maximum. Treating it as the single value N made a
package/dist/sessions.js CHANGED
@@ -55,7 +55,10 @@ function safeName(id) {
55
55
  const s = String(id);
56
56
  let out = '';
57
57
  for (const ch of s) {
58
- if (/[a-zA-Z0-9.-]/.test(ch))
58
+ // `_` ITSELF is the escape prefix: leaving a literal underscore in
59
+ // place made the encoding ambiguous (`a/b` -> `a_2fb` collided with the
60
+ // real id `a_2fb`), so one session silently overwrote the other.
61
+ if (/[a-zA-Z0-9.-]/.test(ch) && ch !== '_')
59
62
  out += ch;
60
63
  else
61
64
  out += '_' + ch.codePointAt(0).toString(16).padStart(2, '0');
package/dist/subagent.js CHANGED
@@ -76,7 +76,6 @@ export function createSubagentRunner(opts) {
76
76
  ' per task). Do the remaining work yourself with the other tools.',
77
77
  };
78
78
  }
79
- used++;
80
79
  // Resolve the parent chat id BEFORE anything else. The runner restores the
81
80
  // parent chat in its finally block, and that restore is what keeps the
82
81
  // subagent's isolated context from swallowing the parent's turn. If the id
@@ -98,6 +97,10 @@ export function createSubagentRunner(opts) {
98
97
  'would be lost). Do the work yourself with the other tools.',
99
98
  };
100
99
  }
100
+ // Count the run only NOW: every early exit above (budget, unknown
101
+ // parent chat id) returned BEFORE anything ran, and charging those
102
+ // attempts burned the whole budget on runs that never started.
103
+ used++;
101
104
  const subTools = buildTools(req.type === 'explore');
102
105
  const taskText = subagentPersona(req.type) + '\n\n--- SUB-TASK ---\n' + req.prompt;
103
106
  // Save the parent's send hooks: the nested loop OVERWRITES them on
package/dist/web.js CHANGED
@@ -226,6 +226,28 @@ export function isPrivateIp(ip) {
226
226
  // ff00::/8 multicast is not a public unicast destination either.
227
227
  if ((g0 & 0xff00) === 0xff00)
228
228
  return true;
229
+ // 6to4 (2002:AABB:CCDD::/48) embeds an IPv4 in groups 1-2, so
230
+ // `2002:7f00:0001::` IS 127.0.0.1 and `2002:a9fe:a9fe::` is the cloud
231
+ // metadata address. Judged by the embedded address, like the other
232
+ // transition prefixes.
233
+ if (v6[0] === 0x2002) {
234
+ return isPrivateIp((v6[1] >> 8) +
235
+ '.' +
236
+ (v6[1] & 0xff) +
237
+ '.' +
238
+ (v6[2] >> 8) +
239
+ '.' +
240
+ (v6[2] & 0xff));
241
+ }
242
+ // Teredo (2001:0000::/32): the CLIENT IPv4 is the last 32 bits XORed with
243
+ // 0xffffffff (the server IPv4 sits in groups 3-4 and is not the peer).
244
+ if (v6[0] === 0x2001 && v6[1] === 0x0000) {
245
+ const a = (v6[6] >> 8) ^ 0xff;
246
+ const b = (v6[6] & 0xff) ^ 0xff;
247
+ const c = (v6[7] >> 8) ^ 0xff;
248
+ const d = (v6[7] & 0xff) ^ 0xff;
249
+ return isPrivateIp(a + '.' + b + '.' + c + '.' + d);
250
+ }
229
251
  // IPv4-mapped ::ffff:a.b.c.d — judge the embedded address.
230
252
  const mappedV4 = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff;
231
253
  if (mappedV4) {
@@ -625,7 +647,11 @@ export function parseDuckDuckGoResults(body, limit) {
625
647
  href = decodeURIComponent(uddgMatch[1]);
626
648
  const title = htmlToText(m[2] || '').trim();
627
649
  const snippet = htmlToText(m[3] || '').trim();
628
- if (!title || !href)
650
+ // Only an absolute http(s) link is a real result. The page can carry a
651
+ // `javascript:`/`data:` href (malicious or broken markup) or a
652
+ // site-relative one; handing either to the model as a result URL is wrong
653
+ // (a `javascript:` URL looks fetchable, a relative one is not).
654
+ if (!title || !/^https?:\/\//i.test(href))
629
655
  continue;
630
656
  results.push({ title, url: href, snippet });
631
657
  }
@@ -46,13 +46,29 @@ function readAllAttrs(attrs) {
46
46
  // (the agent then stalls). Here we look for the first JSON object inside the
47
47
  // attributes/body and parse it as args.
48
48
  function parseInlineJsonArgs(s) {
49
+ // Find the start of the inline JSON. The SPECIFIC `"args" {` marker wins:
50
+ // a generic `{` in the surrounding prose (`note {here}: {…}`) was taken as
51
+ // the object start, balance ended on its `}`, and the call reached the tool
52
+ // with EMPTY args. Fall back to a bare `{` only when the marker is absent.
53
+ const argsMarker = /["']args["']\s*[:=]?\s*\{/i.exec(s);
54
+ if (argsMarker)
55
+ return parseBalancedJson(s, argsMarker.index + argsMarker[0].length - 1);
56
+ // No explicit marker: try EVERY `{` and take the first one that actually
57
+ // parses as an object. Picking the first `{` blindly broke on a stray brace
58
+ // in the prose (`note {here}: {…}`): balance ended on its `}`, the fragment
59
+ // carried no arguments, and the tool ran with EMPTY args.
60
+ const re = /\{/g;
61
+ let b;
62
+ while ((b = re.exec(s)) !== null) {
63
+ const parsed = parseBalancedJson(s, b.index);
64
+ if (parsed)
65
+ return parsed;
66
+ }
67
+ return null;
68
+ }
69
+ /** Parse one balanced, string-aware `{...}` object that STARTS at `from`. */
70
+ function parseBalancedJson(s, from) {
49
71
  const Q = String.fromCharCode(34);
50
- // Find a plausible start of a JSON object: `{` or `"args" {`/`'args' {`.
51
- const startRe = /\{|["']args["']\s*[:=]?\s*\{/i;
52
- const m = startRe.exec(s);
53
- if (!m)
54
- return null;
55
- const from = m.index;
56
72
  // Scan for a balanced {...} block (string-aware) and try to parse it.
57
73
  let depth = 0;
58
74
  let inStr = false;
@@ -112,14 +128,47 @@ function parseInlineJsonArgs(s) {
112
128
  // parameter).
113
129
  function parseParameters(body) {
114
130
  const args = {};
115
- const paramRe = /<[^>]*?parameter([^>]*)>([\s\S]*?)<\/[^>]*?parameter[^>]*>/gi;
116
- let p;
117
- while ((p = paramRe.exec(body)) !== null) {
118
- const pname = readAttr(p[1] || '', 'name');
131
+ // The VALUE is arbitrary model text: a Write/Edit content may legitimately
132
+ // contain the literal `</parameter>` (writing an XML parser, a docs
133
+ // example). A plain non-greedy scan stops at that FIRST close tag and the
134
+ // rest of the content is silently lost — the file is written TRUNCATED.
135
+ // So: find every opening tag, then take as the value everything up to the
136
+ // LAST close tag of the same name that precedes the next opening tag (or the
137
+ // end of the body). A stray `</parameter>` inside a value is thus kept.
138
+ // An opening tag is `<parameter …>` / `<|DSML|parameter …>` — NOT the
139
+ // closing `</parameter>`, whose name ALSO matches this pattern (the prefix
140
+ // before `parameter` is not required to be empty). Treating a close tag as
141
+ // an open one shifted every limit by one element and cut the last value;
142
+ // a slash directly before `parameter` marks the close form.
143
+ const openRe = /<[^>]*?(?<!\/)parameter([^>]*)>/gi;
144
+ const opens = [];
145
+ let m;
146
+ while ((m = openRe.exec(body)) !== null) {
147
+ opens.push({
148
+ attrs: m[1] || '',
149
+ start: m.index,
150
+ valueStart: m.index + m[0].length,
151
+ });
152
+ }
153
+ const closeRe = /<\/[^>]*?parameter[^>]*>/gi;
154
+ for (let i = 0; i < opens.length; i++) {
155
+ const o = opens[i];
156
+ const pname = readAttr(o.attrs, 'name');
119
157
  if (!pname)
120
158
  continue;
121
- const isString = /string[ ]*=[ ]*"?true"?/i.test(p[1] || '');
122
- let value = unescapeXml(p[2]);
159
+ const limit = i + 1 < opens.length ? opens[i + 1].start : body.length;
160
+ // The LAST close tag before the next opening tag (or the end).
161
+ let valueEnd = limit;
162
+ closeRe.lastIndex = o.valueStart;
163
+ let c;
164
+ while ((c = closeRe.exec(body)) !== null) {
165
+ if (c.index >= limit)
166
+ break;
167
+ valueEnd = c.index;
168
+ }
169
+ const raw = body.slice(o.valueStart, valueEnd);
170
+ const isString = /string[ ]*=[ ]*"?true"?/i.test(o.attrs);
171
+ let value = unescapeXml(raw);
123
172
  if (!isString) {
124
173
  try {
125
174
  value = JSON.parse(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.78.3",
3
+ "version": "2.80.0",
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": {