toolnexus 0.27.0 → 0.28.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.
@@ -14,9 +14,11 @@
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
+ import { randomBytes } from "node:crypto";
17
18
  import { defineTool } from "../native.js";
18
19
  import { agent } from "./agent.js";
19
- import { isVerbError } from "./runtime.js";
20
+ import { AgentRuntime, isVerbError } from "./runtime.js";
21
+ import { transcriptSearchTool } from "../transcript_search.js";
20
22
  /**
21
23
  * Bootstrap discovery order (§7E): identity first, durable memory last. Each present
22
24
  * file is injected into the soul as a `## <filename>` section, in exactly this order.
@@ -55,16 +57,71 @@ function readCapped(file) {
55
57
  }
56
58
  return buf.toString("utf8");
57
59
  }
60
+ /** The preset index cap (§7E "Memory hierarchy"): Claude Code's 200 lines / 25 KB. */
61
+ export const MEMORY_INDEX_CAP_PRESET = { maxLines: 200, maxBytes: 25600 };
62
+ function resolveIndexCap(c) {
63
+ if (c === undefined || c === false)
64
+ return undefined;
65
+ const v = c === true ? {} : c;
66
+ const maxLines = v.maxLines ?? MEMORY_INDEX_CAP_PRESET.maxLines;
67
+ const maxBytes = v.maxBytes ?? MEMORY_INDEX_CAP_PRESET.maxBytes;
68
+ if (!Number.isInteger(maxLines) || maxLines < 1 || !Number.isInteger(maxBytes) || maxBytes < 1) {
69
+ throw new Error("memoryIndexCap: maxLines and maxBytes must be integers >= 1");
70
+ }
71
+ return { maxLines, maxBytes };
72
+ }
73
+ /** The exact notice appended to a capped `MEMORY.md` (§7E "Memory hierarchy"). */
74
+ export function memoryIndexNotice(maxLines, maxBytes, notes) {
75
+ const tail = notes ? `; move detail into notes (memory target "note:<slug>").` : ` (keep entries short).`;
76
+ return `\n\n[MEMORY.md index truncated at ${maxLines} lines / ${maxBytes} bytes. Keep MEMORY.md a short index${tail}]`;
77
+ }
78
+ /** Cut a UTF-8 string at the last character boundary whose byte length is <= `max`. */
79
+ function cutUtf8(s, max) {
80
+ let out = "";
81
+ let bytes = 0;
82
+ for (const ch of s) {
83
+ const n = Buffer.byteLength(ch);
84
+ if (bytes + n > max)
85
+ break;
86
+ out += ch;
87
+ bytes += n;
88
+ }
89
+ return out;
90
+ }
91
+ /**
92
+ * Apply the index cap to `MEMORY.md`'s (already 2 MB-capped) content. Lines are split on
93
+ * "\n"; one trailing "\n" does not start a line. Within both bounds ⇒ returned unchanged.
94
+ * Otherwise keep the first `maxLines` lines, drop trailing whole lines until the "\n"-joined
95
+ * UTF-8 length is <= `maxBytes` (a lone over-long first line is cut at a character
96
+ * boundary), and append {@link memoryIndexNotice}.
97
+ */
98
+ export function applyIndexCap(content, cap, notes = false) {
99
+ const lines = content.split("\n");
100
+ if (content.endsWith("\n"))
101
+ lines.pop();
102
+ if (lines.length <= cap.maxLines && Buffer.byteLength(lines.join("\n")) <= cap.maxBytes)
103
+ return content;
104
+ const kept = lines.slice(0, cap.maxLines);
105
+ while (kept.length > 1 && Buffer.byteLength(kept.join("\n")) > cap.maxBytes)
106
+ kept.pop();
107
+ let text = kept.join("\n");
108
+ if (Buffer.byteLength(text) > cap.maxBytes)
109
+ text = cutUtf8(text, cap.maxBytes);
110
+ return text + memoryIndexNotice(cap.maxLines, cap.maxBytes, notes);
111
+ }
58
112
  /**
59
113
  * Compose the bootstrap files present in `dir` into one soul string — the frozen
60
114
  * snapshot injected as the system prompt for the whole run. Only present files
61
115
  * appear, always in {@link BOOTSTRAP_ORDER}, each as a `## <filename>` section.
62
116
  */
63
- export function composeSoul(dir) {
117
+ export function composeSoul(dir, opts = {}) {
64
118
  const sections = [];
65
119
  const found = [];
120
+ const cap = resolveIndexCap(opts.memoryIndexCap);
66
121
  for (const file of BOOTSTRAP_ORDER) {
67
- const body = readCapped(path.join(dir, file));
122
+ let body = readCapped(path.join(dir, file));
123
+ if (body !== undefined && cap && file === "MEMORY.md")
124
+ body = applyIndexCap(body, cap, opts.notes === true);
68
125
  if (body !== undefined) {
69
126
  sections.push(`## ${file}\n\n${body.trim()}`);
70
127
  found.push(file);
@@ -75,26 +132,98 @@ export function composeSoul(dir) {
75
132
  function memoryFile(dir, target) {
76
133
  return path.join(dir, target === "user" ? "USER.md" : "MEMORY.md");
77
134
  }
135
+ /** A valid note slug (§7E "Memory hierarchy"): lowercase, no path separators, no `..`. */
136
+ export const NOTE_SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
137
+ const MEMORY_DESCRIPTION = "Persist durable memory. action=add appends an entry; replace swaps an " +
138
+ "existing substring (with); remove deletes one. target=self (MEMORY.md, " +
139
+ "default) or user (USER.md). Writes persist to disk and load at the START of " +
140
+ "your NEXT session — they do NOT change your current context.";
141
+ /** The notes-on description (§7E): says what memory is FOR, not rules about what to record. */
142
+ export const MEMORY_NOTES_DESCRIPTION = "Persist durable memory. MEMORY.md is a short index loaded at the start of every " +
143
+ "session; put detail in notes (target note:<slug>, stored in memory/<slug>.md, never " +
144
+ "loaded automatically) and point to them from the index. Record what will change a " +
145
+ "future decision. action=add appends an entry; replace swaps the first occurrence of " +
146
+ "an existing substring (with); remove deletes it; read returns a file; list lists the " +
147
+ "notes. target=self (MEMORY.md, default), user (USER.md) or note:<slug> (slug: " +
148
+ "lowercase letters, digits, '.', '_', '-'). Writes persist to disk and load at the " +
149
+ "START of your NEXT session — they do NOT change your current context.";
150
+ /**
151
+ * Atomic replace (§7E write safety): temp file in the same dir, flushed (fsync), renamed
152
+ * over the target; an existing file's permission bits are kept.
153
+ */
154
+ function atomicWrite(file, data) {
155
+ fs.mkdirSync(path.dirname(file), { recursive: true });
156
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
157
+ try {
158
+ const fd = fs.openSync(tmp, "w"); // new file: 0666 & ~umask
159
+ try {
160
+ fs.writeFileSync(fd, data);
161
+ fs.fsyncSync(fd);
162
+ }
163
+ finally {
164
+ fs.closeSync(fd);
165
+ }
166
+ // Keep an existing file's permission bits (the rename would otherwise give it the temp's).
167
+ if (process.platform !== "win32" && fs.existsSync(file)) {
168
+ fs.chmodSync(tmp, fs.statSync(file).mode & 0o777);
169
+ }
170
+ fs.renameSync(tmp, file);
171
+ }
172
+ catch (e) {
173
+ fs.rmSync(tmp, { force: true });
174
+ throw e;
175
+ }
176
+ }
177
+ /** Read-modify-write over the FULL file (never the capped view). In JS the whole RMW is
178
+ * synchronous, so it cannot interleave with another call in this runtime — the per-path
179
+ * serialization §7E requires holds by construction. */
180
+ function rmw(file, action, text, withText) {
181
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
182
+ let next;
183
+ switch (action) {
184
+ case "add":
185
+ next = (current.trimEnd() + `\n- ${text}\n`).trimStart();
186
+ break;
187
+ case "replace":
188
+ if (!current.includes(text))
189
+ return { output: `not found: ${text}`, isError: true };
190
+ // First occurrence only, inserted LITERALLY (§7E): a function replacement, because a
191
+ // string one would expand `$&`, `$1`, `$$` … in the model's text.
192
+ next = current.replace(text, () => String(withText ?? ""));
193
+ break;
194
+ case "remove":
195
+ if (!current.includes(text))
196
+ return { output: `not found: ${text}`, isError: true };
197
+ next = current.replace(text, () => ""); // first occurrence only (§7E)
198
+ break;
199
+ default:
200
+ return { output: `unknown action: ${String(action)}`, isError: true };
201
+ }
202
+ atomicWrite(file, next);
203
+ return undefined;
204
+ }
78
205
  /**
79
206
  * The `memory` builtin (§7E, file-backed, opt-in). One tool, three actions over
80
207
  * `MEMORY.md` (the agent's own notes) and `USER.md` (its model of the user):
81
208
  *
82
209
  * - `add` — append an entry
83
- * - `replace` — swap an existing substring for `with`
84
- * - `remove` — delete an existing substring
210
+ * - `replace` — swap the first occurrence of an existing substring for `with`
211
+ * - `remove` — delete the first occurrence of an existing substring
212
+ *
213
+ * With `{notes: true}` it also takes `target: "note:<slug>"` (`<dir>/memory/<slug>.md`,
214
+ * never injected into the soul) and two read actions, `read` and `list`.
85
215
  *
86
216
  * Every action writes to **disk**. It does **not** touch the live session's system
87
217
  * prompt: the frozen-snapshot rule means the edit loads only at the START of the
88
218
  * next session (keeping a long-lived persona cache-stable). A `replace`/`remove`
89
219
  * whose substring is absent is a loud `isError` — never a silent no-op.
90
220
  */
91
- export function memoryTool(dir) {
221
+ export function memoryTool(dir, opts = {}) {
222
+ if (opts.notes === true)
223
+ return notesMemoryTool(dir);
92
224
  return defineTool({
93
225
  name: "memory",
94
- description: "Persist durable memory. action=add appends an entry; replace swaps an " +
95
- "existing substring (with); remove deletes one. target=self (MEMORY.md, " +
96
- "default) or user (USER.md). Writes persist to disk and load at the START of " +
97
- "your NEXT session — they do NOT change your current context.",
226
+ description: MEMORY_DESCRIPTION,
98
227
  inputSchema: {
99
228
  type: "object",
100
229
  properties: {
@@ -108,32 +237,103 @@ export function memoryTool(dir) {
108
237
  run: (args) => {
109
238
  const target = args.target ?? "self";
110
239
  const file = memoryFile(dir, target);
111
- const text = String(args.text);
112
- const current = readCapped(file) ?? "";
113
- let next;
114
- switch (args.action) {
115
- case "add":
116
- next = (current.trimEnd() + `\n- ${text}\n`).trimStart();
117
- break;
118
- case "replace":
119
- if (!current.includes(text))
120
- return { output: `not found: ${text}`, isError: true };
121
- next = current.replace(text, String(args.with ?? ""));
122
- break;
123
- case "remove":
124
- if (!current.includes(text))
125
- return { output: `not found: ${text}`, isError: true };
126
- next = current.replace(text, "");
127
- break;
128
- default:
129
- return { output: `unknown action: ${String(args.action)}`, isError: true };
130
- }
131
- fs.mkdirSync(path.dirname(file), { recursive: true });
132
- fs.writeFileSync(file, next);
240
+ const err = rmw(file, args.action, String(args.text), args.with);
241
+ if (err)
242
+ return err;
133
243
  return `ok (${String(args.action)} → ${path.basename(file)}); loads next session`;
134
244
  },
135
245
  });
136
246
  }
247
+ function notesMemoryTool(dir) {
248
+ const notesDir = path.join(dir, "memory");
249
+ return defineTool({
250
+ name: "memory",
251
+ description: MEMORY_NOTES_DESCRIPTION,
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: {
255
+ action: { type: "string", enum: ["add", "replace", "remove", "read", "list"] },
256
+ target: {
257
+ type: "string",
258
+ description: "self=MEMORY.md (default), user=USER.md, note:<slug>=memory/<slug>.md",
259
+ },
260
+ text: { type: "string", description: "For add: the entry. For replace/remove: the existing text." },
261
+ with: { type: "string", description: "For replace: the replacement text." },
262
+ },
263
+ required: ["action"],
264
+ },
265
+ run: (args) => {
266
+ const action = args.action;
267
+ if (action === "list")
268
+ return listNotes(notesDir);
269
+ const target = args.target === undefined ? "self" : String(args.target);
270
+ let file;
271
+ let label;
272
+ if (target === "self" || target === "user") {
273
+ file = memoryFile(dir, target);
274
+ label = path.basename(file);
275
+ }
276
+ else if (target.startsWith("note:")) {
277
+ const slug = target.slice(5);
278
+ if (!NOTE_SLUG_RE.test(slug) || slug.includes("..")) {
279
+ return { output: `invalid note slug: ${slug}`, isError: true };
280
+ }
281
+ file = path.join(notesDir, `${slug}.md`);
282
+ label = `memory/${slug}.md`;
283
+ }
284
+ else {
285
+ return { output: `unknown target: ${target}`, isError: true };
286
+ }
287
+ if (action === "read") {
288
+ const body = readCapped(file);
289
+ if (body === undefined)
290
+ return { output: `not found: ${target}`, isError: true };
291
+ return body;
292
+ }
293
+ if (action !== "add" && action !== "replace" && action !== "remove") {
294
+ return { output: `unknown action: ${String(action)}`, isError: true };
295
+ }
296
+ if (typeof args.text !== "string")
297
+ return { output: "text is required", isError: true };
298
+ const err = rmw(file, action, args.text, args.with);
299
+ if (err)
300
+ return err;
301
+ return label.startsWith("memory/") ? `ok (${action} → ${label})` : `ok (${action} → ${label}); loads next session`;
302
+ },
303
+ });
304
+ }
305
+ /** `list` output (§7E): `<slug>\t<bytes>` per note, slug-ascending (explicit sort). */
306
+ function listNotes(notesDir) {
307
+ let names;
308
+ try {
309
+ names = fs.readdirSync(notesDir);
310
+ }
311
+ catch {
312
+ names = [];
313
+ }
314
+ const rows = [];
315
+ for (const name of names) {
316
+ if (!name.endsWith(".md"))
317
+ continue;
318
+ const slug = name.slice(0, -3);
319
+ if (!NOTE_SLUG_RE.test(slug) || slug.includes(".."))
320
+ continue;
321
+ // lstat: a symlink (dangling or not) is not a regular file and is skipped (§7E).
322
+ let st;
323
+ try {
324
+ st = fs.lstatSync(path.join(notesDir, name));
325
+ }
326
+ catch {
327
+ continue;
328
+ }
329
+ if (st.isFile())
330
+ rows.push([slug, st.size]);
331
+ }
332
+ rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
333
+ return rows.length === 0 ? "(no notes)" : rows.map(([s, n]) => `${s}\t${n}`).join("\n");
334
+ }
335
+ /** Dream config attached by `fromDir(…, {dream})`, read by {@link startAgent}. */
336
+ const DREAMS = new WeakMap();
137
337
  /**
138
338
  * The directory IS the agent (§7E). Discovers the bootstrap files in `dir`, composes
139
339
  * them into a frozen soul snapshot, and (unless `memory:false`) wires a `memory` tool
@@ -141,16 +341,83 @@ export function memoryTool(dir) {
141
341
  * all apply.
142
342
  */
143
343
  export function fromDir(dir, opts = {}) {
144
- const { soul } = composeSoul(dir);
344
+ const { soul } = composeSoul(dir, { memoryIndexCap: opts.memoryIndexCap, notes: opts.notes });
145
345
  const tools = [...(opts.tools ?? [])];
146
346
  if (opts.memory !== false)
147
- tools.push(memoryTool(dir));
148
- return agent(opts.name ?? path.basename(dir), {
347
+ tools.push(memoryTool(dir, { notes: opts.notes }));
348
+ const name = opts.name ?? path.basename(dir);
349
+ const persona = agent(name, {
149
350
  does: opts.does ?? `persona agent from ${dir}`,
150
351
  soul,
151
352
  model: opts.model ?? "inherit",
152
353
  uses: { tools },
153
354
  });
355
+ if (opts.dream !== undefined) {
356
+ const { everyMs, store, instructions } = opts.dream;
357
+ if (!(Number.isFinite(everyMs) && everyMs > 0))
358
+ throw new Error("fromDir: dream.everyMs must be > 0");
359
+ DREAMS.set(persona, { dreamer: dreamer(dir, { store, instructions, name: `${name}-dreamer` }), everyMs });
360
+ }
361
+ return persona;
362
+ }
363
+ /**
364
+ * What a dream pass is FOR and the order it works in (§7E "Dreaming"). Byte-identical in
365
+ * every port. It becomes the dreamer's `## HEARTBEAT.md` section, so the ordinary
366
+ * {@link HEARTBEAT_PROMPT} wake drives a pass and `HEARTBEAT_OK` keeps an idle pass silent.
367
+ */
368
+ export const DREAM_PROMPT = [
369
+ "You are dreaming: an offline pass over your own memory. Memory exists so that a future session decides better. MEMORY.md is a short index loaded at the start of every session; notes (memory target \"note:<slug>\") hold the detail; the transcripts (transcript_search) are the ground truth of what actually happened.",
370
+ "Work in this order:",
371
+ "1. Verify: read MEMORY.md and the notes it points to (memory read, memory list) and check each claim against the transcripts. Fix or remove a claim only when it is contradicted by the transcripts; a claim they do not mention stays (transcripts may be pruned).",
372
+ "2. Demote: move detail out of MEMORY.md into a note and leave a one-line [[slug]] pointer, so the index stays short.",
373
+ "3. Merge: fold duplicate or conflicting notes into one.",
374
+ "4. Repair links: point every [[slug]] at an existing note or create it; give every note an index line.",
375
+ "5. Log: if you changed anything, append exactly one entry with memory add, target \"note:log\", text starting \"dream: \" and naming every change and why.",
376
+ "If nothing needs changing, write nothing and reply HEARTBEAT_OK.",
377
+ ].join("\n");
378
+ /**
379
+ * The built-in dreamer preset (§7E "Dreaming"): a plain {@link Agent} whose tools are
380
+ * EXACTLY `memory` (notes on, over `dir`) and `transcript_search` (over `store`) — never
381
+ * the persona's shell/file tools. Its soul is one `## HEARTBEAT.md` section holding
382
+ * {@link DREAM_PROMPT}, then `DREAM.md` from `dir` (trimmed, if non-empty), then
383
+ * `instructions` (trimmed, if non-empty), joined by blank lines. Schedule it with
384
+ * {@link startAgent} or run one pass with `run(HEARTBEAT_PROMPT, …)`.
385
+ */
386
+ export function dreamer(dir, opts) {
387
+ if (!opts || !opts.store)
388
+ throw new Error("dreamer: store is required");
389
+ const search = transcriptSearchTool(opts.store);
390
+ const parts = [DREAM_PROMPT];
391
+ const dreamMd = readCapped(path.join(dir, "DREAM.md"))?.trim();
392
+ if (dreamMd)
393
+ parts.push(dreamMd);
394
+ const extra = opts.instructions?.trim();
395
+ if (extra)
396
+ parts.push(extra);
397
+ return agent(opts.name ?? `${path.basename(dir)}-dreamer`, {
398
+ does: `dreams over the memory in ${dir}`,
399
+ soul: `## HEARTBEAT.md\n\n${parts.join("\n\n")}`,
400
+ model: opts.model ?? "inherit",
401
+ uses: { tools: [dreamGuard(memoryTool(dir, { notes: true })), search] },
402
+ });
403
+ }
404
+ /**
405
+ * The dream guard (§7E "Dreaming"): the dreamer's `memory` tool refuses every action on
406
+ * `USER.md` and `replace`/`remove` on `note:log`, before anything is read or written, so a
407
+ * dream never touches the user model and its audit trail is append-only.
408
+ */
409
+ function dreamGuard(mem) {
410
+ return {
411
+ ...mem,
412
+ execute: async (args, ctx) => {
413
+ if (args.target === "user")
414
+ return { output: "dreamer: USER.md is off-limits", isError: true };
415
+ if (args.target === "note:log" && (args.action === "replace" || args.action === "remove")) {
416
+ return { output: "dreamer: memory/log.md is append-only", isError: true };
417
+ }
418
+ return mem.execute(args, ctx);
419
+ },
420
+ };
154
421
  }
155
422
  /**
156
423
  * Give a persona its own clock (§7E). On each interval it `post`s a tick to its own
@@ -164,15 +431,53 @@ export function fromDir(dir, opts = {}) {
164
431
  * `started.runtime.post`/`wake` on the persona's handle.
165
432
  */
166
433
  export function startAgent(a, run, hb) {
167
- const runtime = a.createRuntime(run);
434
+ const dream = DREAMS.get(a);
435
+ if (dream && Object.prototype.hasOwnProperty.call(a.registry(), dream.dreamer.name)) {
436
+ throw new Error(`startAgent: dreamer name collision: ${dream.dreamer.name} is already registered`);
437
+ }
438
+ const runtime = dream
439
+ ? new AgentRuntime({ ...run, registry: { ...dream.dreamer.registry(), ...a.registry() } })
440
+ : a.createRuntime(run);
168
441
  const handle = runtime.spawn(runtime.root, a.name);
169
442
  if (isVerbError(handle))
170
443
  throw new Error(handle.error);
171
444
  const beats = [];
445
+ const cancels = [];
172
446
  let stopped = false;
447
+ cancels.push(heartbeat(runtime, handle, hb.everyMs, () => stopped, (text) => {
448
+ beats.push(text);
449
+ hb.onBeat?.(text);
450
+ }));
451
+ if (dream) {
452
+ // fromDir(…, {dream}): the dreamer runs on the SAME runtime on its own interval. Its
453
+ // replies are housekeeping — they never reach onBeat; what it did is in memory/log.md.
454
+ const dh = runtime.spawn(runtime.root, dream.dreamer.name);
455
+ if (isVerbError(dh)) {
456
+ // Never leak the persona's heartbeat when the dreamer cannot start.
457
+ stopped = true;
458
+ for (const c of cancels)
459
+ c();
460
+ void runtime.close(runtime.root);
461
+ throw new Error(dh.error);
462
+ }
463
+ cancels.push(heartbeat(runtime, dh, dream.everyMs, () => stopped, () => { }));
464
+ }
465
+ return {
466
+ runtime,
467
+ beats,
468
+ stop: async () => {
469
+ stopped = true;
470
+ for (const c of cancels)
471
+ c();
472
+ await runtime.close(runtime.root);
473
+ },
474
+ };
475
+ }
476
+ /** One self-rescheduling heartbeat on `handle`; returns its cancel. */
477
+ function heartbeat(runtime, handle, everyMs, isStopped, onText) {
173
478
  let cancel;
174
479
  const beat = () => {
175
- if (stopped)
480
+ if (isStopped())
176
481
  return;
177
482
  // Unsolicited rail: the tick lands in the inbox (coalesces); a wake — only when
178
483
  // idle — drains the whole inbox as one turn.
@@ -181,25 +486,15 @@ export function startAgent(a, run, hb) {
181
486
  const woke = runtime.wake(handle, HEARTBEAT_PROMPT);
182
487
  if (woke.ok) {
183
488
  void runtime.wait(handle).then((r) => {
184
- if (!r.isError && !r.text.includes(HEARTBEAT_OK)) {
185
- beats.push(r.text);
186
- hb.onBeat?.(r.text);
187
- }
489
+ if (!r.isError && !r.text.includes(HEARTBEAT_OK))
490
+ onText(r.text);
188
491
  });
189
492
  }
190
493
  }
191
494
  // keepAlive:false — a heartbeat is housekeeping and must never be the reason
192
495
  // a host process stays up.
193
- cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false }); // self-reschedule → one live timer
194
- };
195
- cancel = runtime.clock.setTimeout(beat, hb.everyMs, { keepAlive: false });
196
- return {
197
- runtime,
198
- beats,
199
- stop: async () => {
200
- stopped = true;
201
- cancel?.();
202
- await runtime.close(runtime.root);
203
- },
496
+ cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false }); // self-reschedule → one live timer
204
497
  };
498
+ cancel = runtime.clock.setTimeout(beat, everyMs, { keepAlive: false });
499
+ return () => cancel?.();
205
500
  }
@@ -16,7 +16,7 @@ export declare const DEFAULT_BATCH_CONCURRENCY = 16;
16
16
  * `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
17
17
  * endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
18
18
  */
19
- export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom";
19
+ export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "openai" | "custom";
20
20
  /** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
21
21
  * the vendor. Override per classifier with `ClassifierOptions.supports`. */
22
22
  export interface ClassifierSupports {
@@ -28,8 +28,9 @@ export interface ClassifierBackendPreset {
28
28
  baseUrl: string;
29
29
  model: string;
30
30
  apiKeyEnv: string;
31
- /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
32
- wire: "systemone" | "cloudflare";
31
+ /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
32
+ * `decisions`: POST {baseUrl}/decisions, OpenAI's Decisions API (#157). */
33
+ wire: "systemone" | "cloudflare" | "decisions";
33
34
  /** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
34
35
  imagesHeader: boolean;
35
36
  /** The default support for a model on this backend; absent on `custom`. */
@@ -537,3 +538,10 @@ export declare function thresholdBand(answer: DecisionAnswer | RawAnswer, t?: Th
537
538
  * joint calibration across a split is unproven, so the host decides to split and merges.
538
539
  */
539
540
  export declare function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[];
541
+ /** The Decisions API's per-request question cap. */
542
+ export declare const MAX_DECISIONS_QUESTIONS = 400;
543
+ /**
544
+ * Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
545
+ * validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
546
+ */
547
+ export declare function decisionsReply(reply: unknown, questions: Record<string, AnyQuestion>): string;