clearotron 0.4.1-beta.2 → 0.4.1-beta.4

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/.env.example CHANGED
@@ -96,6 +96,15 @@ CLEAROTRON_CLAUDE_PATH=claude
96
96
  # the copied `.env`, not a choice).
97
97
  # effect: setup
98
98
  CLEAROTRON_CODEX_PATH=codex
99
+ #
100
+ # How long one tool answer may be before it is handed over in parts, in bytes, counted as a quoted string
101
+ # (shared/answer-parts.mjs). Each engine gives every tool server its own size: 45,000 on Claude, because
102
+ # Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and
103
+ # 36,000 on openai-agent, because Codex removes the middle of a tool read longer than about 39,000 bytes.
104
+ # A smaller value here still applies on both; empty leaves the engine's size. The answer itself is
105
+ # unchanged either way; the tool runs once, and a later part is read from the answer the server holds.
106
+ # effect: tuning
107
+ CLEAROTRON_TOOL_ANSWER_BYTES=
99
108
 
100
109
  CLEAROTRON_WORK_DIR=
101
110
  # Customer/project config store (loadProfiles). Unset ⇒ driver's built-in profiles dir.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
  <p align="center">
9
9
  <a href="LICENSE"><img src="https://img.shields.io/badge/licence-AGPL--3.0--only-860F09?style=flat-square" alt="Licence: AGPL-3.0-only"></a>
10
10
  <a href=".nvmrc"><img src="https://img.shields.io/badge/node-%E2%89%A5%2022.13-250902?style=flat-square" alt="Node 22.13+"></a>
11
+ <a href="https://scorecard.dev/viewer/?uri=github.com/CordilleraSarl/clearotron"><img src="https://img.shields.io/ossf-scorecard/github.com/CordilleraSarl/clearotron?label=openssf%20scorecard&style=flat-square" alt="OpenSSF Scorecard"></a>
11
12
  </p>
12
13
 
13
14
  Before your company commits to a name, find out what stands in its way. Give Clearotron the name, the
package/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "08583753fbde693fdecdc8e16f11781dae789f55",
3
- "version": "0.4.1-beta.2"
2
+ "commit": "103da45d8abcbbe5af51cacd213954a3cc59c841",
3
+ "version": "0.4.1-beta.4"
4
4
  }
@@ -292,7 +292,8 @@ of truncations.
292
292
 
293
293
  | Var | Default | Meaning |
294
294
  |---|---|---|
295
- | `CLEAROTRON_BAND_RESPONSE_CHARS` | the shape-part size | Characters per band-server response. Floor 4096; defaults to whatever the shape part is set to, so raising one raises both unless this is set too. |
295
+ | `CLEAROTRON_BAND_RESPONSE_CHARS` | five sixths of `CLEAROTRON_TOOL_ANSWER_BYTES`: 37,500 on the Claude engine, 30,000 on the OpenAI engine; 70,000 with no answer size | Characters per band-server response, the band summary's parts included; a summary part is never longer than 70,000 whatever this says. Floor 4096. The sixth left over is room for the quotes and line breaks a printout adds and for a banner, so a band answer is never split again into answer parts. On the OpenAI engine it is at most 30,000; a smaller value set here still applies. |
296
+ | `CLEAROTRON_TOOL_ANSWER_BYTES` | 45,000 on the Claude engine, 36,000 on the OpenAI engine | Bytes per tool answer from every local tool server and the case-law bridge, counted as a quoted string (a quote or line break counts two). A longer answer is served in parts, each naming the next, and the tool runs once: a later part is read from the answer the server holds. Each engine writes its size into every server's entry: Claude Code saves an answer longer than 50,000 characters to a file the model must read for itself, and Codex removes the middle of a tool read longer than about 39,000 bytes. A smaller value set here still applies on both. A server started outside a stage, with no size, serves every answer whole. Floor 8192. |
296
297
 
297
298
  ### Surfaces beside the driver — portal, demo, e2e harness
298
299
 
@@ -1,5 +1,17 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1-beta.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: Every clearance uses the same web search whichever engine runs it; the engines' own web search is switched off.
8
+
9
+ ## 0.4.1-beta.3
10
+
11
+ ### Patch Changes
12
+
13
+ - Fixed: A crowded clearance on the Codex engine no longer stops before its report.
14
+
3
15
  ## 0.4.1-beta.2
4
16
 
5
17
  ### Patch Changes
@@ -499,30 +499,22 @@ export function caseLawInventory(env = process.env) {
499
499
  remedy: null,
500
500
  });
501
501
 
502
- // ── THE TWO THE REPORT NAMED AND THIS BUILD DOES NOT SHIP ────────────────────────────────────────
502
+ // ── THE ONE THE REPORT NAMED AND THIS BUILD DOES NOT SHIP ────────────────────────────────────────
503
503
  //
504
- // "no EUIPO Boards-of-Appeal source" and "WebSearch unavailable this session" were the other two gaps
505
- // in the same report, and the issue asks for the same treatment. They get it, and the treatment is to
506
- // say what is TRUE of each rather than to invent a switch:
504
+ // "no EUIPO Boards-of-Appeal source" was another gap in the same report, and the treatment is to say
505
+ // what is TRUE of it rather than to invent a switch: the Boards of Appeal have no adapter in this build.
506
+ // That is a release fact, not a setting, and an operator can do nothing about it — which is precisely
507
+ // what they need to be told before they spend two hours discovering it from a report.
507
508
  //
508
- // · the Boards of Appeal have no adapter in this build. That is a release fact, not a setting, and
509
- // an operator can do nothing about it — which is precisely what they need to be told before they
510
- // spend two hours discovering it from a report.
511
- // · WebSearch is the engine's own tool. Whether it answers is decided per session by the engine, so
512
- // no deployment check can promise it. Recorded as a capability with a stated limit rather than as
513
- // a green tick this file cannot honestly give.
509
+ // The engine's own web search had a row here too. It is off on both engines now (OWN_SEARCH_TOOLS in
510
+ // engine/anthropic-agent.mjs, `web_search` in engine/mcp/codex-config.mjs), so it has none: the open-web
511
+ // row a run actually uses is the research provider's, above.
514
512
  rows.push({
515
513
  key: "caselaw", label: "Case law and oppositions", provider: "euipo-boards-of-appeal",
516
514
  providerLabel: "EUIPO Boards of Appeal", known: false, enrolment: "absent", configured: false, missing: [],
517
515
  remedy: "Not part of this build. There is no adapter for it, so no setting turns it on and a report "
518
516
  + "covering the EU discloses it as a gap. Nothing to fix on this box.",
519
517
  });
520
- rows.push({
521
- key: "web", label: "Open-web search", provider: "engine-websearch",
522
- providerLabel: "The engine's own web search", known: true, enrolment: "built-in", configured: true, missing: [],
523
- remedy: "Provided by the engine, not by this deployment, and it can be unavailable for a single "
524
- + "session. A run that loses it discloses the gap rather than reporting a clean sweep.",
525
- });
526
518
  return rows;
527
519
  }
528
520
 
@@ -16,8 +16,8 @@
16
16
  // extractor never sees:
17
17
  //
18
18
  // D1 verify.mjs connotationDispositionFail fail(`connotation_${reason}:…`) — reason is a member of CALL_REASON_SET
19
- // D2 verify.mjs fail(String(e.message)) — parseFindingsJson throws token-first
20
- // D3 verify.mjs:1237 checkJson: fail(String(e.message)) — FIVE parsers reach this one site
19
+ // D2 verify.mjs checkFindingsSibling fail(String(e.message)…) — parseFindingsJson throws token-first
20
+ // D3 verify.mjs:1240 checkJson: fail(String(e.message)…) — FIVE parsers reach this one site
21
21
  // D4 verify.mjs parseCoverageLedgerJson, same shape
22
22
  // D5 verify.mjs validators.registerFindings fail(`${unaccounted[0].token}:…`) — token minted in a DATA ROW
23
23
  // D6 verify.mjs validators.registerFindings fail(`${violations[0].token}…`) — findUnexecutedCleanClaims() in register-plan.mjs
@@ -370,6 +370,14 @@ export function runDirGrant({ runDir, dispatch, seatWrites = null } = {}) {
370
370
  * shell the program offered them anyway.
371
371
  */
372
372
  export const COMMAND_TOOLS = Object.freeze(["Bash", "PowerShell", "Monitor"]);
373
+ /**
374
+ * The program's own web search, removed from every stage by the same name-removal. The engine's web search
375
+ * is its own: the common-law sweep, which keeps a record of every result. The program's was never granted,
376
+ * and every attempt a stage made at it was refused, a turn spent for nothing (observed in testing,
377
+ * 2026-10-02 and 2026-10-03: 48 attempts in five runs, all refused). Removed, the model is never offered it.
378
+ * The codex engine turns its own off the same way (`web_search`, codex-config.mjs).
379
+ */
380
+ export const OWN_SEARCH_TOOLS = Object.freeze(["WebSearch"]);
373
381
  /** The program's tools that write a file. A result that comes back an error is a write that failed. */
374
382
  const FILE_WRITE_TOOLS = Object.freeze(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
375
383
 
@@ -399,7 +407,7 @@ export function buildClaudeArgs({ message, model, thinking, resumeRef, mcpConfig
399
407
  // through Bash on a login with no rule allowing it. A bare name here "removes the matching tools from
400
408
  // Claude's context" (the vendor's CLI reference, `--disallowedTools`), on every call, the resumed one
401
409
  // included, so the model is never offered them. See COMMAND_TOOLS for the three names.
402
- args.push("--disallowedTools", COMMAND_TOOLS.join(" "));
410
+ args.push("--disallowedTools", [...COMMAND_TOOLS, ...OWN_SEARCH_TOOLS].join(" "));
403
411
  const cap = maxBudgetUsd ?? (process.env.CLEAROTRON_MAX_BUDGET_USD ? Number(process.env.CLEAROTRON_MAX_BUDGET_USD) : null);
404
412
  if (cap != null && Number.isFinite(cap)) args.push("--max-budget-usd", String(cap));
405
413
  // FILE ACCESS: claude's Read/Write/Edit tools are confined to cwd + --add-dir roots even under
@@ -593,6 +601,8 @@ export const anthropicAgentEngine = {
593
601
  let lastMove = Date.now();
594
602
  let lastProgress = Date.now(); // honest-progress clock: token movement / agent-loop steps / artifact writes ONLY
595
603
  let firstOutputAt = null; // #1692/#1813 — first byte of the PROTOCOL (stdout only); until then every clock below is timing STARTUP
604
+ let atKill = null; // what the watchdog had seen when it decided to kill — recording only, see killDecision
605
+ let silentIdleMs = null; // the no-progress clock at the watchdog's last look before the child spoke — recording only
596
606
  const GRACE = spawnGraceMs(); // one source of the grace: common.mjs, where #1703 set it
597
607
  let sawAnyEvent = false; // ANY parseable stream event (incl. system:init) — false on close = the CLI died before streaming (startup-class failure)
598
608
  let rateLimitEvent = null; // a 429 session-cap rejection rides a `rate_limit_event` (status:"rejected" + resetsAt)
@@ -957,6 +967,7 @@ export const anthropicAgentEngine = {
957
967
  ? (sig) => { const n = killWindowsTreeNow({ pid: child.pid, since: spawnedAt, until: exitedAt }); if (!n && sig === "SIGTERM") process.stderr.write(`[engine] the Windows stop found nothing of this turn left to end (program ${child.pid})\n`); }
958
968
  : (sig) => { try { process.kill(-child.pid, sig); } catch { try { child.kill(sig); } catch { /* already gone */ } } };
959
969
  const killTree = () => {
970
+ atKill ??= { ms: Date.now() - t0, spoken: firstOutputAt !== null, toolCalls };
960
971
  if (escalation) return; // the watchdog polls — arm the escalation exactly once
961
972
  groupKill("SIGTERM");
962
973
  escalation = setTimeout(() => groupKill("SIGKILL"), killEscalateMs());
@@ -1009,6 +1020,7 @@ export const anthropicAgentEngine = {
1009
1020
  // was recorded as a no-progress stall against a turn that had not yet been given a chance to
1010
1021
  // progress. A child that never speaks AT ALL is still bounded: the byte-stall fires at max(STALL, GRACE).
1011
1022
  const started = firstOutputAt !== null;
1023
+ if (!started) silentIdleMs = progIdle;
1012
1024
  if (idle >= (started ? STALL : Math.max(STALL, GRACE))) { stallKill = true; killed = true; killTree(); }
1013
1025
  // No observable progress (tokens/agent-loop/artifact) for NOPROG while bytes kept the pipe warm:
1014
1026
  // kill it NOW, well below the wall, and record it as a STALL — never let the ceiling raise turn
@@ -1220,6 +1232,16 @@ export const anthropicAgentEngine = {
1220
1232
  // ONLY: nothing branches on it. A recording field is protected by a test or by nothing, so
1221
1233
  // engine.anthropic.test.mjs pins it — deleting it must red something.
1222
1234
  firstByteMs: firstOutputAt === null ? null : Math.round(firstOutputAt - t0),
1235
+ // WHICH SIDE OF THE KILL a byte or a tool ask fell on. RECORDING ONLY, like firstByteMs. The
1236
+ // totals above keep counting after the decision, because what a dying child already wrote is
1237
+ // still read (streamed usage depends on it). On Windows the kill blocks this process while it
1238
+ // lists and ends the tree, measured at one to three seconds on the hosted runner, which is long
1239
+ // enough for a booting child to speak and open a tool ask that is read only afterwards.
1240
+ // `killDecision` is what the watchdog had seen when it decided: ms from spawn, whether the child
1241
+ // had spoken, and the tool asks read by then; null when nothing was killed. `silentIdleMs` is how
1242
+ // long the no-progress clock had run at the watchdog's last look before the child spoke; null
1243
+ // when it never looked at a silent child.
1244
+ killDecision: atKill, silentIdleMs,
1223
1245
  // WHICH CLOCK RAN OUT, and WHAT the wait was made of.
1224
1246
  //
1225
1247
  // The hard wall measures ELAPSED, and elapsed includes time the model spent waiting on tools
@@ -20,6 +20,7 @@ import { readFileSync, readdirSync, appendFileSync, existsSync, mkdirSync } from
20
20
  import { join, dirname } from "node:path";
21
21
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
22
22
  import { serve } from "./stdio-server.mjs";
23
+ import { answerBudget } from "../../../shared/answer-parts.mjs";
23
24
  import { classifyRecord, prepareTargets, SHAPE_TIERS, isLiveRecord } from "../../band-shape.mjs";
24
25
  import { recordQids } from "../../named-band.mjs";
25
26
  import { normalizeRecordUri } from "../../registry-fidelity.mjs";
@@ -98,7 +99,8 @@ const loadJson = (p) => JSON.parse(readFileSync(p, "utf8"));
98
99
  // contract is honest and explicit: an over-size shape announces "part N/M" and names the next call;
99
100
  // the common single-part case is served verbatim with no banner. The part size was a test seam with a
100
101
  // floor under it so a misconfiguration could never shred the shape into confetti; step 3 deleted
101
- // the knob, and a constant cannot be misconfigured.
102
+ // the knob, and a constant cannot be misconfigured. A smaller answer size still lowers it
103
+ // (SHAPE_PART_SERVED, below), never under the answer size's own floor.
102
104
  const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever set it
103
105
 
104
106
  // PR-11 — the SAME transport cap bounds the other two tools (they were shipped unbounded): a
@@ -106,7 +108,19 @@ const SHAPE_PART_CHARS = 70000; // step 3 — was a knob; no environment ever
106
108
  // per-tool-output token cap and error the call — killing the reading path exactly where the record
107
109
  // volume is highest. band_lookup TRIMS records (the full match count always survives, with an
108
110
  // honest note); band_record serves PARTS like band_shape (a record is a document — never trimmed).
109
- const RESPONSE_CHARS = Math.max(4096, Number(process.env.CLEAROTRON_BAND_RESPONSE_CHARS) || SHAPE_PART_CHARS);
111
+ // Given an answer size and no band size of its own, the band answers in five sixths of it: the rest is room
112
+ // for the quotes and line breaks a printout adds (up to 13% on band answers) and for a banner, so a band
113
+ // answer is never split a second time (shared/answer-parts.mjs). 37,500 on Claude, 30,000 on codex.
114
+ const RESPONSE_CHARS = Math.max(4096, Number(process.env.CLEAROTRON_BAND_RESPONSE_CHARS)
115
+ || (answerBudget() ? Math.floor(answerBudget() / 1.2) : SHAPE_PART_CHARS));
116
+
117
+ // The shape is split at the smaller of its own part size and the answer size, because codex cuts the
118
+ // middle out of a read over about 39,000 bytes and is given a smaller answer size for that
119
+ // (engine/mcp/codex-config.mjs, CODEX_BAND_ANSWER_CHARS). Measured in testing, 2026-10-11: a GPT model that
120
+ // printed a 70,269-character shape part to read it named 11 of its 24 marker rows, and in one attempt of a
121
+ // real run four of five shape reads were that long. Never larger than the constant,
122
+ // so a larger answer size cannot push a part past Claude's limit; with no size set, nothing changes.
123
+ const SHAPE_PART_SERVED = Math.min(SHAPE_PART_CHARS, RESPONSE_CHARS);
110
124
 
111
125
  /** Split text into parts of ≤ budget chars, cutting only at line boundaries (an over-budget single
112
126
  * line still ships whole — never cut mid-line). Concatenating the parts with "\n" restores the
@@ -216,7 +230,7 @@ function bandShape(params) {
216
230
  if (!RUN_DIR) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: "ERROR: CLEAROTRON_BAND_RUN_DIR not set — band tools are wired per run by the driver." }; }
217
231
  if (!existsSync(p)) { logRead("band_shape", { format }, { ok: false }); return { isError: true, text: `ERROR: ${p} not found — the driver derives it after the register fan-in; if the band exists but the shape does not, report this as a driver defect (do NOT slice the raw band instead).` }; }
218
232
  const text = readFileSync(p, "utf8");
219
- const parts = splitAtLines(text, SHAPE_PART_CHARS);
233
+ const parts = splitAtLines(text, SHAPE_PART_SERVED);
220
234
  const total = parts.length;
221
235
  if (total === 1) { // the common case — served verbatim, no banner
222
236
  logRead("band_shape", { format }, { ok: true, bytes: text.length });
@@ -240,6 +254,11 @@ function bandLookup(params = {}) {
240
254
  try { band = loadJson(bandPath()); } catch (e) { logRead("band_lookup", params, { ok: false }); return { isError: true, text: `ERROR: band unreadable (${e.message}) — a driver-side defect; report it, never hand-repair the band.` }; }
241
255
  const { record_id, owner, nice_class, tier, qid, query, text, status, live_only = false } = params;
242
256
  const limit = Math.max(1, Math.min(Number(params.limit) || 20, 100));
257
+ // PAGING. A large band is read page by page from `offset`, in the band's own order, and a capped answer
258
+ // names the next offset. Without it the only way to see every record was to slice the band by owner or
259
+ // text fragments, and a seat told to judge the complete band read it many times over (1,423 records,
260
+ // 2,287 lookups, 65.8 MB, until the engine's output cap stopped the turn).
261
+ const offset = Math.max(0, Math.floor(Number(params.offset) || 0));
243
262
  if (tier && !SHAPE_TIERS.includes(tier)) return { isError: true, text: `ERROR: tier must be one of ${SHAPE_TIERS.join(" | ")}.` };
244
263
 
245
264
  // tier filtering classifies against the SHAPE's own frozen targets — same classifier, same result.
@@ -270,7 +289,7 @@ function bandLookup(params = {}) {
270
289
  if (text && !norm(r?.mark_text).includes(norm(text))) continue;
271
290
  if (prepared && classifyRecord(r?.mark_text, prepared).tier !== tier) continue;
272
291
  matched++;
273
- if (hits.length < limit) hits.push(project(r));
292
+ if (matched > offset && hits.length < limit) hits.push(project(r));
274
293
  }
275
294
  // matching crowd descriptors ride along (a lookup that lands in an un-enumerated zone must SEE that).
276
295
  const crowds = (Array.isArray(band?.crowds) ? band.crowds : []).filter((c) => {
@@ -301,9 +320,10 @@ function bandLookup(params = {}) {
301
320
  // The full `matched` count always ships, and the note says exactly how many records were withheld.
302
321
  let byteCapped = 0;
303
322
  const body = () => JSON.stringify({
304
- matched, returned: hits.length,
305
- ...(matched > hits.length ? { note: `capped at ${hits.length} — narrow the filters${byteCapped ? "" : " (or raise limit ≤100)"} to see the rest; the count above is the full match` } : {}),
306
- ...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — narrow the filters (owner / nice_class / tier / qid) and look them up in smaller slices` } : {}),
323
+ matched, returned: hits.length, ...(offset ? { offset } : {}),
324
+ ...(matched > offset + hits.length ? { next_offset: offset + hits.length } : {}),
325
+ ...(matched > offset + hits.length ? { note: `capped at ${hits.length} — call again with offset: ${offset + hits.length} for the next page; the count above is the full match` } : {}),
326
+ ...(byteCapped ? { byte_cap_note: `${byteCapped} matched record(s) withheld to fit one tool response — call again with offset: ${offset + hits.length} for the rest` } : {}),
307
327
  records: hits,
308
328
  ...(crowds.length ? { matching_crowds: crowds, crowd_note: "these matching slices were NOT enumerated — records under them may exist that no lookup can return; weigh the descriptor, never treat this result as their clean"
309
329
  + (refusedCrowds ? ` — and ${refusedCrowds} of them was/were NEVER ANSWERED at all (see "refusal"): no count exists for those, so they are unsearched rather than empty, and nothing about them can be read off a zero` : "")
@@ -405,11 +425,11 @@ serve({
405
425
  },
406
426
  {
407
427
  name: "band_lookup",
408
- description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
428
+ description: "Look up enumerated records in this run's frozen register band (read-only; logged to the reading audit). Filters AND together: record_id (exact uri), owner (substring), nice_class, tier (identical|near-identical|same-family|other|unclassifiable — classified against the shape's frozen targets), qid (exact plan-entry id — the plan-execution ledger join; a record surfaced by several slices matches under EVERY one of them), query (the _query slice that surfaced the record), text (substring of mark_text), status (substring), live_only, limit (default 20, max 100), offset (default 0: where in the matching records to start — a capped answer names the next offset, so a large band is read page by page). Matching un-enumerated crowd descriptors ride along so a lookup can never mistake a counted-only zone for a clean.",
409
429
  inputSchema: { type: "object", properties: {
410
430
  record_id: { type: "string" }, owner: { type: "string" }, nice_class: { type: "string" },
411
431
  tier: { type: "string", enum: SHAPE_TIERS }, qid: { type: "string" }, query: { type: "string" }, text: { type: "string" },
412
- status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" },
432
+ status: { type: "string" }, live_only: { type: "boolean" }, limit: { type: "number" }, offset: { type: "number" },
413
433
  } },
414
434
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
415
435
  handler: bandLookup,
@@ -50,6 +50,30 @@ export const TOOL_SERVER_SETTINGS = Object.freeze([
50
50
  "OAUTH_BRIDGE_CREDS_DIR", "OAUTH_BRIDGE_CLIENT_NAME",
51
51
  ]);
52
52
 
53
+ // CODEX CUTS THE MIDDLE OUT OF A LONG READ. It shows a model at most 10,000 tokens of the text a tool call
54
+ // puts in front of it, from its start and its end, counting 4 bytes a token, with "…N tokens truncated…"
55
+ // between them. For the GPT models, which call tools only from code (5.6 and 6.x), that text is what the
56
+ // code prints, and a quoted print adds a byte for each quote and line break: up to 13% on real band answers.
57
+ // Measured in testing, 2026-10-11, on codex 0.162.0 with gpt-6.1-sol: a model that printed a 68,752-character
58
+ // band page to read it named 14 of its 30 records, and at 32,000 characters it named every one. The band
59
+ // server sizes its answers for Claude's limit (70,000 characters, under 25,000 tokens). The vendor's settings
60
+ // for a larger budget (`tool_output_token_limit`, a tool's `output_token_limit`) changed nothing on that
61
+ // version, written in this file or given on the command line. So here the band server answers in 30,000
62
+ // characters: printed with its escapes and banner it stays under the general answer size below, and so under
63
+ // the cut. An install's own smaller size still wins.
64
+ export const CODEX_BAND_ANSWER_CHARS = 30000;
65
+ export const codexBandAnswerChars = (env = process.env) =>
66
+ Math.min(CODEX_BAND_ANSWER_CHARS, Number(env.CLEAROTRON_BAND_RESPONSE_CHARS) || CODEX_BAND_ANSWER_CHARS);
67
+
68
+ // AND EVERY OTHER ANSWER: each server here, the case-law bridges and the page fetcher included, is given an
69
+ // answer size, and an answer longer than that, counted as a quoted string, is served in parts, the tool
70
+ // running once (shared/answer-parts.mjs). 36,000 bytes leaves room under the cut for the engine's own
71
+ // wrapping (about 840 bytes); the band server's own answers stay under it and are never split again. An
72
+ // install's own smaller size still wins.
73
+ export const CODEX_TOOL_ANSWER_BYTES = 36000;
74
+ export const codexToolAnswerBytes = (env = process.env) =>
75
+ Math.min(CODEX_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CODEX_TOOL_ANSWER_BYTES);
76
+
53
77
  // ── TOML value escaping (basic strings) ──────────────────────────────────────────────────────────────
54
78
  // One encoder for every TOML block the product writes; re-exported so this module's callers keep their import.
55
79
  export { tomlString };
@@ -121,7 +145,7 @@ export function webFetchRequested(allowedTools) {
121
145
  // stays the backstop. Absent/0/negative ⇒ emit nothing and leave codex's default alone, so this is inert
122
146
  // for any caller that does not thread a budget.
123
147
  export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstructions, nodeBin = process.execPath, credEnvForward = [...CRED_ENV_FORWARD, ...TOOL_SERVER_SETTINGS], toolTimeoutSec, fence = null, withheldFromCommands = [],
124
- platform = process.platform } = {}) {
148
+ platform = process.platform, bandAnswerChars = codexBandAnswerChars(), toolAnswerBytes = codexToolAnswerBytes() } = {}) {
125
149
  const servers = parseClaudeMcpServers(mcpConfig);
126
150
  const enabled = enabledToolsByServer(allowedTools);
127
151
  const toolTimeout = Number(toolTimeoutSec) > 0 ? Math.floor(Number(toolTimeoutSec)) : undefined;
@@ -129,6 +153,14 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
129
153
  if (developerInstructions) lines.push(`developer_instructions = ${tomlString(developerInstructions)}`, "");
130
154
  // Top-level, so above the first table: TOML reads a bare key after a table header as that table's.
131
155
  if (fence) lines.push(`default_permissions = ${tomlString(FENCE_PROFILE)}`, "");
156
+ // CODEX'S OWN WEB SEARCH IS OFF, ON EVERY STAGE AND IN EVERY SAFETY SETTING. The engine's web search is its
157
+ // own: the common-law sweep, which keeps a record of every result. Codex brings a search of its own that
158
+ // is on unless the configuration says otherwise, "cached" by default and "live" once the sandbox is
159
+ // bypassed, and `"disabled"` removes the tool (the vendor's configuration reference, `web_search`). Left
160
+ // on, it fired in some runs and not others and brought web addresses into the placement notes that no
161
+ // recorded search returned (observed in testing, 2026-10). The Claude engine removes its own the same way
162
+ // (OWN_SEARCH_TOOLS, anthropic-agent.mjs). The case-law step's page fetcher is a tool server, not this.
163
+ lines.push(`web_search = "disabled"`, "");
132
164
  lines.push(...commandEnvToml(withheldFromCommands));
133
165
 
134
166
  const emitServer = (name, s, { forwardCreds } = {}) => {
@@ -167,11 +199,15 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
167
199
  lines.push("");
168
200
  };
169
201
 
170
- for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name] }, { forwardCreds: true });
202
+ // Every server answers under codex's cut, the band server in its own way too (CODEX_TOOL_ANSWER_BYTES,
203
+ // CODEX_BAND_ANSWER_CHARS, above); `env` wins over a forwarded name.
204
+ const answers = { CLEAROTRON_TOOL_ANSWER_BYTES: String(toolAnswerBytes) };
205
+ for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name],
206
+ env: { ...s.env, ...answers, ...(name === "band" ? { CLEAROTRON_BAND_RESPONSE_CHARS: String(bandAnswerChars) } : {}) } }, { forwardCreds: true });
171
207
 
172
208
  // WebFetch → the engine-local fetch server (codex has no built-in). Needs no creds (no env_vars).
173
209
  if (webFetchRequested(allowedTools))
174
- emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: {}, enabledTools: ["fetch_url"] }, { forwardCreds: false });
210
+ emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: { ...answers }, enabledTools: ["fetch_url"] }, { forwardCreds: false });
175
211
 
176
212
  if (fence) lines.push(...fenceToml(fence));
177
213
  // ON WINDOWS, CODEX'S SANDBOX IS ON ONLY WHEN THIS NAMES IT. With no `[windows] sandbox` and no feature
@@ -834,9 +834,21 @@ function resolveGroup(group) {
834
834
  }
835
835
  }
836
836
 
837
+ // CLAUDE'S ANSWER SIZE. Claude Code replaces a tool answer longer than 50,000 characters, or 25,000 tokens,
838
+ // with a message naming a file it saved the answer to, and the model must read that file itself, the
839
+ // first read refused when the file is past 25,000 tokens (the vendor's MCP page; measured in testing,
840
+ // 2026-10-11, on 2.1.286 and 2.1.295: 49,159 characters arrived whole, 51,189 were replaced). So every
841
+ // server is given this size, and a longer answer comes in the parts shared/answer-parts.mjs serves, the
842
+ // banners the same on both engines. 45,000 bytes counted as a quoted string is under 50,000 characters, and
843
+ // under 25,000 tokens for any text a register or a search returns. The codex config writes its own, smaller
844
+ // size over this one (codex-config.mjs). An install's own smaller size still wins.
845
+ export const CLAUDE_TOOL_ANSWER_BYTES = 45000;
846
+ export const claudeToolAnswerBytes = (env = process.env) =>
847
+ Math.min(CLAUDE_TOOL_ANSWER_BYTES, Number(env.CLEAROTRON_TOOL_ANSWER_BYTES) || CLAUDE_TOOL_ANSWER_BYTES);
848
+
837
849
  // The mcp-config server env carries ONLY the per-run, non-secret values (session key + ledger paths +
838
- // agent + the run dir the band tools serve). Creds (CORSEARCH_SESSION_KEY / PERPLEXITY_API_KEY /
839
- // EUIPO_*) are inherited from the engine env.
850
+ // agent + the run dir the band tools serve, and the answer size above). Creds (CORSEARCH_SESSION_KEY /
851
+ // PERPLEXITY_API_KEY / EUIPO_*) are inherited from the engine env.
840
852
  function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
841
853
  const e = {};
842
854
  // — see buildGatherMcpConfig: a register server without a run is refused there, so by the time
@@ -881,6 +893,7 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
881
893
  if (runDir) e.CLEAROTRON_REGISTER_RECORD_LOG = runRecordLogPath(runDir);
882
894
  for (const v of ["CLEAROTRON_GATHER_SESSION_ID", "EUIPO_ENVIRONMENT"])
883
895
  if (process.env[v]) e[v] = process.env[v];
896
+ e.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes()); // CLAUDE_TOOL_ANSWER_BYTES, above
884
897
  return e;
885
898
  }
886
899
 
@@ -935,6 +948,8 @@ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, r
935
948
  if (runDir) auditEnv.CLEAROTRON_BAND_RUN_DIR = runDir;
936
949
  if (sessionKey) auditEnv.CLEAROTRON_GATHER_SESSION_KEY = sessionKey;
937
950
  if (agent) auditEnv.CLEAROTRON_GATHER_AGENT = agent;
951
+ // In a run, a fourth: the answer size, because a case-law answer is served in parts like any other.
952
+ if (Object.keys(auditEnv).length) auditEnv.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes());
938
953
  for (const k of bridgeKeys) mcpServers[k] = { command: NODE, args: [BRIDGE, "--server", k], connectionTimeoutMs: 60000, ...(Object.keys(auditEnv).length ? { env: auditEnv } : {}) };
939
954
  return Object.keys(mcpServers).length ? { mcpServers } : null;
940
955
  }
@@ -10,6 +10,7 @@ import { dirname, basename } from "node:path";
10
10
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
11
11
  import { underStudioSegment } from "../../../shared/path-seps.mjs"; // Windows builds the path with "\"
12
12
  import { serve } from "./stdio-server.mjs";
13
+ import { answerBudget } from "../../../shared/answer-parts.mjs";
13
14
  import {
14
15
  isKnownPreset, questionPresetFor, buildRequestBody, callAgentAPI, formatResponse, formatSandboxResponse,
15
16
  validateGridSpec, buildGridProgramTask, captureGridFromResponse, requiredLedgerRefusal,
@@ -194,7 +195,10 @@ function tellObligations(spec, ledgerJson) {
194
195
  // and a grid's candidates grow with its results per cell. So they are served in parts, split between
195
196
  // cells, as band_shape serves an oversize shape, with the same part size. A later part is read from the
196
197
  // saved ledger: nothing is searched again.
197
- const GRID_PART_CHARS = 70000;
198
+ // Smaller when this server was given an answer size (codex cuts the middle out of a longer read;
199
+ // shared/answer-parts.mjs): three quarters of it, which leaves room for the rest of the reply and for the
200
+ // quotes and line breaks a printout adds, so a part of the grid is never split again on its way to the model.
201
+ const GRID_PART_CHARS = answerBudget() ? Math.min(70000, Math.floor(answerBudget() * 0.75)) : 70000;
198
202
 
199
203
  // `reserve` is what part 1's reply also carries (the meaning obligations): part 1 is that much smaller,
200
204
  // and every call passes the same reserve for the same ledger, so every call splits it the same way.
@@ -12,6 +12,7 @@ import { createInterface } from "node:readline";
12
12
  import { appendFileSync, mkdirSync } from "node:fs";
13
13
  import { dirname } from "node:path";
14
14
  import { driverDir } from "../../../shared/driver-dir.mjs"; //
15
+ import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../../shared/answer-parts.mjs";
15
16
 
16
17
  // ── half 2 — THE TOOL CALL LOG, so a killed call is a FACT and not an inference ────────────────
17
18
  //
@@ -88,6 +89,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
88
89
  // reader must key on (server, seq) and never on seq alone.
89
90
  let callSeq = 0;
90
91
  const byName = new Map(tools.map((t) => [t.name, t]));
92
+ // A LONG ANSWER IS SERVED IN PARTS when this server was given an answer size (shared/answer-parts.mjs):
93
+ // the handler runs once, the answer is held here, and a later part is read from it. No size, no change.
94
+ const budget = answerBudget();
95
+ const held = heldAnswers();
91
96
  // ── — THE ANNOTATIONS TRAVEL, BECAUSE A TOOL MUST BE ABLE TO SAY WHAT IT DOES ──
92
97
  //
93
98
  // This line used to pick three fields and drop the rest, so a tool that declared `annotations` had
@@ -117,9 +122,10 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
117
122
  // convenience — the exact thing annotations exist to prevent.
118
123
  // Listed when asked, not at start: a tool may give the schema for the run it serves (`schemaNow`), read
119
124
  // at call time like every per-run read here. A tool without one lists its declared schema, as before.
125
+ const schemaOf = (t) => (typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema);
120
126
  const list = () => tools.map((t) => ({
121
127
  name: t.name, description: t.description,
122
- inputSchema: typeof t.schemaNow === "function" ? t.schemaNow(t.inputSchema) : t.inputSchema,
128
+ inputSchema: budget ? withAnswerPart(schemaOf(t)) : schemaOf(t),
123
129
  ...(t.annotations ? { annotations: t.annotations } : {}),
124
130
  }));
125
131
  const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
@@ -144,11 +150,20 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
144
150
  } else if (method === "tools/call") {
145
151
  const tool = byName.get(params?.name);
146
152
  if (!tool) return ok(id, { isError: true, content: [{ type: "text", text: `unknown tool: ${params?.name}` }] });
153
+ // A later part of a held answer is read from it, and the tool does not run: no started/settled pair,
154
+ // one row of its own, so a count of the tool's runs stays a count of its runs.
155
+ const { args, part } = budget ? takePart(params?.arguments) : { args: params?.arguments, part: null };
156
+ const kept = part ? held.get(tool.name, args) : null;
157
+ if (part && (kept || part > 1)) {
158
+ logToolEvent({ event: "answer-part", server: name, tool: tool.name, part, ...(kept ? { of: kept.length } : { held: false }) });
159
+ return ok(id, kept ? { content: [{ type: "text", text: servedPart(tool.name, kept, part) }] }
160
+ : { isError: true, content: [{ type: "text", text: partNotHeld(tool.name, part) }] });
161
+ }
147
162
  // — STAMPED BEFORE THE WORK, settled after it, and the pair is the whole mechanism. A
148
163
  // process killed mid-call cannot write anything, so "started with no settled" is the only shape
149
164
  // that can record a call that never returned.
150
165
  const seq = ++callSeq;
151
- const axis = typeof params?.arguments?.axis === "string" ? params.arguments.axis : null;
166
+ const axis = typeof args?.axis === "string" ? args.axis : null;
152
167
  logToolEvent({ event: "started", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}) });
153
168
  // ── — THE ONE SEAM EVERY TYPED CALL PASSES THROUGH ───────────────────────
154
169
  //
@@ -157,7 +172,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
157
172
  // re-check by hand, which made "this field is required" a property of one author's diligence
158
173
  // rather than of the contract. The refusal names the fields in the acceptors' own token shape so
159
174
  // a seat can act on it in the turn it happens.
160
- const unmet = requiredFieldViolations(tool.inputSchema, params?.arguments);
175
+ const unmet = requiredFieldViolations(tool.inputSchema, args);
161
176
  if (unmet.length) {
162
177
  // THE REASON THE ROW HAD IN SCOPE AND THREW AWAY. A settled row with
163
178
  // `ok: false` and nothing else asserts a failure and withholds everything that would let a
@@ -177,7 +192,7 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
177
192
  + `${unmet.length === 1 ? "it" : "them"}. Send the field itself, not null and not a placeholder.` }] });
178
193
  }
179
194
  let out;
180
- try { out = await tool.handler(params?.arguments || {}); }
195
+ try { out = await tool.handler(args || {}); }
181
196
  catch (e) {
182
197
  // A THROWN tool error SETTLES. It returned an answer — a wrong one, but the model saw it and
183
198
  // could act on it. Recording it as unsettled would attribute a live failure to a timeout. The
@@ -193,6 +208,11 @@ export function serve({ name, version = "0.1.0", tools = [] }) {
193
208
  const code = isError ? complaintCode(text) : null;
194
209
  logToolEvent({ event: "settled", seq, server: name, tool: tool.name, ...(axis ? { axis } : {}), ok: !isError,
195
210
  ...(isError ? { failure: "server", ...(code ? { reason: code } : {}) } : {}) });
211
+ const pieces = budget && !isError ? answerPieces(text, budget, tool.name) : null;
212
+ if (pieces && pieces.length > 1) {
213
+ held.hold(tool.name, args, pieces);
214
+ return ok(id, { content: [{ type: "text", text: servedPart(tool.name, pieces, 1) }] });
215
+ }
196
216
  ok(id, { isError: isError || undefined, content: [{ type: "text", text }] });
197
217
  } else if (id != null) {
198
218
  err(id, -32601, `method not found: ${method}`);
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.2",
5
+ "version": "0.4.1-beta.4",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {