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 +33 -0
- package/dist/attach-refs.js +9 -1
- package/dist/browser-chats.js +9 -1
- package/dist/checkpoint.js +7 -2
- package/dist/commands.js +18 -3
- package/dist/config.js +6 -0
- package/dist/extraTools.js +98 -60
- package/dist/gitTools.js +47 -3
- package/dist/goal.js +27 -4
- package/dist/hooks.js +16 -3
- package/dist/input/layout.js +22 -8
- package/dist/mcp.js +9 -2
- package/dist/permissions.js +41 -8
- package/dist/qwen-chats.js +34 -10
- package/dist/qwen-net-capture.js +6 -1
- package/dist/scheduler.js +19 -2
- package/dist/sessions.js +4 -1
- package/dist/subagent.js +4 -1
- package/dist/web.js +27 -1
- package/dist/xml-toolcall.js +61 -12
- package/package.json +1 -1
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
|
package/dist/attach-refs.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/browser-chats.js
CHANGED
|
@@ -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
|
-
|
|
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/checkpoint.js
CHANGED
|
@@ -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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/extraTools.js
CHANGED
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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) =>
|
|
157
|
-
|
|
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 (
|
|
161
|
-
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
143
|
+
if (hunkNorm(fileLines[i + j], mode) !== anchor[j])
|
|
144
|
+
return false;
|
|
164
145
|
}
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
|
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
|
|
221
|
+
return (cutHookText(joined, HOOK_OUTPUT_CAP) +
|
|
209
222
|
String.fromCharCode(10) +
|
|
210
223
|
'… [hook output truncated]');
|
|
211
224
|
}
|
package/dist/input/layout.js
CHANGED
|
@@ -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
|
|
63
|
-
//
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
}
|
package/dist/permissions.js
CHANGED
|
@@ -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 (
|
|
85
|
-
|
|
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
|
|
100
|
-
// entries
|
|
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
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
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
|
}
|
package/dist/qwen-chats.js
CHANGED
|
@@ -101,18 +101,33 @@ export function parseQwenChatList(json) {
|
|
|
101
101
|
data.list ??
|
|
102
102
|
data.items)
|
|
103
103
|
: null;
|
|
104
|
-
|
|
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
|
|
119
|
+
for (const [key, c] of entries) {
|
|
108
120
|
if (!c || typeof c !== 'object')
|
|
109
121
|
continue;
|
|
110
122
|
const m = c;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
196
|
+
fromStr === 'user' ||
|
|
197
|
+
fromStr === 'human';
|
|
174
198
|
const role = isUser ? 'user' : 'assistant';
|
|
175
199
|
let text = '';
|
|
176
200
|
if (typeof m.content === 'string')
|
package/dist/qwen-net-capture.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/xml-toolcall.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
|
|
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.
|
|
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": {
|