toolnexus 0.18.1 → 0.20.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/dist/acp.d.ts +42 -0
- package/dist/acp.js +246 -0
- package/dist/agents/agent.d.ts +12 -1
- package/dist/agents/agent.js +3 -2
- package/dist/agents/home.js +4 -2
- package/dist/agents/loop.d.ts +21 -0
- package/dist/agents/loop.js +38 -2
- package/dist/agents/runtime.d.ts +76 -6
- package/dist/agents/runtime.js +92 -22
- package/dist/builtin.d.ts +32 -2
- package/dist/builtin.js +342 -58
- package/dist/classifier.d.ts +23 -0
- package/dist/classifier.js +33 -25
- package/dist/client.d.ts +116 -16
- package/dist/client.js +197 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/order.d.ts +46 -0
- package/dist/order.js +71 -0
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +110 -27
- package/dist/types.d.ts +11 -0
- package/dist/types.js +22 -0
- package/package.json +2 -2
- package/src/acp.ts +308 -0
- package/src/agents/agent.ts +15 -3
- package/src/agents/home.ts +4 -2
- package/src/agents/loop.ts +40 -2
- package/src/agents/runtime.ts +154 -29
- package/src/builtin.ts +379 -55
- package/src/classifier.ts +45 -27
- package/src/client.ts +220 -51
- package/src/index.ts +1 -0
- package/src/order.ts +70 -0
- package/src/skill.ts +121 -27
- package/src/types.ts +22 -0
package/dist/builtin.js
CHANGED
|
@@ -7,12 +7,151 @@
|
|
|
7
7
|
* ToolResult{isError:true}, never a thrown exception across the boundary. Paths
|
|
8
8
|
* resolve relative to the process working directory unless absolute.
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from "node:child_process";
|
|
10
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
11
11
|
import fs from "node:fs/promises";
|
|
12
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
|
13
|
+
import { compareCodePoints, sortEntriesByName, toPosixPath } from "./order.js";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import { pending } from "./types.js";
|
|
15
16
|
import { mediaTypeFor } from "./content.js";
|
|
17
|
+
/**
|
|
18
|
+
* How long a job gets between "please stop" and "stop". Fixed, and identical in
|
|
19
|
+
* every port, so a timeout means the same thing everywhere.
|
|
20
|
+
*/
|
|
21
|
+
const KILL_GRACE_MS = 2000;
|
|
22
|
+
const WIN_RESERVED = new Set([
|
|
23
|
+
"CON", "PRN", "AUX", "NUL",
|
|
24
|
+
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
|
25
|
+
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
|
26
|
+
]);
|
|
27
|
+
/** Does `name` resolve as an executable on PATH? (PATHEXT-aware on Windows.) */
|
|
28
|
+
function resolvesOnPath(name) {
|
|
29
|
+
if (name.includes(path.sep) || name.includes("/"))
|
|
30
|
+
return existsSync(name);
|
|
31
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
32
|
+
const exts = process.platform === "win32"
|
|
33
|
+
? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
34
|
+
: [""];
|
|
35
|
+
for (const dir of dirs) {
|
|
36
|
+
for (const ext of exts) {
|
|
37
|
+
if (existsSync(path.join(dir, name + ext)))
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The interpreters tried when the host names none. %COMSPEC% leads on Windows
|
|
45
|
+
* because PowerShell is routinely blocked by execution or application-control
|
|
46
|
+
* policy, while %COMSPEC% is always present.
|
|
47
|
+
*/
|
|
48
|
+
function shellCandidates() {
|
|
49
|
+
// `/bin/sh` first, then a PATH lookup: `shell: true` means literally
|
|
50
|
+
// `/bin/sh`, while spawning the bare name `sh` is a PATH lookup — two
|
|
51
|
+
// different binaries on a machine that has both. Naming the absolute path
|
|
52
|
+
// first is what makes dropping `shell: true` byte-identical here.
|
|
53
|
+
if (process.platform !== "win32")
|
|
54
|
+
return [["/bin/sh", "-c"], ["sh", "-c"]];
|
|
55
|
+
const out = [];
|
|
56
|
+
if (process.env.COMSPEC)
|
|
57
|
+
out.push([process.env.COMSPEC, "/d", "/s", "/c"]);
|
|
58
|
+
out.push(["cmd.exe", "/d", "/s", "/c"], ["pwsh", "-NoProfile", "-Command"], ["powershell", "-NoProfile", "-Command"], ["bash", "-lc"]);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The host boundary the builtins are allowed to know about. Constructed once,
|
|
63
|
+
* at toolkit construction: a missing interpreter is a configuration fact, and
|
|
64
|
+
* turn fourteen of a paid run is the expensive place to learn it.
|
|
65
|
+
*/
|
|
66
|
+
class BuiltinEnv {
|
|
67
|
+
shell;
|
|
68
|
+
shellError;
|
|
69
|
+
baseDir;
|
|
70
|
+
confine;
|
|
71
|
+
constructor(cfg) {
|
|
72
|
+
const obj = typeof cfg === "object" && cfg !== null ? cfg : {};
|
|
73
|
+
this.baseDir = obj.baseDir ?? "";
|
|
74
|
+
this.confine = obj.confineToBaseDir === true;
|
|
75
|
+
if (obj.shell && obj.shell.length > 0) {
|
|
76
|
+
this.shell = obj.shell;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const tried = [];
|
|
80
|
+
for (const argv of shellCandidates()) {
|
|
81
|
+
tried.push(argv[0]);
|
|
82
|
+
if (resolvesOnPath(argv[0])) {
|
|
83
|
+
this.shell = argv;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
this.shell = [];
|
|
88
|
+
this.shellError = new Error(`no shell interpreter found (tried: ${tried.join(", ")}); set builtins.shell, or disable the bash builtin with builtins.tools.bash = false`);
|
|
89
|
+
}
|
|
90
|
+
/** The interpreter argv, or the detection error when there is none. */
|
|
91
|
+
shellArgv() {
|
|
92
|
+
if (this.shellError)
|
|
93
|
+
throw this.shellError;
|
|
94
|
+
return this.shell;
|
|
95
|
+
}
|
|
96
|
+
get shellLabel() {
|
|
97
|
+
return this.shell.join(" ");
|
|
98
|
+
}
|
|
99
|
+
/** The directory a command or a walk starts from. */
|
|
100
|
+
dir() {
|
|
101
|
+
return this.baseDir || process.cwd();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Map a tool-supplied path onto the filesystem: relative to baseDir (or, with
|
|
105
|
+
* none, exactly as before), and refused when confinement is on and the
|
|
106
|
+
* canonical target is outside the base.
|
|
107
|
+
*/
|
|
108
|
+
resolvePath(p) {
|
|
109
|
+
if (this.confine && !this.baseDir) {
|
|
110
|
+
throw new Error("confineToBaseDir is set but baseDir is empty");
|
|
111
|
+
}
|
|
112
|
+
const full = this.baseDir && !path.isAbsolute(p) ? path.join(this.baseDir, p) : p;
|
|
113
|
+
if (!this.confine)
|
|
114
|
+
return full;
|
|
115
|
+
if (process.platform === "win32") {
|
|
116
|
+
const base = path.basename(full).split(".")[0].trim().toUpperCase();
|
|
117
|
+
if (WIN_RESERVED.has(base)) {
|
|
118
|
+
throw new Error(`${p} names a reserved device, which is not a file inside ${this.baseDir}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const canonBase = canonicalPath(this.baseDir);
|
|
122
|
+
const canonTarget = canonicalPath(full);
|
|
123
|
+
const rel = path.relative(canonBase, canonTarget);
|
|
124
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
125
|
+
throw new Error(`${p} resolves outside baseDir ${this.baseDir}`);
|
|
126
|
+
}
|
|
127
|
+
return full;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve a path for comparison. Symlinks — and, on Windows, directory
|
|
132
|
+
* junctions and 8.3 short names — are resolved on the DEEPEST EXISTING
|
|
133
|
+
* ancestor and the remaining segments re-attached, because a file `write` is
|
|
134
|
+
* about to create has no real path, and a check that only works on existing
|
|
135
|
+
* files is not a check for `write`.
|
|
136
|
+
*/
|
|
137
|
+
function canonicalPath(p) {
|
|
138
|
+
const abs = path.resolve(p);
|
|
139
|
+
let cur = abs;
|
|
140
|
+
let tail = "";
|
|
141
|
+
for (;;) {
|
|
142
|
+
try {
|
|
143
|
+
const resolved = realpathSync.native(cur);
|
|
144
|
+
return tail ? path.join(resolved, tail) : resolved;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
const parent = path.dirname(cur);
|
|
148
|
+
if (parent === cur)
|
|
149
|
+
return abs;
|
|
150
|
+
tail = tail ? path.join(path.basename(cur), tail) : path.basename(cur);
|
|
151
|
+
cur = parent;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
16
155
|
/**
|
|
17
156
|
* Whether the builtin source is on. Default ON. Same precedence as MCP:
|
|
18
157
|
* `disabled:true` wins, else `enabled:false` disables, otherwise enabled.
|
|
@@ -38,10 +177,14 @@ export function selectBuiltins(cfg) {
|
|
|
38
177
|
if (!builtinsEnabled(cfg))
|
|
39
178
|
return [];
|
|
40
179
|
const map = typeof cfg === "object" ? cfg.tools : undefined;
|
|
41
|
-
const all = createBuiltinTools();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
180
|
+
const all = createBuiltinTools(cfg);
|
|
181
|
+
const selected = map ? all.filter((t) => map[t.name] !== false) : all;
|
|
182
|
+
// A missing interpreter is a construction-time failure, and only when `bash`
|
|
183
|
+
// survived the toggles: a host that disabled it should run fine on a box with
|
|
184
|
+
// no shell at all (ADR 0034 D1).
|
|
185
|
+
if (selected.some((t) => t.name === "bash"))
|
|
186
|
+
builtinShell(cfg);
|
|
187
|
+
return selected;
|
|
45
188
|
}
|
|
46
189
|
const err = (output, metadata) => ({ output, isError: true, metadata });
|
|
47
190
|
const ok = (output, metadata) => ({ output, isError: false, metadata });
|
|
@@ -108,7 +251,9 @@ function walkFiles(root) {
|
|
|
108
251
|
const dir = stack.pop();
|
|
109
252
|
let entries;
|
|
110
253
|
try {
|
|
111
|
-
|
|
254
|
+
// EXPLICIT (A24). `glob` breaks at its cap, so an unsorted read changes WHICH files the
|
|
255
|
+
// model is shown, not merely their order — the same content bug as <skill_files> had.
|
|
256
|
+
entries = sortEntriesByName(readdirSync(dir, { withFileTypes: true }));
|
|
112
257
|
}
|
|
113
258
|
catch {
|
|
114
259
|
continue;
|
|
@@ -130,7 +275,7 @@ function walkFiles(root) {
|
|
|
130
275
|
// ---------------------------------------------------------------------------
|
|
131
276
|
// individual tools
|
|
132
277
|
// ---------------------------------------------------------------------------
|
|
133
|
-
function bashTool() {
|
|
278
|
+
function bashTool(env) {
|
|
134
279
|
return builtin("bash", "Run a shell command and return its combined stdout+stderr. Non-zero exit is an error.", {
|
|
135
280
|
type: "object",
|
|
136
281
|
properties: {
|
|
@@ -141,37 +286,131 @@ function bashTool() {
|
|
|
141
286
|
},
|
|
142
287
|
required: ["command"],
|
|
143
288
|
additionalProperties: false,
|
|
144
|
-
}, (args) => new Promise((resolve) => {
|
|
289
|
+
}, (args, ctx) => new Promise((resolve) => {
|
|
145
290
|
const command = String(args.command ?? "");
|
|
146
291
|
if (!command)
|
|
147
292
|
return resolve(err("bash: command is required"));
|
|
148
|
-
|
|
293
|
+
let argv;
|
|
294
|
+
let workdir;
|
|
295
|
+
try {
|
|
296
|
+
argv = env.shellArgv();
|
|
297
|
+
workdir = args.workdir ? env.resolvePath(String(args.workdir)) : env.dir();
|
|
298
|
+
}
|
|
299
|
+
catch (e) {
|
|
300
|
+
return resolve(err(`bash: ${e.message}`));
|
|
301
|
+
}
|
|
149
302
|
const timeout = typeof args.timeout === "number" ? args.timeout : 60_000;
|
|
150
|
-
const
|
|
303
|
+
const meta = { shell: env.shellLabel };
|
|
304
|
+
// NOT `shell: true`: on Windows that silently means cmd.exe while every
|
|
305
|
+
// other port fails loudly, and it hides WHICH interpreter ran. `detached`
|
|
306
|
+
// puts the child in its own process group so a kill reaches the command
|
|
307
|
+
// AND everything it started — without it the kill reaches the
|
|
308
|
+
// interpreter only and the real work runs on, reparented (ADR 0034 D4).
|
|
309
|
+
const child = spawn(argv[0], [...argv.slice(1), command], {
|
|
310
|
+
cwd: workdir,
|
|
311
|
+
detached: process.platform !== "win32",
|
|
312
|
+
});
|
|
151
313
|
let out = "";
|
|
152
|
-
let
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
314
|
+
let stopped = "";
|
|
315
|
+
let killedTree = false;
|
|
316
|
+
let settled = false;
|
|
317
|
+
// Captured once, at spawn: `detached` makes the child a process-group
|
|
318
|
+
// LEADER, so its pid is the pgid. Reading it later from `child` would
|
|
319
|
+
// risk signalling by a pid the runtime has already let go of.
|
|
320
|
+
const pgid = child.pid;
|
|
321
|
+
const killJob = (graceful) => {
|
|
322
|
+
// Nothing may be signalled once this call has settled: a stale timer
|
|
323
|
+
// firing after the fact would aim at a pid the OS may have reissued,
|
|
324
|
+
// and a NEGATED pid is a whole process group.
|
|
325
|
+
if (settled)
|
|
326
|
+
return false;
|
|
327
|
+
if (typeof pgid !== "number" || !Number.isInteger(pgid) || pgid <= 1)
|
|
328
|
+
return false;
|
|
329
|
+
try {
|
|
330
|
+
if (process.platform === "win32") {
|
|
331
|
+
// Windows has NO graceful termination for a console process, and
|
|
332
|
+
// asking anyway is worse than not asking: measured on a native
|
|
333
|
+
// Windows box, `taskkill /T` without `/F` refuses every console
|
|
334
|
+
// process in the tree ("can only be terminated forcefully") while
|
|
335
|
+
// still being able to take the parent down — which reparents the
|
|
336
|
+
// grandchild and leaves it running. So the job is terminated at
|
|
337
|
+
// once there, and the grace window is a POSIX effect (ADR 0034 D4).
|
|
338
|
+
const r = spawnSync("taskkill", ["/T", "/F", "/PID", String(pgid)]);
|
|
339
|
+
return r.status === 0;
|
|
340
|
+
}
|
|
341
|
+
// Never signal a REAPED child by negated pid: that pid may belong to
|
|
342
|
+
// something else by now, and the blast radius is a whole process
|
|
343
|
+
// GROUP. (On Windows above, taskkill targets one pid and a dead pid
|
|
344
|
+
// is merely an error, so the same guard is not needed there — and
|
|
345
|
+
// must not be applied, because the tree can outlive the child.)
|
|
346
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
347
|
+
return false;
|
|
348
|
+
process.kill(-pgid, graceful ? "SIGTERM" : "SIGKILL");
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
// Ask, wait out the grace window, then insist. A runner that gets
|
|
356
|
+
// SIGTERM removes its temp directories; one that gets SIGKILL does not.
|
|
357
|
+
const stop = (why) => {
|
|
358
|
+
if (stopped)
|
|
359
|
+
return;
|
|
360
|
+
stopped = why;
|
|
361
|
+
killedTree = killJob(true);
|
|
362
|
+
graceTimer = setTimeout(() => {
|
|
363
|
+
if (killJob(false))
|
|
364
|
+
killedTree = true;
|
|
365
|
+
}, KILL_GRACE_MS);
|
|
366
|
+
};
|
|
367
|
+
let graceTimer;
|
|
368
|
+
const timer = setTimeout(() => stop("timeout"), timeout);
|
|
369
|
+
const signal = ctx?.signal;
|
|
370
|
+
const onAbort = () => stop("cancelled");
|
|
371
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
372
|
+
const finish = (result) => {
|
|
373
|
+
if (settled)
|
|
374
|
+
return;
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
if (graceTimer)
|
|
378
|
+
clearTimeout(graceTimer);
|
|
379
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
380
|
+
resolve(result);
|
|
381
|
+
};
|
|
157
382
|
child.stdout?.on("data", (d) => (out += d.toString()));
|
|
158
383
|
child.stderr?.on("data", (d) => (out += d.toString()));
|
|
159
|
-
child.on("error", (e) => {
|
|
160
|
-
clearTimeout(timer);
|
|
161
|
-
resolve(err(`bash: ${e.message}`));
|
|
162
|
-
});
|
|
384
|
+
child.on("error", (e) => finish(err(`bash: ${e.message}`, meta)));
|
|
163
385
|
child.on("close", (code) => {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
386
|
+
if (stopped) {
|
|
387
|
+
// WINDOWS ONLY, and the platform guard is the point. There, the
|
|
388
|
+
// direct child closing does not mean the job is gone — a detached
|
|
389
|
+
// grandchild outlives it — so the tree is terminated here by pid.
|
|
390
|
+
//
|
|
391
|
+
// On POSIX this must NOT run. `close` only fires once the stdio the
|
|
392
|
+
// grandchild inherited has closed, so the job is already gone; and
|
|
393
|
+
// signalling `-pid` after the child has been reaped aims at whatever
|
|
394
|
+
// process now owns that pid. PIDs recycle fast on a busy CI box, and
|
|
395
|
+
// the blast radius of a negated pid is a whole process GROUP — which
|
|
396
|
+
// is how this killed sibling test workers on the GitHub runner while
|
|
397
|
+
// passing on macOS, in a container, and on its own.
|
|
398
|
+
if (!killedTree && process.platform === "win32")
|
|
399
|
+
killedTree = killJob(false);
|
|
400
|
+
meta.timedOut = stopped === "timeout";
|
|
401
|
+
meta.killedTree = killedTree;
|
|
402
|
+
return finish(err(stopped === "timeout"
|
|
403
|
+
? `bash: command timed out after ${timeout}ms\n${out}`
|
|
404
|
+
: `bash: command cancelled\n${out}`, meta));
|
|
169
405
|
}
|
|
170
|
-
|
|
406
|
+
meta.exitCode = code;
|
|
407
|
+
if (code !== 0)
|
|
408
|
+
return finish(err(`${out}\nbash: command exited with code ${code}`, meta));
|
|
409
|
+
finish(ok(out, meta));
|
|
171
410
|
});
|
|
172
411
|
}));
|
|
173
412
|
}
|
|
174
|
-
function readTool() {
|
|
413
|
+
function readTool(env) {
|
|
175
414
|
return builtin("read", "Read a file. Recognised media (png/jpg/jpeg/gif/webp/pdf/mp3/wav) comes back as a content part; anything else is read as UTF-8 text, with offset/limit returning that line window.", {
|
|
176
415
|
type: "object",
|
|
177
416
|
properties: {
|
|
@@ -186,15 +425,17 @@ function readTool() {
|
|
|
186
425
|
if (!p)
|
|
187
426
|
return err("read: path is required");
|
|
188
427
|
let bytes;
|
|
428
|
+
let full;
|
|
189
429
|
try {
|
|
190
|
-
|
|
430
|
+
full = env.resolvePath(p);
|
|
431
|
+
bytes = await fs.readFile(full);
|
|
191
432
|
}
|
|
192
433
|
catch (e) {
|
|
193
434
|
return err(`read: ${e instanceof Error ? e.message : String(e)}`);
|
|
194
435
|
}
|
|
195
436
|
// §6: a recognised media extension comes back as a part. The table is fixed — no magic-byte
|
|
196
437
|
// sniffing and no platform mime database, whose contents vary per machine.
|
|
197
|
-
const media = mediaTypeFor(
|
|
438
|
+
const media = mediaTypeFor(full);
|
|
198
439
|
if (media) {
|
|
199
440
|
return {
|
|
200
441
|
output: `${p} (${media.mimeType}, ${bytes.length} bytes)`,
|
|
@@ -221,7 +462,7 @@ function readTool() {
|
|
|
221
462
|
return ok(lines.slice(start, start + limit).join("\n"));
|
|
222
463
|
});
|
|
223
464
|
}
|
|
224
|
-
function writeTool() {
|
|
465
|
+
function writeTool(env) {
|
|
225
466
|
return builtin("write", "Write content to a file (create/overwrite), creating parent directories.", {
|
|
226
467
|
type: "object",
|
|
227
468
|
properties: {
|
|
@@ -235,13 +476,20 @@ function writeTool() {
|
|
|
235
476
|
if (!p)
|
|
236
477
|
return err("write: path is required");
|
|
237
478
|
const content = typeof args.content === "string" ? args.content : String(args.content ?? "");
|
|
238
|
-
|
|
239
|
-
|
|
479
|
+
let full;
|
|
480
|
+
try {
|
|
481
|
+
full = env.resolvePath(p);
|
|
482
|
+
}
|
|
483
|
+
catch (e) {
|
|
484
|
+
return err(`write: ${e instanceof Error ? e.message : String(e)}`);
|
|
485
|
+
}
|
|
486
|
+
await fs.mkdir(path.dirname(path.resolve(full)), { recursive: true });
|
|
487
|
+
await fs.writeFile(full, content, "utf8");
|
|
240
488
|
const bytes = Buffer.byteLength(content, "utf8");
|
|
241
489
|
return ok(`Wrote ${bytes} bytes to ${p}`, { bytes });
|
|
242
490
|
});
|
|
243
491
|
}
|
|
244
|
-
function editTool() {
|
|
492
|
+
function editTool(env) {
|
|
245
493
|
return builtin("edit", "Exact-string replace in a file. Default replaces a single unique occurrence; replaceAll replaces all.", {
|
|
246
494
|
type: "object",
|
|
247
495
|
properties: {
|
|
@@ -262,8 +510,10 @@ function editTool() {
|
|
|
262
510
|
const oldString = args.oldString;
|
|
263
511
|
const newString = typeof args.newString === "string" ? args.newString : String(args.newString ?? "");
|
|
264
512
|
let content;
|
|
513
|
+
let full;
|
|
265
514
|
try {
|
|
266
|
-
|
|
515
|
+
full = env.resolvePath(p);
|
|
516
|
+
content = await fs.readFile(full, "utf8");
|
|
267
517
|
}
|
|
268
518
|
catch (e) {
|
|
269
519
|
return err(`edit: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -286,7 +536,7 @@ function editTool() {
|
|
|
286
536
|
});
|
|
287
537
|
});
|
|
288
538
|
}
|
|
289
|
-
function grepTool() {
|
|
539
|
+
function grepTool(env) {
|
|
290
540
|
return builtin("grep", "Search file contents by regex under a directory. Output is file:line:text matches.", {
|
|
291
541
|
type: "object",
|
|
292
542
|
properties: {
|
|
@@ -308,14 +558,22 @@ function grepTool() {
|
|
|
308
558
|
catch (e) {
|
|
309
559
|
return err(`grep: invalid regex: ${e instanceof Error ? e.message : String(e)}`);
|
|
310
560
|
}
|
|
311
|
-
|
|
561
|
+
let root;
|
|
562
|
+
try {
|
|
563
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir();
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
return err(`grep: ${e instanceof Error ? e.message : String(e)}`);
|
|
567
|
+
}
|
|
312
568
|
const include = args.include ? String(args.include) : undefined;
|
|
313
569
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
314
|
-
|
|
570
|
+
// Was the worse of the two builtins: capped MID-WALK and never sorted at all, so both the
|
|
571
|
+
// CONTENT and the order of the results were whatever the walk happened to reach first.
|
|
572
|
+
// Now: collect every match, order by relative path (code point) then line number ascending,
|
|
573
|
+
// and truncate last. The emitted path and the sort key are the same string (A28).
|
|
574
|
+
const hits = [];
|
|
315
575
|
for (const file of walkFiles(root)) {
|
|
316
|
-
|
|
317
|
-
break;
|
|
318
|
-
const rel = path.relative(root, file);
|
|
576
|
+
const rel = toPosixPath(path.relative(root, file), path.sep);
|
|
319
577
|
if (include && !matchGlob(rel, include))
|
|
320
578
|
continue;
|
|
321
579
|
let text;
|
|
@@ -327,16 +585,16 @@ function grepTool() {
|
|
|
327
585
|
}
|
|
328
586
|
const lines = text.split("\n");
|
|
329
587
|
for (let i = 0; i < lines.length; i++) {
|
|
330
|
-
if (matches.length >= limit)
|
|
331
|
-
break;
|
|
332
588
|
if (re.test(lines[i]))
|
|
333
|
-
|
|
589
|
+
hits.push({ rel, line: i + 1, text: lines[i] });
|
|
334
590
|
}
|
|
335
591
|
}
|
|
592
|
+
hits.sort((a, b) => compareCodePoints(a.rel, b.rel) || a.line - b.line);
|
|
593
|
+
const matches = hits.slice(0, limit).map((h) => `${h.rel}:${h.line}:${h.text}`);
|
|
336
594
|
return ok(matches.join("\n"), { count: matches.length });
|
|
337
595
|
});
|
|
338
596
|
}
|
|
339
|
-
function globTool() {
|
|
597
|
+
function globTool(env) {
|
|
340
598
|
return builtin("glob", "List files matching a glob under a directory. Output is newline-joined relative paths.", {
|
|
341
599
|
type: "object",
|
|
342
600
|
properties: {
|
|
@@ -350,17 +608,24 @@ function globTool() {
|
|
|
350
608
|
const pattern = String(args.pattern ?? "");
|
|
351
609
|
if (!pattern)
|
|
352
610
|
return err("glob: pattern is required");
|
|
353
|
-
|
|
611
|
+
let root;
|
|
612
|
+
try {
|
|
613
|
+
root = args.path ? env.resolvePath(String(args.path)) : env.dir();
|
|
614
|
+
}
|
|
615
|
+
catch (e) {
|
|
616
|
+
return err(`glob: ${e instanceof Error ? e.message : String(e)}`);
|
|
617
|
+
}
|
|
354
618
|
const limit = typeof args.limit === "number" ? args.limit : 100;
|
|
619
|
+
// Collect ALL matches, order them, THEN cap (A24/A25): breaking the walk at the cap let
|
|
620
|
+
// the filesystem decide WHICH files the model sees, not merely their order. The emitted
|
|
621
|
+
// string and the sort key are the SAME `/`-separated relative path (A28).
|
|
355
622
|
const found = [];
|
|
356
623
|
for (const file of walkFiles(root)) {
|
|
357
|
-
|
|
358
|
-
break;
|
|
359
|
-
const rel = path.relative(root, file);
|
|
624
|
+
const rel = toPosixPath(path.relative(root, file), path.sep);
|
|
360
625
|
if (matchGlob(rel, pattern))
|
|
361
626
|
found.push(rel);
|
|
362
627
|
}
|
|
363
|
-
found.sort();
|
|
628
|
+
found.sort(compareCodePoints);
|
|
364
629
|
return ok(found.slice(0, limit).join("\n"), { count: Math.min(found.length, limit) });
|
|
365
630
|
});
|
|
366
631
|
}
|
|
@@ -575,7 +840,7 @@ function applyUpdate(content, body) {
|
|
|
575
840
|
}
|
|
576
841
|
return result;
|
|
577
842
|
}
|
|
578
|
-
function applyPatchTool() {
|
|
843
|
+
function applyPatchTool(env) {
|
|
579
844
|
return builtin("apply_patch", "Apply a patch (Begin/End Patch grammar: Add/Update/Delete File). Atomic — a non-matching hunk aborts with no writes.", {
|
|
580
845
|
type: "object",
|
|
581
846
|
properties: {
|
|
@@ -594,6 +859,16 @@ function applyPatchTool() {
|
|
|
594
859
|
catch (e) {
|
|
595
860
|
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
|
|
596
861
|
}
|
|
862
|
+
// The paths live INSIDE the patch text, not in the arguments, so a host
|
|
863
|
+
// cannot rewrite them from a hook — this is the one case that genuinely
|
|
864
|
+
// needs the base directory to be library-side (ADR 0034 D2).
|
|
865
|
+
try {
|
|
866
|
+
for (const op of ops)
|
|
867
|
+
op.path = env.resolvePath(op.path);
|
|
868
|
+
}
|
|
869
|
+
catch (e) {
|
|
870
|
+
return err(`apply_patch: ${e instanceof Error ? e.message : String(e)}`);
|
|
871
|
+
}
|
|
597
872
|
// Stage every write/delete first; only touch the filesystem once all hunks apply.
|
|
598
873
|
const writes = [];
|
|
599
874
|
const deletes = [];
|
|
@@ -637,17 +912,26 @@ function applyPatchTool() {
|
|
|
637
912
|
* for parity: bash, read, write, edit, grep, glob, webfetch, question,
|
|
638
913
|
* apply_patch, todowrite.
|
|
639
914
|
*/
|
|
640
|
-
export function createBuiltinTools() {
|
|
915
|
+
export function createBuiltinTools(cfg) {
|
|
916
|
+
const env = new BuiltinEnv(cfg);
|
|
641
917
|
return [
|
|
642
|
-
bashTool(),
|
|
643
|
-
readTool(),
|
|
644
|
-
writeTool(),
|
|
645
|
-
editTool(),
|
|
646
|
-
grepTool(),
|
|
647
|
-
globTool(),
|
|
918
|
+
bashTool(env),
|
|
919
|
+
readTool(env),
|
|
920
|
+
writeTool(env),
|
|
921
|
+
editTool(env),
|
|
922
|
+
grepTool(env),
|
|
923
|
+
globTool(env),
|
|
648
924
|
webfetchTool(),
|
|
649
925
|
questionTool(),
|
|
650
|
-
applyPatchTool(),
|
|
926
|
+
applyPatchTool(env),
|
|
651
927
|
todowriteTool(),
|
|
652
928
|
];
|
|
653
929
|
}
|
|
930
|
+
/**
|
|
931
|
+
* The interpreter the builtins resolved for `bash` — what a host prints, and
|
|
932
|
+
* what `metadata.shell` carries on every bash result. Throws the same error
|
|
933
|
+
* `selectBuiltins` fails construction with when nothing resolves.
|
|
934
|
+
*/
|
|
935
|
+
export function builtinShell(cfg) {
|
|
936
|
+
return new BuiltinEnv(cfg).shellArgv();
|
|
937
|
+
}
|
package/dist/classifier.d.ts
CHANGED
|
@@ -19,6 +19,18 @@ export declare const DEFAULT_CLASSIFIER_MODEL = "jev-latest";
|
|
|
19
19
|
export declare const DEFAULT_CLASSIFIER_API_KEY_ENV = "TYPESAFE_API_KEY";
|
|
20
20
|
/** Bounds ONE request; a classifier has no loop to bound. */
|
|
21
21
|
export declare const DEFAULT_CLASSIFIER_TIMEOUT_MS = 10000;
|
|
22
|
+
/**
|
|
23
|
+
* A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
|
|
24
|
+
* a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
|
|
25
|
+
* `typesafe/jev-1.13`, and mixing the two is issue #91.
|
|
26
|
+
*/
|
|
27
|
+
export type ClassifierBackend = "typesafe" | "openrouter";
|
|
28
|
+
/** The two servable pairings, as configurations rather than six independently-choosable cells. */
|
|
29
|
+
export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
model: string;
|
|
32
|
+
apiKeyEnv: string;
|
|
33
|
+
}>>;
|
|
22
34
|
/** Client-side cap on a choice's named options (§8B). */
|
|
23
35
|
export declare const MAX_CHOICE_OPTIONS = 255;
|
|
24
36
|
/** Bounds of a score rubric (§8B). */
|
|
@@ -217,6 +229,11 @@ export type EvaluateFn = (state: unknown, questions: Record<string, Question>, s
|
|
|
217
229
|
export interface ClassifierOptions {
|
|
218
230
|
/** Default `"systemone"`. */
|
|
219
231
|
style?: ClassifierStyle;
|
|
232
|
+
/** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
|
|
233
|
+
* UNIT. Default `"typesafe"`. The three individual options still win where given, for a
|
|
234
|
+
* self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
|
|
235
|
+
* as an "Unknown model" 400. */
|
|
236
|
+
backend?: ClassifierBackend;
|
|
220
237
|
/** Default `https://api.typesafe.ai/v1`. OpenRouter serves this wire today. */
|
|
221
238
|
baseUrl?: string;
|
|
222
239
|
/** Default `"jev-latest"`. Pin it once thresholds are tuned. */
|
|
@@ -332,3 +349,9 @@ export declare function createClassifier(opts?: ClassifierOptions): Classifier;
|
|
|
332
349
|
* the reason, or "" when the criteria are fine.
|
|
333
350
|
*/
|
|
334
351
|
export declare function degenerateCriteria(criteria: Record<string, string>): string;
|
|
352
|
+
/**
|
|
353
|
+
* The one mixture known to fail, caught before the wire: TypeSafe's unqualified `jev-*` model id
|
|
354
|
+
* pointed at the OpenRouter gateway, which has never heard of it. Returns the message to fail
|
|
355
|
+
* with, or `""` when the pairing is fine.
|
|
356
|
+
*/
|
|
357
|
+
export declare function backendMismatch(baseUrl: string, model: string): string;
|