opencode-zen-freex 0.2.7 → 0.2.9
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/LICENSE +2 -7
- package/MAINTAINING.md +81 -1
- package/NOTICE +18 -0
- package/README.md +6 -4
- package/core-tools.mjs +85 -7
- package/index.mjs +18 -0
- package/limits.mjs +247 -26
- package/models.mjs +67 -22
- package/package.json +14 -5
- package/provider.mjs +128 -42
- package/stream.mjs +151 -28
- package/tool-response.mjs +109 -24
- package/capture.mjs +0 -203
- package/limits-audit.mjs +0 -262
- package/tools-smoke.mjs +0 -175
package/tool-response.mjs
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
|
-
|
|
1
|
+
import { MAX_BYTES, STALE_BODY_HEADERS } from "./stream.mjs";
|
|
2
|
+
|
|
3
|
+
// The limit as a failure's message names it, from the one place it is set:
|
|
4
|
+
// stream.mjs owns the number, and this guard reads the same bytes through it.
|
|
5
|
+
const LIMIT = `${MAX_BYTES / (1024 * 1024)} MiB`;
|
|
2
6
|
const encoder = new TextEncoder();
|
|
3
7
|
|
|
4
|
-
class GuardError extends Error {
|
|
8
|
+
class GuardError extends Error {
|
|
9
|
+
constructor(message, code = "upstream_error") {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
function headersFor(response) {
|
|
7
16
|
const headers = new Headers(response.headers);
|
|
8
|
-
headers.delete(
|
|
9
|
-
headers.delete("content-encoding");
|
|
17
|
+
for (const name of STALE_BODY_HEADERS) headers.delete(name);
|
|
10
18
|
return headers;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
|
-
|
|
21
|
+
// failure answers the error a client reads. `code` says what went wrong
|
|
22
|
+
// where the shape has room for it: only a call of no tool the agent has is
|
|
23
|
+
// an undeclared one, and a limit, a truncation or a malformed upstream is
|
|
24
|
+
// not.
|
|
25
|
+
function failure(protocol, message, stream = false, code = "upstream_error") {
|
|
14
26
|
if (protocol === "anthropic") return { type: "error", error: { type: "api_error", message } };
|
|
15
27
|
if (protocol === "responses") {
|
|
16
28
|
return stream
|
|
17
|
-
? { type: "error", code
|
|
18
|
-
: { error: { type: "upstream_error", code
|
|
29
|
+
? { type: "error", code, message }
|
|
30
|
+
: { error: { type: "upstream_error", code, message } };
|
|
19
31
|
}
|
|
20
32
|
return { error: { message, type: "upstream_error" } };
|
|
21
33
|
}
|
|
@@ -198,7 +210,7 @@ function checkSnapshots(data, protocol, checkName) {
|
|
|
198
210
|
if (isCall(protocol, value)) {
|
|
199
211
|
const name = checkName(value.name);
|
|
200
212
|
if (name == null) {
|
|
201
|
-
if (!holder) throw new GuardError(droppedNote(value.name));
|
|
213
|
+
if (!holder) throw new GuardError(droppedNote(value.name), "undeclared_tool");
|
|
202
214
|
holder[key] = inPlaceOf(protocol, value, droppedNote(value.name));
|
|
203
215
|
changed = true;
|
|
204
216
|
left++;
|
|
@@ -267,7 +279,7 @@ function chatBuffer(checkName) {
|
|
|
267
279
|
bytes += size;
|
|
268
280
|
state.bytes += size;
|
|
269
281
|
if (bytes > MAX_BYTES)
|
|
270
|
-
throw new GuardError(
|
|
282
|
+
throw new GuardError(`Pending upstream tools exceed the ${LIMIT} buffer limit`);
|
|
271
283
|
}
|
|
272
284
|
function stateFor(index, data) {
|
|
273
285
|
if (!pending.has(index)) {
|
|
@@ -289,7 +301,10 @@ function chatBuffer(checkName) {
|
|
|
289
301
|
return pending.get(index);
|
|
290
302
|
}
|
|
291
303
|
// Choices that streamed text, and those whose every call was left out
|
|
292
|
-
// (whose later "tool_calls" finish is a stop).
|
|
304
|
+
// (whose later "tool_calls" finish is a stop). The second is per round: a
|
|
305
|
+
// later round that buffers a call for the same choice is judged on what it
|
|
306
|
+
// holds, not on the round before it, so the mark is dropped as soon as that
|
|
307
|
+
// call arrives.
|
|
293
308
|
const wrote = new Set();
|
|
294
309
|
const ended = new Set();
|
|
295
310
|
function complete(state, index) {
|
|
@@ -308,6 +323,14 @@ function chatBuffer(checkName) {
|
|
|
308
323
|
}
|
|
309
324
|
return delta;
|
|
310
325
|
}
|
|
326
|
+
// What a choice the buffer flushes at [DONE] ended as: tool use when the
|
|
327
|
+
// delta it publishes carries calls, the legacy field when it carries that,
|
|
328
|
+
// and a plain stop otherwise.
|
|
329
|
+
function ending(delta) {
|
|
330
|
+
if (delta.tool_calls?.length) return "tool_calls";
|
|
331
|
+
if (delta.function_call) return "function_call";
|
|
332
|
+
return "stop";
|
|
333
|
+
}
|
|
311
334
|
function remove(index) {
|
|
312
335
|
const state = pending.get(index);
|
|
313
336
|
if (state) bytes -= state.bytes;
|
|
@@ -319,6 +342,10 @@ function chatBuffer(checkName) {
|
|
|
319
342
|
},
|
|
320
343
|
clear() {
|
|
321
344
|
pending.clear();
|
|
345
|
+
// Everything this buffer holds, not only the bytes: a cleared buffer
|
|
346
|
+
// has no round to judge.
|
|
347
|
+
wrote.clear();
|
|
348
|
+
ended.clear();
|
|
322
349
|
bytes = 0;
|
|
323
350
|
},
|
|
324
351
|
apply(data) {
|
|
@@ -330,7 +357,12 @@ function chatBuffer(checkName) {
|
|
|
330
357
|
if (typeof delta.content === "string" && delta.content) wrote.add(position(choice.index));
|
|
331
358
|
if (delta.tool_calls == null && delta.function_call == null) continue;
|
|
332
359
|
changed = true;
|
|
333
|
-
|
|
360
|
+
// A call buffered now opens a round of its own for this choice: a
|
|
361
|
+
// choice that finished as a plain stop because every call of an
|
|
362
|
+
// earlier round was left out is judged again on what this one holds.
|
|
363
|
+
const at = position(choice.index);
|
|
364
|
+
ended.delete(at);
|
|
365
|
+
const state = stateFor(at, data);
|
|
334
366
|
for (const fragment of list(delta.tool_calls)) {
|
|
335
367
|
if (!object(fragment) || (fragment.type != null && fragment.type !== "function")) {
|
|
336
368
|
throw new GuardError("Unsupported upstream tool delta");
|
|
@@ -381,9 +413,16 @@ function chatBuffer(checkName) {
|
|
|
381
413
|
},
|
|
382
414
|
finish() {
|
|
383
415
|
if (!pending.size) return null;
|
|
384
|
-
const choices = [...pending]
|
|
385
|
-
|
|
386
|
-
|
|
416
|
+
const choices = [...pending].sort(([a], [b]) => a - b).map(([index, state]) => {
|
|
417
|
+
const delta = complete(state, index);
|
|
418
|
+
// The case this buffer exists for is an upstream that closes with
|
|
419
|
+
// [DONE] before sending a finish chunk: the calls it buffered are
|
|
420
|
+
// published here, and the choice has to say the turn ended in tool
|
|
421
|
+
// use. A null reason reads as no ending at all, and "stop" as a plain
|
|
422
|
+
// one — either way a client keyed on finish_reason never runs a call
|
|
423
|
+
// it was handed.
|
|
424
|
+
return { index, delta, finish_reason: ending(delta) };
|
|
425
|
+
});
|
|
387
426
|
const metadata = pending.values().next().value.metadata;
|
|
388
427
|
pending.clear();
|
|
389
428
|
bytes = 0;
|
|
@@ -485,6 +524,46 @@ function snapshotStream(protocol, checkName) {
|
|
|
485
524
|
};
|
|
486
525
|
}
|
|
487
526
|
|
|
527
|
+
// A reply that is not a stream is read whole before it is checked, so it is
|
|
528
|
+
// read through the same limit a streamed reply's events are: a body past it
|
|
529
|
+
// is a 502, rather than the whole of it sitting in memory first.
|
|
530
|
+
async function readBounded(response) {
|
|
531
|
+
const reader = response.body?.getReader();
|
|
532
|
+
if (!reader) return "";
|
|
533
|
+
const parts = [];
|
|
534
|
+
let bytes = 0;
|
|
535
|
+
try {
|
|
536
|
+
while (true) {
|
|
537
|
+
const { done, value } = await reader.read();
|
|
538
|
+
if (done) break;
|
|
539
|
+
bytes += value.byteLength;
|
|
540
|
+
if (bytes > MAX_BYTES)
|
|
541
|
+
throw new GuardError(`Upstream response exceeds the ${LIMIT} buffer limit`);
|
|
542
|
+
parts.push(value);
|
|
543
|
+
}
|
|
544
|
+
} finally {
|
|
545
|
+
// Whatever ended the read — the limit, a malformed body, or the end of
|
|
546
|
+
// it — this body is never read again, so the reader is loosed and the
|
|
547
|
+
// stream cancelled. This was the one place in the package that abandoned
|
|
548
|
+
// a body without cancelling it (measured: no cancellation past the limit,
|
|
549
|
+
// and the source still pulled after the 502), which leaves an upstream
|
|
550
|
+
// writing into a socket nobody reads. The cancel's own failure is not
|
|
551
|
+
// swallowed, and the lock is released whether it threw or not.
|
|
552
|
+
try {
|
|
553
|
+
await reader.cancel();
|
|
554
|
+
} finally {
|
|
555
|
+
reader.releaseLock();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const all = new Uint8Array(bytes);
|
|
559
|
+
let offset = 0;
|
|
560
|
+
for (const part of parts) {
|
|
561
|
+
all.set(part, offset);
|
|
562
|
+
offset += part.length;
|
|
563
|
+
}
|
|
564
|
+
return new TextDecoder().decode(all);
|
|
565
|
+
}
|
|
566
|
+
|
|
488
567
|
function parseJSON(text) {
|
|
489
568
|
let value;
|
|
490
569
|
try {
|
|
@@ -519,7 +598,7 @@ async function* framesFrom(reader) {
|
|
|
519
598
|
function append(value) {
|
|
520
599
|
eventBytes += value.byteLength;
|
|
521
600
|
if (eventBytes > MAX_BYTES)
|
|
522
|
-
throw new GuardError(
|
|
601
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
523
602
|
if (value.byteLength) {
|
|
524
603
|
parts.push(value);
|
|
525
604
|
lineBytes += value.byteLength;
|
|
@@ -546,7 +625,7 @@ async function* framesFrom(reader) {
|
|
|
546
625
|
start = 1;
|
|
547
626
|
}
|
|
548
627
|
if (eventBytes > MAX_BYTES)
|
|
549
|
-
throw new GuardError(
|
|
628
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
550
629
|
skipLF = false;
|
|
551
630
|
if (pendingFrame) {
|
|
552
631
|
const frame = pendingFrame;
|
|
@@ -559,7 +638,7 @@ async function* framesFrom(reader) {
|
|
|
559
638
|
if (value[offset] !== 10 && value[offset] !== 13) continue;
|
|
560
639
|
append(value.subarray(start, offset));
|
|
561
640
|
if (++eventBytes > MAX_BYTES)
|
|
562
|
-
throw new GuardError(
|
|
641
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
563
642
|
const bytes = new Uint8Array(lineBytes);
|
|
564
643
|
let cursor = 0;
|
|
565
644
|
for (const part of parts) {
|
|
@@ -584,7 +663,7 @@ async function* framesFrom(reader) {
|
|
|
584
663
|
rawLine += "\n";
|
|
585
664
|
offset++;
|
|
586
665
|
if (++eventBytes > MAX_BYTES)
|
|
587
|
-
throw new GuardError(
|
|
666
|
+
throw new GuardError(`Upstream SSE event exceeds the ${LIMIT} buffer limit`);
|
|
588
667
|
} else if (offset + 1 === value.length) skipLF = true;
|
|
589
668
|
}
|
|
590
669
|
start = offset + 1;
|
|
@@ -768,9 +847,16 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
768
847
|
return;
|
|
769
848
|
}
|
|
770
849
|
if (frame.name !== "error" && frame.text.trim() === "[DONE]") {
|
|
771
|
-
|
|
850
|
+
// [DONE] is what ends a Chat stream, and a valid end-of-stream
|
|
851
|
+
// sentinel for a Responses or Messages one that has already
|
|
852
|
+
// reported its own terminal event (response.completed,
|
|
853
|
+
// response.incomplete, message_stop) — a gateway in front of the
|
|
854
|
+
// upstream may append it. Before that event it claims an ending
|
|
855
|
+
// the protocol never reported, and the frame is refused; the
|
|
856
|
+
// collapse path reads the same bytes the same way.
|
|
857
|
+
if (protocol !== "chat" && !completed)
|
|
772
858
|
throw new GuardError("Unexpected upstream completion marker");
|
|
773
|
-
const final = chat.finish();
|
|
859
|
+
const final = chat ? chat.finish() : null;
|
|
774
860
|
completed = true;
|
|
775
861
|
controller.enqueue(encoder.encode((final ? encodeEvent(final) : "") + frame.raw));
|
|
776
862
|
return;
|
|
@@ -826,7 +912,7 @@ function guardedStream(response, protocol, checkName, endCutShort) {
|
|
|
826
912
|
controller.enqueue(
|
|
827
913
|
encoder.encode(
|
|
828
914
|
encodeEvent(
|
|
829
|
-
failure(protocol, error.message, true),
|
|
915
|
+
failure(protocol, error.message, true, error.code),
|
|
830
916
|
protocol === "chat" ? "" : "error",
|
|
831
917
|
),
|
|
832
918
|
),
|
|
@@ -865,16 +951,15 @@ export async function guardToolResponse(response, protocol, policy, options = {}
|
|
|
865
951
|
headers,
|
|
866
952
|
});
|
|
867
953
|
}
|
|
868
|
-
const text = await response.text();
|
|
869
954
|
let value;
|
|
870
955
|
let status = response.status;
|
|
871
956
|
try {
|
|
872
|
-
value = parseJSON(
|
|
957
|
+
value = parseJSON(await readBounded(response));
|
|
873
958
|
checkSnapshots(value, protocol, checkName);
|
|
874
959
|
} catch (error) {
|
|
875
960
|
if (!(error instanceof GuardError)) throw error;
|
|
876
961
|
status = 502;
|
|
877
|
-
value = failure(protocol, error.message);
|
|
962
|
+
value = failure(protocol, error.message, false, error.code);
|
|
878
963
|
}
|
|
879
964
|
headers.set("content-type", "application/json");
|
|
880
965
|
return new Response(JSON.stringify(value), { status, headers });
|
package/capture.mjs
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
// Recaptures fingerprint.json from a real OpenCode.
|
|
2
|
-
//
|
|
3
|
-
// The free tier reads the wording of the tools it knows by name out of the
|
|
4
|
-
// request body and refuses the request when it differs from its own copy. That
|
|
5
|
-
// copy moves with OpenCode, so the one held here goes stale — and a stale copy
|
|
6
|
-
// looks exactly like a working one until somebody is refused. This is how a new
|
|
7
|
-
// one is taken, so that refreshing it is a command and not an archaeology
|
|
8
|
-
// exercise.
|
|
9
|
-
//
|
|
10
|
-
// bun run capture # writes fingerprint.json
|
|
11
|
-
// bun run capture -- --dry # reports what changed, writes nothing
|
|
12
|
-
//
|
|
13
|
-
// Needs opencode on PATH (https://opencode.ai) and bun. Nothing goes to Zen: the
|
|
14
|
-
// OpenCode under test is pointed at a server running here, and the request it
|
|
15
|
-
// makes is the thing being read.
|
|
16
|
-
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
import { readFileSync } from "node:fs";
|
|
19
|
-
import { mkdtempSync } from "node:fs";
|
|
20
|
-
import { tmpdir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import { PROFILES } from "./core-tools.mjs";
|
|
23
|
-
|
|
24
|
-
const PORT = Number(process.env.CAPTURE_PORT ?? 8791);
|
|
25
|
-
const DRY = process.argv.includes("--dry");
|
|
26
|
-
const HERE = import.meta.dir;
|
|
27
|
-
const FINGERPRINT = join(HERE, "fingerprint.json");
|
|
28
|
-
|
|
29
|
-
// The names Zen reads. Everything else a host declares is ignored by it.
|
|
30
|
-
const READ = ["bash", "glob", "grep", "read", "write", "edit"];
|
|
31
|
-
|
|
32
|
-
// The capture host's own scratch directory and OS banner belong to the machine
|
|
33
|
-
// the capture ran on, not to the tool. These are put back to something neutral
|
|
34
|
-
// so the capture is not tied to one machine.
|
|
35
|
-
const NEUTRALISE = [
|
|
36
|
-
[/Be aware: OS: [^\n]*/g, "Be aware: OS: linux, Shell: bash"],
|
|
37
|
-
[/Use `[^`]*` for temporary work outside the workspace\./g, "Use `/tmp` for temporary work outside the workspace."],
|
|
38
|
-
[/Use `[A-Za-z]:\\[^`]*`/g, "Use `/tmp`"],
|
|
39
|
-
];
|
|
40
|
-
// Zen compares roughly the first 700 characters and ignores the rest; 1200
|
|
41
|
-
// leaves margin, and the tail is where a note to the model goes.
|
|
42
|
-
const KEEP = 1200;
|
|
43
|
-
|
|
44
|
-
const capturedPath = join(tmpdir(), `zen-capture-${process.pid}.json`);
|
|
45
|
-
const server = Bun.serve({
|
|
46
|
-
port: PORT,
|
|
47
|
-
hostname: "127.0.0.1",
|
|
48
|
-
async fetch(request) {
|
|
49
|
-
const url = new URL(request.url);
|
|
50
|
-
if (url.pathname.endsWith("/models"))
|
|
51
|
-
return Response.json({ object: "list", data: [{ id: "big-pickle", object: "model", owned_by: "opencode" }] });
|
|
52
|
-
// OpenCode makes more than one request (a short one for the title, then the
|
|
53
|
-
// real one). The one worth reading is the longest, since it is the one that
|
|
54
|
-
// carries the full toolset.
|
|
55
|
-
const body = await request.text();
|
|
56
|
-
let previous = { agent: null, body: "" };
|
|
57
|
-
try {
|
|
58
|
-
previous = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
59
|
-
} catch {}
|
|
60
|
-
if (body.length > previous.body.length)
|
|
61
|
-
await Bun.write(capturedPath, JSON.stringify({ agent: request.headers.get("user-agent"), body }));
|
|
62
|
-
return new Response(
|
|
63
|
-
[
|
|
64
|
-
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{"role":"assistant","content":"OK"},"finish_reason":null}]}',
|
|
65
|
-
'data: {"id":"c1","object":"chat.completion.chunk","created":1,"model":"big-pickle","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}',
|
|
66
|
-
"data: [DONE]",
|
|
67
|
-
"",
|
|
68
|
-
].join("\n\n"),
|
|
69
|
-
{ headers: { "content-type": "text/event-stream" } },
|
|
70
|
-
);
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const home = mkdtempSync(join(tmpdir(), "zen-capture-home-"));
|
|
75
|
-
// Where OpenCode looks for its config is XDG_CONFIG_HOME/opencode, and the
|
|
76
|
-
// three XDG directories are pointed at `home` below.
|
|
77
|
-
const config = join(home, "config", "opencode", "opencode.json");
|
|
78
|
-
await Bun.write(
|
|
79
|
-
config,
|
|
80
|
-
JSON.stringify({ provider: { opencode: { options: { baseURL: `http://127.0.0.1:${PORT}/v1` } } } }),
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
console.log(`listening on 127.0.0.1:${PORT}, running opencode against it`);
|
|
84
|
-
let ran = true;
|
|
85
|
-
const code = await new Promise((done) => {
|
|
86
|
-
const child = spawn("opencode", ["run", "-m", "opencode/big-pickle", "Say OK."], {
|
|
87
|
-
env: {
|
|
88
|
-
...process.env,
|
|
89
|
-
XDG_DATA_HOME: join(home, "data"),
|
|
90
|
-
XDG_CONFIG_HOME: join(home, "config"),
|
|
91
|
-
XDG_CACHE_HOME: join(home, "cache"),
|
|
92
|
-
},
|
|
93
|
-
stdio: ["ignore", "ignore", "inherit"],
|
|
94
|
-
});
|
|
95
|
-
child.on("error", () => {
|
|
96
|
-
console.error("could not run opencode; install it from https://opencode.ai and put it on PATH");
|
|
97
|
-
ran = false;
|
|
98
|
-
done(1);
|
|
99
|
-
});
|
|
100
|
-
child.on("exit", (value) => done(value ?? 0));
|
|
101
|
-
});
|
|
102
|
-
server.stop(true);
|
|
103
|
-
if (!ran) process.exit(1);
|
|
104
|
-
|
|
105
|
-
let request;
|
|
106
|
-
try {
|
|
107
|
-
request = JSON.parse(readFileSync(capturedPath, "utf8"));
|
|
108
|
-
} catch {
|
|
109
|
-
console.error("no request was captured; opencode did not reach the local server");
|
|
110
|
-
process.exit(1);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const body = JSON.parse(request.body);
|
|
114
|
-
const tools = {};
|
|
115
|
-
for (const raw of body.tools ?? []) {
|
|
116
|
-
const tool = raw.function ?? raw;
|
|
117
|
-
if (!tool?.name || !READ.includes(tool.name) || !tool.description) continue;
|
|
118
|
-
tools[tool.name] = {
|
|
119
|
-
name: tool.name,
|
|
120
|
-
description: NEUTRALISE.reduce((text, [find, to]) => text.replace(find, to), tool.description).slice(0, KEEP),
|
|
121
|
-
parameters: tool.parameters,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const missing = READ.filter((name) => !tools[name]);
|
|
126
|
-
if (missing.length) {
|
|
127
|
-
console.error(
|
|
128
|
-
`the captured request has no ${missing.join(", ")}; this OpenCode no longer sends them, so it cannot be captured this way`,
|
|
129
|
-
);
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
for (const [name, tool] of Object.entries(tools))
|
|
133
|
-
if (tool.description.length < 100) {
|
|
134
|
-
console.error(`${name}'s description looks truncated (${tool.description.length} chars); not writing`);
|
|
135
|
-
process.exit(1);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Which generation this client is: the names it sends say so.
|
|
139
|
-
const generation = body.tools.some((t) => (t.function ?? t).name === "shell") ? "2.x" : "1.x";
|
|
140
|
-
const required = generation === "2.x" ? ["shell", "read"] : ["bash", "glob", "grep", "read"];
|
|
141
|
-
const absent = required.filter((name) => !tools[name]);
|
|
142
|
-
if (absent.length) {
|
|
143
|
-
console.error(`a ${generation} request must carry ${absent.join(", ")}, and the capture does not`);
|
|
144
|
-
process.exit(1);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Which protocol was captured, since each of 1.x's protocols bundles its own
|
|
148
|
-
// provider-utils and a capture must not overwrite the ones it did not observe.
|
|
149
|
-
const protocol = body.input !== undefined && body.messages === undefined ? "responses" : "chat";
|
|
150
|
-
|
|
151
|
-
console.log(`\nagent: ${request.agent}`);
|
|
152
|
-
console.log(`tools: ${Object.keys(tools).join(", ")} (${generation}, ${protocol})`);
|
|
153
|
-
if (code !== 0) console.log(`(opencode exited ${code}; its request was still captured)`);
|
|
154
|
-
|
|
155
|
-
const agent = parseAgent(request.agent);
|
|
156
|
-
if (agent.providerUtils) agent.providerUtils = { ...PROFILES[generation]?.agent?.providerUtils, [protocol]: agent.providerUtils[protocol] };
|
|
157
|
-
|
|
158
|
-
const before = PROFILES[generation];
|
|
159
|
-
const agentChanged = JSON.stringify(before?.agent) !== JSON.stringify(agent);
|
|
160
|
-
let changed = 0;
|
|
161
|
-
for (const name of Object.keys(tools)) {
|
|
162
|
-
if (JSON.stringify(before?.tools?.[name]) !== JSON.stringify(tools[name])) changed++;
|
|
163
|
-
console.log(` ${name.padEnd(6)} ${JSON.stringify(before?.tools?.[name]) === JSON.stringify(tools[name]) ? "unchanged" : "CHANGED"} ${tools[name].description.length} chars`);
|
|
164
|
-
}
|
|
165
|
-
if (agent.providerUtils)
|
|
166
|
-
console.log(` agent ${agentChanged ? "CHANGED" : "unchanged"} ${protocol} provider-utils kept separate from the others`);
|
|
167
|
-
|
|
168
|
-
if (DRY) {
|
|
169
|
-
const what = [changed ? `${changed} tool(s)` : null, agentChanged ? "the agent" : null].filter(Boolean);
|
|
170
|
-
console.log(`\n${what.length ? `${what.join(" and ")} would change.` : "Nothing would change."} Nothing written (--dry).`);
|
|
171
|
-
process.exit(what.length ? 1 : 0);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (!changed && !agentChanged) {
|
|
175
|
-
console.log("\nnothing changed.");
|
|
176
|
-
process.exit(0);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const next = JSON.parse(readFileSync(FINGERPRINT, "utf8"));
|
|
180
|
-
next.profiles[generation] = { agent, required, tools };
|
|
181
|
-
await Bun.write(FINGERPRINT, JSON.stringify(next, null, 2) + "\n");
|
|
182
|
-
console.log(`\nwrote ${FINGERPRINT} for ${generation}. Now run \`bun run check:gate\` and commit if it passes.`);
|
|
183
|
-
|
|
184
|
-
// The User-Agent is stored the way agentFor() reads it back, which is not
|
|
185
|
-
// necessarily the same split the raw header used.
|
|
186
|
-
function parseAgent(agent) {
|
|
187
|
-
const twoPart = /^opencode\/(\d[^ ]*) ai-sdk\/provider-utils\/(\S+) (\S+)$/.exec(agent ?? "");
|
|
188
|
-
if (twoPart) {
|
|
189
|
-
const [, opencode, providerUtils, runtime] = twoPart;
|
|
190
|
-
return {
|
|
191
|
-
opencode,
|
|
192
|
-
providerUtils: { chat: providerUtils, responses: providerUtils, anthropic: providerUtils },
|
|
193
|
-
runtime,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
const threePart = /^opencode\/([a-z]+)\/(\d[^/]*)\/([a-z-]+)$/.exec(agent ?? "");
|
|
197
|
-
if (threePart) {
|
|
198
|
-
const [, channel, opencode, client] = threePart;
|
|
199
|
-
return { channel, opencode, client };
|
|
200
|
-
}
|
|
201
|
-
console.error(`\ncould not read \`${agent}\` as an OpenCode User-Agent; write PROFILES[${generation}].agent by hand`);
|
|
202
|
-
process.exit(1);
|
|
203
|
-
}
|