zames_pro 2.78.2 → 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 +37 -0
- package/dist/answer-parse.js +58 -0
- package/dist/backlog.js +4 -1
- package/dist/browser-chats.js +9 -1
- package/dist/commands.js +13 -2
- package/dist/config.js +6 -0
- package/dist/diagnostics.js +9 -3
- package/dist/extraTools.js +98 -60
- package/dist/gitTools.js +12 -5
- package/dist/goal.js +27 -4
- package/dist/input/layout.js +22 -8
- package/dist/input/suggest.js +14 -1
- package/dist/net-capture.js +27 -6
- package/dist/permissions.js +48 -8
- package/dist/qwen-chats.js +34 -10
- package/dist/qwen-net-capture.js +6 -1
- package/dist/scheduler.js +19 -2
- package/dist/shell.js +22 -1
- package/dist/tools.js +11 -3
- package/dist/transcript.js +15 -5
- package/dist/web.js +34 -1
- package/dist/xml-toolcall.js +61 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ 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
|
+
|
|
30
|
+
## [2.78.3] - 2026-10-18
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- **answer-parse:** a report narrating completed steps is not unfinished work
|
|
35
|
+
- **net-capture:** a short complete answer is not a finished-without-answer turn
|
|
36
|
+
- **git:** keep the empty-output sentinel out of the git context
|
|
37
|
+
- **tools:** make Read offset/limit 1-based like every line-numbered tool
|
|
38
|
+
- **shell:** scan a glued cd target (cd../.. escaped the sandbox)
|
|
39
|
+
- **web:** drop an unclosed script/style body in htmlToText
|
|
40
|
+
- **diagnostics:** recognize a coloured/timestamped clean banner
|
|
41
|
+
- **permissions:** fail closed on a rule with an invalid regex
|
|
42
|
+
- **transcript:** sanitize the session name in the file name
|
|
43
|
+
- **backlog:** strip a CR from a note title
|
|
44
|
+
- **shell:** escalate to SIGKILL when a killed job traps SIGTERM
|
|
45
|
+
- **input:** Tab on an empty buffer inserted a guessed command
|
|
46
|
+
|
|
10
47
|
## [2.78.2] - 2026-10-18
|
|
11
48
|
|
|
12
49
|
### Fixed
|
package/dist/answer-parse.js
CHANGED
|
@@ -111,6 +111,64 @@ export function looksLikeUnfinishedWork(text) {
|
|
|
111
111
|
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
112
112
|
return false;
|
|
113
113
|
}
|
|
114
|
+
// A report that NARRATES a completed step ("Tests pass. Now I will run
|
|
115
|
+
// the build.", "I have updated the README.", "Fixed the bug.") recounts
|
|
116
|
+
// work that is ALREADY done. The future-tense "now …" that follows must not
|
|
117
|
+
// turn such a report back into unfinished work: the loop re-asked the model
|
|
118
|
+
// up to 3 times on a complete answer. A bare promise ("Let me run the
|
|
119
|
+
// tests") has NO completed-step clause and is still caught below.
|
|
120
|
+
// The prefix and the verb are matched SEPARATELY (not as one combined
|
|
121
|
+
// regex): with a Cyrillic alternation glued to a `\b`-prefixed group, V8
|
|
122
|
+
// silently fails to find the match. A short English/Russian verb list is
|
|
123
|
+
// checked with plain `includes` on the lowercased text.
|
|
124
|
+
const lower = t.toLowerCase();
|
|
125
|
+
const completedWords = [
|
|
126
|
+
'passed',
|
|
127
|
+
'succeeded',
|
|
128
|
+
'failed',
|
|
129
|
+
'updated',
|
|
130
|
+
'fixed',
|
|
131
|
+
'added',
|
|
132
|
+
'removed',
|
|
133
|
+
'applied',
|
|
134
|
+
'committed',
|
|
135
|
+
'pushed',
|
|
136
|
+
'installed',
|
|
137
|
+
'verified',
|
|
138
|
+
'changed',
|
|
139
|
+
'modified',
|
|
140
|
+
'implemented',
|
|
141
|
+
'reviewed',
|
|
142
|
+
'created',
|
|
143
|
+
'built',
|
|
144
|
+
'pass.',
|
|
145
|
+
'pass,',
|
|
146
|
+
'pass ',
|
|
147
|
+
'have ',
|
|
148
|
+
'has ',
|
|
149
|
+
'проходят',
|
|
150
|
+
'прошёл',
|
|
151
|
+
'прошел',
|
|
152
|
+
'успешн',
|
|
153
|
+
'готов',
|
|
154
|
+
];
|
|
155
|
+
const completedRu = [
|
|
156
|
+
'исправил',
|
|
157
|
+
'добавил',
|
|
158
|
+
'обновил',
|
|
159
|
+
'создал',
|
|
160
|
+
'удалил',
|
|
161
|
+
'применил',
|
|
162
|
+
'запустил',
|
|
163
|
+
'проверил',
|
|
164
|
+
'изменил',
|
|
165
|
+
'сделал',
|
|
166
|
+
'выполнил',
|
|
167
|
+
];
|
|
168
|
+
if (completedWords.some((w) => lower.includes(w)) ||
|
|
169
|
+
completedRu.some((w) => lower.includes(w))) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
114
172
|
// `update`/`summarize`/`explain` are REPORT verbs (`I will update you on the
|
|
115
173
|
// results: …`) — they do not promise a tool call. A colon anywhere in the
|
|
116
174
|
// gap also means the sentence is explaining a fact, not about to act.
|
package/dist/backlog.js
CHANGED
|
@@ -30,7 +30,10 @@ export function parseBacklogNote(note) {
|
|
|
30
30
|
break;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
// A note typed on Windows (or pasted from one) carries CRLF line endings;
|
|
34
|
+
// the title would keep the trailing `\r` and the heading would get a stray
|
|
35
|
+
// carriage return.
|
|
36
|
+
const bodyLines = body.split(NL).map((l) => l.replace(/\r$/, ''));
|
|
34
37
|
const title = bodyLines[0];
|
|
35
38
|
const rest = bodyLines.slice(1).join(NL).trim();
|
|
36
39
|
return { priority, title, note: rest || undefined };
|
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/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
|
}
|
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/diagnostics.js
CHANGED
|
@@ -28,11 +28,17 @@ export function summarizeDiagnostics(raw, maxChars) {
|
|
|
28
28
|
// old 40-char cutoff — so every edit in watch mode appended a pointless
|
|
29
29
|
// diagnostics block to the tool result. Treat a ZERO-count banner as clean
|
|
30
30
|
// however long it is; a non-zero count still falls through to the lines.
|
|
31
|
-
|
|
31
|
+
// Strip ANSI colour codes and a leading `[12:00:00]`-style timestamp before
|
|
32
|
+
// the clean checks: a watch-mode banner is usually coloured and/or
|
|
33
|
+
// prefixed, and the anchored regexes missed it — every edit then
|
|
34
|
+
// appended a pointless diagnostics block.
|
|
35
|
+
const plain = text.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
36
|
+
const cleanCheck = plain.replace(/^\[[^\]]*\]\s*/, '');
|
|
37
|
+
if (/^(?:found\s+)?0\s+errors?\b/im.test(cleanCheck))
|
|
32
38
|
return null;
|
|
33
|
-
if (/^no errors?\.?$/im.test(
|
|
39
|
+
if (/^no errors?(?: found)?\.?$/im.test(cleanCheck))
|
|
34
40
|
return null;
|
|
35
|
-
if (/^0 problems?\b/im.test(
|
|
41
|
+
if (/^0 problems?\b/im.test(cleanCheck))
|
|
36
42
|
return null;
|
|
37
43
|
// Keep only error-looking lines when we can recognise them, so a long build
|
|
38
44
|
// log collapses to the actual problems. Fall back to the whole output.
|
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
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'child_process';
|
|
2
2
|
const BS = String.fromCharCode(92);
|
|
3
|
+
// What runGitArgs answers for an EMPTY successful run. Callers that parse the
|
|
4
|
+
// output must not mistake it for real content (a clean tree is not "one
|
|
5
|
+
// modified file whose name is this string").
|
|
6
|
+
export const NO_OUTPUT_SENTINEL = '(command produced no output)';
|
|
3
7
|
// Run git with an argument ARRAY (no shell). ALL git invocations go through
|
|
4
8
|
// this: an argument from the model can never be seen by a shell, so $(...),
|
|
5
9
|
// backticks and a leading `-` (option injection) cannot turn a read-only call
|
|
@@ -22,7 +26,7 @@ export function runGitArgs(args, cwd, timeout = 15_000) {
|
|
|
22
26
|
const errStr = (stderr || '').toString();
|
|
23
27
|
if (!err) {
|
|
24
28
|
const combined = (out + errStr).trim();
|
|
25
|
-
resolve(combined ||
|
|
29
|
+
resolve(combined || NO_OUTPUT_SENTINEL);
|
|
26
30
|
return;
|
|
27
31
|
}
|
|
28
32
|
const parts = [];
|
|
@@ -49,7 +53,11 @@ export async function getGitContext(workdir) {
|
|
|
49
53
|
const remoteR = await runGitArgs(['remote'], workdir, 5000);
|
|
50
54
|
const aheadR = await runGitArgs(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'], workdir, 5000);
|
|
51
55
|
const branch = branchR.trim() || '(detached HEAD)';
|
|
52
|
-
|
|
56
|
+
// The shared runner answers an EMPTY successful run with a sentinel. Storing
|
|
57
|
+
// it verbatim told the model the tree had a modified "file" that is really
|
|
58
|
+
// an internal string — every task started with a false dirty-tree premise.
|
|
59
|
+
const statusText = statusR.trim() === NO_OUTPUT_SENTINEL ? '' : statusR;
|
|
60
|
+
const statusLines = statusText
|
|
53
61
|
.split(String.fromCharCode(10))
|
|
54
62
|
.map((l) => l.trimEnd())
|
|
55
63
|
.filter(Boolean);
|
|
@@ -84,7 +92,7 @@ export function formatGitContext(ctx) {
|
|
|
84
92
|
else {
|
|
85
93
|
s += '\n- Remote: (none)';
|
|
86
94
|
}
|
|
87
|
-
if (ctx.changedFiles > 0) {
|
|
95
|
+
if (ctx.changedFiles > 0 && ctx.statusPreview.trim() !== NO_OUTPUT_SENTINEL) {
|
|
88
96
|
s += `\n- Uncommitted changes: ${ctx.changedFiles} file(s)
|
|
89
97
|
`;
|
|
90
98
|
// Choose a fence longer than any backtick run in the preview: a filename
|
|
@@ -234,8 +242,7 @@ export function createGitTools(workdir) {
|
|
|
234
242
|
// We stage everything only if the index is empty (as the description promises).
|
|
235
243
|
const staged = await runGitArgs(['diff', '--cached', '--name-only'], workdir, 10_000);
|
|
236
244
|
const stagedTrimmed = staged.trim();
|
|
237
|
-
const hasStaged = stagedTrimmed !== '' &&
|
|
238
|
-
stagedTrimmed !== '(command produced no output)';
|
|
245
|
+
const hasStaged = stagedTrimmed !== '' && stagedTrimmed !== NO_OUTPUT_SENTINEL;
|
|
239
246
|
const addResult = hasStaged
|
|
240
247
|
? '(index is not empty — add -A skipped)'
|
|
241
248
|
: await runGitArgs(['add', '-A'], workdir, 20_000);
|
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/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/input/suggest.js
CHANGED
|
@@ -100,10 +100,23 @@ export function completeSlashCommand(buf, sugg, selected) {
|
|
|
100
100
|
if (sugg.length === 1)
|
|
101
101
|
return sugg[0].name + ' ';
|
|
102
102
|
let prefix = sugg[0].name;
|
|
103
|
+
const typed = buf.toLowerCase();
|
|
103
104
|
for (const c of sugg) {
|
|
104
|
-
while (
|
|
105
|
+
while (prefix.length > typed.length &&
|
|
106
|
+
!c.name.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
105
107
|
prefix = prefix.slice(0, -1);
|
|
106
108
|
}
|
|
107
109
|
}
|
|
110
|
+
// Stop shrinking at the TYPED text: with an empty buffer the list matches
|
|
111
|
+
// everything, and the shared prefix used to shrink down to a bare "/" (or a
|
|
112
|
+
// common first letter like "/h") — Tab then inserted something the operator
|
|
113
|
+
// never typed. A completion must EXTEND what is already there.
|
|
114
|
+
// With an EMPTY buffer everything matches and the shared prefix collapses
|
|
115
|
+
// to a bare "/" (or a common first letter like "/h"): Tab must not guess a
|
|
116
|
+
// command the operator has not started typing.
|
|
117
|
+
if (typed.length < 2)
|
|
118
|
+
return null;
|
|
119
|
+
if (!prefix.toLowerCase().startsWith(typed))
|
|
120
|
+
return null;
|
|
108
121
|
return prefix.length > buf.length ? prefix : null;
|
|
109
122
|
}
|
package/dist/net-capture.js
CHANGED
|
@@ -169,9 +169,12 @@ export function extractAnswer(body) {
|
|
|
169
169
|
// isFinishedWithoutAnswer === false): the finish loop kept polling for a
|
|
170
170
|
// complete answer and `ask()` gave up only at the askDeadlineMs watchdog — the
|
|
171
171
|
// operator watched a spinner for ~4 minutes with no tool call.
|
|
172
|
-
// A real answer
|
|
173
|
-
//
|
|
174
|
-
|
|
172
|
+
// A real answer is anything non-empty. The one case that must NOT count is a
|
|
173
|
+
// truncated RESPONSE head (an empty string or a bare `{`), which the measure
|
|
174
|
+
// below excludes explicitly.
|
|
175
|
+
const MIN_REAL_ANSWER_CHARS = 1;
|
|
176
|
+
/** A bare truncated head (whitespace or braces only) is not a real answer. */
|
|
177
|
+
const TRUNCATED_HEAD_RE = /^[\s{[]*$/;
|
|
175
178
|
// How many chars of RESPONSE text the body carries, or -1 when there is NO
|
|
176
179
|
// RESPONSE fragment at all. The fragment is SSE/JSON-encoded, so the content
|
|
177
180
|
// may contain `\"` escapes AND raw `"` inside the JSON (the capture is the RAW
|
|
@@ -184,7 +187,25 @@ function responseContentLength(body) {
|
|
|
184
187
|
const b = String(body || '');
|
|
185
188
|
if (!/"type":"RESPONSE"/.test(b))
|
|
186
189
|
return -1;
|
|
187
|
-
|
|
190
|
+
// `extractFromSse` reads only `data:` lines, so a plain-JSON body (or one
|
|
191
|
+
// captured without the SSE framing) measured as 0 even when it carried a
|
|
192
|
+
// real SHORT answer. Count the content strings from the raw body and decode
|
|
193
|
+
// them, so an escaped quote does not inflate the count.
|
|
194
|
+
let len = 0;
|
|
195
|
+
const re = /"type":"RESPONSE"[^}]*?"content":"((?:[^"\\]|\\.)*)"/g;
|
|
196
|
+
let m;
|
|
197
|
+
while ((m = re.exec(b)) !== null) {
|
|
198
|
+
let text = m[1];
|
|
199
|
+
try {
|
|
200
|
+
text = JSON.parse('"' + m[1] + '"');
|
|
201
|
+
}
|
|
202
|
+
catch { }
|
|
203
|
+
// A bare truncated head (empty, or a lone `{`/`[`) is not an answer; any
|
|
204
|
+
// other non-empty text — however short — is.
|
|
205
|
+
if (!TRUNCATED_HEAD_RE.test(text))
|
|
206
|
+
len += text.length;
|
|
207
|
+
}
|
|
208
|
+
return len;
|
|
188
209
|
}
|
|
189
210
|
// True when the SSE body shows a turn that ENDED (`quasi_status: FINISHED`)
|
|
190
211
|
// but produced NO real answer text — the reasoning (THINK fragment) was
|
|
@@ -209,7 +230,7 @@ export function isFinishedWithoutAnswer(body) {
|
|
|
209
230
|
return false;
|
|
210
231
|
// A RESPONSE fragment with real content means the model DID answer.
|
|
211
232
|
const len = responseContentLength(b);
|
|
212
|
-
if (len >=
|
|
233
|
+
if (len >= MIN_REAL_ANSWER_CHARS)
|
|
213
234
|
return false;
|
|
214
235
|
// No RESPONSE fragment at all (len === -1) or only a truncated head:
|
|
215
236
|
// treat the absence of a real answer as "no answer produced".
|
|
@@ -234,7 +255,7 @@ export function isFinishedWithAnswer(body) {
|
|
|
234
255
|
// A truncated RESPONSE head (a lone `{`) is NOT a complete answer: the
|
|
235
256
|
// finish loop would return it as the model's text and the tool-call parser
|
|
236
257
|
// would fail on it. Require real content (see MIN_FINISHED_ANSWER_CHARS).
|
|
237
|
-
return responseContentLength(b) >=
|
|
258
|
+
return responseContentLength(b) >= MIN_REAL_ANSWER_CHARS;
|
|
238
259
|
}
|
|
239
260
|
// The CONTEXT size (in tokens) DeepSeek reports for the current answer.
|
|
240
261
|
//
|
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
|
}
|
|
@@ -124,6 +157,13 @@ export function decidePermission(policy, tool, args) {
|
|
|
124
157
|
}
|
|
125
158
|
const paths = collectPathCandidates(tool, args);
|
|
126
159
|
for (const r of policy.rules) {
|
|
160
|
+
// An UNPARSABLE regex in any field of the rule makes the rule fail
|
|
161
|
+
// CLOSED: skipping it silently (the old behavior) turned a deny rule with
|
|
162
|
+
// a typo (`^(Bash$`) into "allow everything it was meant to block".
|
|
163
|
+
const bad = [r.tool, r.command, r.path].find((src) => src !== undefined && safeRegex(src) === null);
|
|
164
|
+
if (bad !== undefined) {
|
|
165
|
+
return { action: 'deny', reason: 'rule has an invalid regex: ' + bad };
|
|
166
|
+
}
|
|
127
167
|
if (r.tool) {
|
|
128
168
|
const re = safeRegex(r.tool);
|
|
129
169
|
if (!re || !re.test(tool))
|
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/shell.js
CHANGED
|
@@ -25,7 +25,12 @@ export function assertCommandInsideRoot(root, command) {
|
|
|
25
25
|
}
|
|
26
26
|
// `(` and backticks open a subshell / command substitution where a `cd` is
|
|
27
27
|
// still executed; include them in the prefix so the scan sees it.
|
|
28
|
-
|
|
28
|
+
//
|
|
29
|
+
// `\s*` (not `\s+`): a shell accepts `cd` GLUED to its target — `cd..`,
|
|
30
|
+
// `cd$HOME`, `cd../..` — and runs it as a real directory change. Requiring
|
|
31
|
+
// whitespace skipped those forms, so a glued `cd../..` walked out of the
|
|
32
|
+
// root with the guard never firing.
|
|
33
|
+
const cdRe = /(?:^|[;&|(\s`])(?:cd|pushd)\s*([^;&|)]+)/gi;
|
|
29
34
|
let m;
|
|
30
35
|
while ((m = cdRe.exec(cmd))) {
|
|
31
36
|
const raw = m[1].trim().replace(/^["']|["']$/g, '');
|
|
@@ -217,6 +222,22 @@ export function killBackground(id) {
|
|
|
217
222
|
return `Failed to kill ${id}: ${e.message}`;
|
|
218
223
|
}
|
|
219
224
|
BG_PROCS.delete(id);
|
|
225
|
+
// SIGTERM is only a REQUEST. A shell that traps it (a wrapper script, a
|
|
226
|
+
// daemon that cleans up on TERM) ignores the signal, and the old code then
|
|
227
|
+
// forgot the id and reported "Killed" while the process kept running as an
|
|
228
|
+
// orphan. Give it a moment, then SIGKILL the group (the shell's own trap
|
|
229
|
+
// cannot block that). Detached from the caller so killBackground stays sync.
|
|
230
|
+
const pid = p.child.pid;
|
|
231
|
+
if (process.platform !== 'win32' && pid) {
|
|
232
|
+
const t = setTimeout(() => {
|
|
233
|
+
try {
|
|
234
|
+
process.kill(-pid, 'SIGKILL');
|
|
235
|
+
}
|
|
236
|
+
catch { }
|
|
237
|
+
}, 1500);
|
|
238
|
+
if (typeof t.unref === 'function')
|
|
239
|
+
t.unref();
|
|
240
|
+
}
|
|
220
241
|
return `Killed background process ${id}.`;
|
|
221
242
|
}
|
|
222
243
|
/** Kill every background process — called on exit so no orphan survives. */
|
package/dist/tools.js
CHANGED
|
@@ -71,7 +71,8 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
71
71
|
const baseTools = [
|
|
72
72
|
{
|
|
73
73
|
name: 'Read',
|
|
74
|
-
description: 'Read a file. Optional: offset and limit (lines
|
|
74
|
+
description: 'Read a file. Optional: offset and limit (lines, BOTH 1-BASED: ' +
|
|
75
|
+
'offset=5 starts at line 5, limit=10 reads ten lines). ' +
|
|
75
76
|
'numbered=true adds cat -n style line numbers (1-based) for reference ' +
|
|
76
77
|
'only — do NOT copy the numbers into Edit old_string. ' +
|
|
77
78
|
'Lines longer than 2000 chars are truncated. ' +
|
|
@@ -117,8 +118,15 @@ export function createTools(workdir, { undo, todos, readOnly = false, subagents
|
|
|
117
118
|
if (content === '')
|
|
118
119
|
return '(file is empty)';
|
|
119
120
|
const lines = content.split('\n');
|
|
120
|
-
|
|
121
|
-
|
|
121
|
+
// `offset`/`limit` are 1-BASED, like sed / `git blame -L` and the
|
|
122
|
+
// `numbered=true` output this tool prints. Treating them as 0-based
|
|
123
|
+
// array slices silently shifted every window by one line and made
|
|
124
|
+
// `offset: 1` skip the first line. A non-positive or non-numeric
|
|
125
|
+
// offset means "from the start".
|
|
126
|
+
const off = Math.max(1, Math.floor(Number(offset) || 1));
|
|
127
|
+
const start = off - 1;
|
|
128
|
+
const lim = Math.floor(Number(limit));
|
|
129
|
+
const end = Number.isFinite(lim) && lim > 0 ? start + lim : lines.length;
|
|
122
130
|
const slice = lines.slice(start, end);
|
|
123
131
|
const clip = (l) => l.length > 2000 ? clipLine(l, 2000) + ' …[line truncated]' : l;
|
|
124
132
|
if (!numbered)
|
package/dist/transcript.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
+
function transcriptErrorLine(e) {
|
|
4
|
+
return `transcript: failed to create file: ${e.message}`;
|
|
5
|
+
}
|
|
3
6
|
export class Transcript {
|
|
4
7
|
enabled;
|
|
5
8
|
file;
|
|
@@ -17,7 +20,14 @@ export class Transcript {
|
|
|
17
20
|
try {
|
|
18
21
|
fs.mkdirSync(dir, { recursive: true });
|
|
19
22
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
20
|
-
|
|
23
|
+
// `sessionName` is the LABEL of the working directory (dirLabel), so a
|
|
24
|
+
// name like `a/b` or `C:\x` would nest a subdirectory or break the
|
|
25
|
+
// path outright: the stream then failed with ENOENT and no transcript
|
|
26
|
+
// at all was written. Keep the name a plain file-name fragment.
|
|
27
|
+
const safeName = String(sessionName)
|
|
28
|
+
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
|
29
|
+
.trim() || 'session';
|
|
30
|
+
this.file = path.join(dir, `${safeName}-${stamp}.jsonl`);
|
|
21
31
|
this.stream = fs.createWriteStream(this.file, { flags: 'a' });
|
|
22
32
|
// Write errors (disk full, file deleted, etc.) arrive as an 'error'
|
|
23
33
|
// event; without a listener this is an uncaught exception.
|
|
@@ -27,10 +37,12 @@ export class Transcript {
|
|
|
27
37
|
});
|
|
28
38
|
}
|
|
29
39
|
catch (e) {
|
|
30
|
-
console.error(
|
|
40
|
+
console.error(transcriptErrorLine(e));
|
|
31
41
|
this.enabled = false;
|
|
32
42
|
}
|
|
33
43
|
}
|
|
44
|
+
/** Number of writes that hit backpressure (write() === false). */
|
|
45
|
+
backpressure = 0;
|
|
34
46
|
log(type, data = {}) {
|
|
35
47
|
const entry = {
|
|
36
48
|
ts: new Date().toISOString(),
|
|
@@ -60,8 +72,6 @@ export class Transcript {
|
|
|
60
72
|
}
|
|
61
73
|
catch { }
|
|
62
74
|
}
|
|
63
|
-
/** Number of writes that hit backpressure (write() === false). */
|
|
64
|
-
backpressure = 0;
|
|
65
75
|
// Flush the stream and resolve once the data reached the file. The callers
|
|
66
76
|
// run on process.exit paths (one-shot --task, browser error, clean exit), and
|
|
67
77
|
// end() without awaiting the callback lets the process die before the buffer
|
|
@@ -89,6 +99,6 @@ export class Transcript {
|
|
|
89
99
|
return null;
|
|
90
100
|
return ('transcript: ' +
|
|
91
101
|
this.backpressure +
|
|
92
|
-
' write(s) hit backpressure (slow disk?)
|
|
102
|
+
' write(s) hit backpressure (slow disk?)― events may be delayed');
|
|
93
103
|
}
|
|
94
104
|
}
|
package/dist/web.js
CHANGED
|
@@ -38,10 +38,17 @@ export function htmlToText(html) {
|
|
|
38
38
|
pendingHighSurrogate = null;
|
|
39
39
|
// Remove blocks we don't need
|
|
40
40
|
let s = html
|
|
41
|
+
// A tag that never CLOSES (a truncated body, or HTML the regex cannot
|
|
42
|
+
// pair) used to leak its raw JS/CSS into the text: also drop everything
|
|
43
|
+
// from an unclosed opening tag to the end of the document.
|
|
41
44
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
45
|
+
.replace(/<script\b[\s\S]*$/i, '')
|
|
42
46
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
47
|
+
.replace(/<style\b[\s\S]*$/i, '')
|
|
43
48
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, '')
|
|
49
|
+
.replace(/<noscript\b[\s\S]*$/i, '')
|
|
44
50
|
.replace(/<svg[\s\S]*?<\/svg>/gi, '')
|
|
51
|
+
.replace(/<svg\b[\s\S]*$/i, '')
|
|
45
52
|
.replace(/<!--[\s\S]*?-->/g, '');
|
|
46
53
|
// Links: <a href="URL">text</a> → text (URL)
|
|
47
54
|
s = s.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_, href, text) => {
|
|
@@ -219,6 +226,28 @@ export function isPrivateIp(ip) {
|
|
|
219
226
|
// ff00::/8 multicast is not a public unicast destination either.
|
|
220
227
|
if ((g0 & 0xff00) === 0xff00)
|
|
221
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
|
+
}
|
|
222
251
|
// IPv4-mapped ::ffff:a.b.c.d — judge the embedded address.
|
|
223
252
|
const mappedV4 = v6.slice(0, 5).every((g) => g === 0) && v6[5] === 0xffff;
|
|
224
253
|
if (mappedV4) {
|
|
@@ -618,7 +647,11 @@ export function parseDuckDuckGoResults(body, limit) {
|
|
|
618
647
|
href = decodeURIComponent(uddgMatch[1]);
|
|
619
648
|
const title = htmlToText(m[2] || '').trim();
|
|
620
649
|
const snippet = htmlToText(m[3] || '').trim();
|
|
621
|
-
|
|
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))
|
|
622
655
|
continue;
|
|
623
656
|
results.push({ title, url: href, snippet });
|
|
624
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.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": {
|