zames_pro 2.78.3 → 2.79.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,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.79.0] - 2026-10-19
11
+
12
+ ### Fixed
13
+
14
+ - **qwen:** match the history 'from' role case-insensitively and accept human
15
+ - **browser-chats:** match the history role case-insensitively and drop service turns
16
+ - **extratools:** apply multi-hunk ApplyPatch updates and honour @@ line numbers
17
+ - **web:** drop non-http(s) links from WebSearch results
18
+ - **web:** judge 6to4 and Teredo addresses by the embedded IPv4
19
+ - **permissions:** match path rules against arrays and nested MCP args
20
+ - **scheduler:** reject empty and malformed cron field tokens
21
+ - **qwen:** read numeric and map-keyed ids in the chat list
22
+ - **xml-toolcall:** a stray brace before inline JSON no longer eats the args
23
+ - **xml-toolcall:** keep a </parameter> literal inside a parameter value
24
+ - **qwen:** detect a finished status carried on the choice, not only the delta
25
+ - **config:** reject an empty value for a number setting
26
+ - **goal:** an echoed label set is not the evaluator verdict
27
+ - **input:** expand each paste into its own marker occurrence
28
+ - **compact:** a summary that mentions DSML in prose is usable
29
+
10
30
  ## [2.78.3] - 2026-10-18
11
31
 
12
32
  ### Fixed
@@ -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) || []) {
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
  }
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/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]
@@ -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
  }
@@ -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/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.79.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": {