clearotron 0.4.1-beta.2 → 0.4.1-beta.3

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/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "08583753fbde693fdecdc8e16f11781dae789f55",
3
- "version": "0.4.1-beta.2"
2
+ "commit": "38d43f7a215c87092fd2cc233812b3aff17e1e82",
3
+ "version": "0.4.1-beta.3"
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,11 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1-beta.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A crowded clearance on the Codex engine no longer stops before its report.
8
+
3
9
  ## 0.4.1-beta.2
4
10
 
5
11
  ### Patch Changes
@@ -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;
@@ -167,11 +191,15 @@ export function renderCodexConfigToml({ mcpConfig, allowedTools, developerInstru
167
191
  lines.push("");
168
192
  };
169
193
 
170
- for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name] }, { forwardCreds: true });
194
+ // Every server answers under codex's cut, the band server in its own way too (CODEX_TOOL_ANSWER_BYTES,
195
+ // CODEX_BAND_ANSWER_CHARS, above); `env` wins over a forwarded name.
196
+ const answers = { CLEAROTRON_TOOL_ANSWER_BYTES: String(toolAnswerBytes) };
197
+ for (const [name, s] of Object.entries(servers)) emitServer(name, { ...s, enabledTools: enabled[name],
198
+ env: { ...s.env, ...answers, ...(name === "band" ? { CLEAROTRON_BAND_RESPONSE_CHARS: String(bandAnswerChars) } : {}) } }, { forwardCreds: true });
171
199
 
172
200
  // WebFetch → the engine-local fetch server (codex has no built-in). Needs no creds (no env_vars).
173
201
  if (webFetchRequested(allowedTools))
174
- emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: {}, enabledTools: ["fetch_url"] }, { forwardCreds: false });
202
+ emitServer("fetch", { command: nodeBin, args: [FETCH_SERVER], env: { ...answers }, enabledTools: ["fetch_url"] }, { forwardCreds: false });
175
203
 
176
204
  if (fence) lines.push(...fenceToml(fence));
177
205
  // 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.3",
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": {
@@ -766,8 +766,8 @@
766
766
  "todos": 0
767
767
  },
768
768
  "a-large-grid-is-served-in-parts-under-the-tool-cap.test.mjs": {
769
- "tests": 4,
770
- "asserts": 19,
769
+ "tests": 5,
770
+ "asserts": 23,
771
771
  "skips": 0,
772
772
  "todos": 0
773
773
  },
@@ -795,6 +795,18 @@
795
795
  "skips": 0,
796
796
  "todos": 0
797
797
  },
798
+ "a-long-answer-reaches-a-gpt-model-in-parts.test.mjs": {
799
+ "tests": 5,
800
+ "asserts": 39,
801
+ "skips": 0,
802
+ "todos": 0
803
+ },
804
+ "a-long-case-law-answer-reaches-a-gpt-model-in-parts.test.mjs": {
805
+ "tests": 2,
806
+ "asserts": 7,
807
+ "skips": 0,
808
+ "todos": 0
809
+ },
798
810
  "a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
799
811
  "tests": 9,
800
812
  "asserts": 38,
@@ -1365,6 +1377,12 @@
1365
1377
  "skips": 0,
1366
1378
  "todos": 0
1367
1379
  },
1380
+ "a-seat-reads-the-band-page-by-page.test.mjs": {
1381
+ "tests": 4,
1382
+ "asserts": 25,
1383
+ "skips": 0,
1384
+ "todos": 0
1385
+ },
1368
1386
  "a-secret-file-is-compared-not-printed.test.mjs": {
1369
1387
  "tests": 5,
1370
1388
  "asserts": 16,
@@ -2020,8 +2038,8 @@
2020
2038
  "todos": 0
2021
2039
  },
2022
2040
  "band-server.test.mjs": {
2023
- "tests": 24,
2024
- "asserts": 126,
2041
+ "tests": 26,
2042
+ "asserts": 136,
2025
2043
  "skips": 0,
2026
2044
  "todos": 0
2027
2045
  },
@@ -3088,8 +3106,8 @@
3088
3106
  "todos": 0
3089
3107
  },
3090
3108
  "engine.codex-config.test.mjs": {
3091
- "tests": 20,
3092
- "asserts": 70,
3109
+ "tests": 23,
3110
+ "asserts": 89,
3093
3111
  "skips": 0,
3094
3112
  "todos": 0
3095
3113
  },
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.3
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.2
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.1-beta.2",
3
+ "version": "0.4.1-beta.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron",
3
3
  "mcpName": "io.github.CordilleraSarl/clearotron",
4
4
  "type": "module",
5
- "version": "0.4.1-beta.2",
5
+ "version": "0.4.1-beta.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.2",
5
+ "version": "0.4.1-beta.3",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.3
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.2
4
8
 
5
9
  No changes in this release.
@@ -30,6 +30,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
30
30
  import { homedir } from "node:os";
31
31
  import path from "node:path";
32
32
  import { driverDir } from "../../shared/driver-dir.mjs"; //
33
+ import { answerBudget, answerPieces, servedPart, partNotHeld, takePart, heldAnswers, withAnswerPart } from "../../shared/answer-parts.mjs";
33
34
  import { RefreshLock } from "./refresh-lock.mjs";
34
35
  import { exitOnStdinClose } from "./stdin-guard.mjs";
35
36
 
@@ -327,6 +328,19 @@ async function main() {
327
328
  { capabilities: { tools: {} } },
328
329
  );
329
330
 
331
+ // A LONG ANSWER IS SERVED IN PARTS when the bridge was given an answer size (shared/answer-parts.mjs). An
332
+ // answer made of text alone is split; one carrying anything else is passed on as it came.
333
+ const budget = answerBudget();
334
+ const held = heldAnswers();
335
+ const partedResult = (tool, args, res) => {
336
+ const blocks = Array.isArray(res?.content) ? res.content : [];
337
+ if (!blocks.length || !blocks.every((c) => c?.type === "text")) return res;
338
+ const pieces = answerPieces(blocks.map((c) => String(c.text ?? "")).join("\n"), budget, tool);
339
+ if (pieces.length === 1) return res;
340
+ held.hold(tool, args, pieces);
341
+ return { content: [{ type: "text", text: servedPart(tool, pieces, 1) }] };
342
+ };
343
+
330
344
  downstream.setRequestHandler(ListToolsRequestSchema, async () => {
331
345
  const upstreamResp = await upstream.listTools();
332
346
  const upstreamTools = upstreamResp?.tools ?? [];
@@ -339,7 +353,7 @@ async function main() {
339
353
  `[oauth-bridge:${serverName}] filtered ${dropped.length} unlisted upstream tool(s) from ListTools: ${dropped.join(", ")}\n`,
340
354
  );
341
355
  }
342
- return { ...upstreamResp, tools };
356
+ return { ...upstreamResp, tools: budget ? tools.map((t) => ({ ...t, inputSchema: withAnswerPart(t.inputSchema) })) : tools };
343
357
  });
344
358
 
345
359
  downstream.setRequestHandler(CallToolRequestSchema, async (req) => {
@@ -353,12 +367,20 @@ async function main() {
353
367
  `Tool "${name}" is not in the bridge allowlist for ${serverName}. To enable it, add the unprefixed upstream name to ALLOWED_TOOLS in bridge.mjs and restart.`,
354
368
  );
355
369
  }
370
+ // A later part of a held answer is read from it; the upstream server is not asked again.
371
+ const { args, part } = budget ? takePart(req.params.arguments) : { args: req.params.arguments, part: null };
372
+ const kept = part ? held.get(name, args) : null;
373
+ if (part && (kept || part > 1)) {
374
+ logCall(serverName, name, args, kept ? { ok: true, answer_part: part, of: kept.length } : { ok: false, answer_part: part, held: false });
375
+ return kept ? { content: [{ type: "text", text: servedPart(name, kept, part) }] }
376
+ : { isError: true, content: [{ type: "text", text: partNotHeld(name, part) }] };
377
+ }
356
378
  try {
357
- const res = await upstream.callTool({ name, arguments: req.params.arguments });
379
+ const res = await upstream.callTool({ name, arguments: args });
358
380
  const refused = toolError(res);
359
381
  if (refused === null) logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
360
382
  else logCall(serverName, name, req.params.arguments, { ok: false, error: refused });
361
- return res;
383
+ return budget && refused === null ? partedResult(name, args, res) : res;
362
384
  } catch (err) {
363
385
  // A FAILED CALL IS LOGGED TOO, and it is the half that matters: without it, "the search ran and
364
386
  // found nothing" and "the search errored" leave the same trace — the ambiguity is about.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.1-beta.2",
3
+ "version": "0.4.1-beta.3",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -222,7 +222,7 @@ export const NOT_COUNTED_EVENTS = {
222
222
  "stale-repair-entry", "taint-disclose", "unsettled-inputs", "verdict-2", "verdict-3",
223
223
  ],
224
224
  "read from its own record: the stage records, the dispatch records and the tool-call log": [
225
- "attempt", "stage", "settled", "started",
225
+ "answer-part", "attempt", "stage", "settled", "started",
226
226
  ],
227
227
  "the portal's, the profile store's or the config store's audit log, never a run log": [
228
228
  "compose-read", "demo-order", "family-group", "family-ungroup", "other", "person-add", "person-change",
@@ -0,0 +1,127 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // answer-parts.mjs — a tool answer longer than the engine carries whole, served in parts.
5
+ //
6
+ // Codex shows a model at most 10,000 tokens of the text a tool call puts in front of it, counting 4 bytes a
7
+ // token, and puts "…N tokens truncated…" where the middle was. That text is the tool's answer itself, or,
8
+ // for the GPT models that call tools only from code (all of 5.6 and 6.x), what that code prints, and code
9
+ // that prints an answer as a quoted string adds a byte for each quote and line break. Measured in testing,
10
+ // 2026-10-11, on codex 0.162.0 with gpt-6.1-sol and on codex 0.156.1 with gpt-5.6-sol: a model that
11
+ // printed a 42,560-byte answer to read it saw its start and end only. Register searches, research answers,
12
+ // fetched pages and case-law results all run past that size on real matters. So when a server is given an
13
+ // answer size (CLEAROTRON_TOOL_ANSWER_BYTES, set for codex in engine/mcp/codex-config.mjs), an answer
14
+ // longer than that, counted as a quoted string, is split into parts, each with a banner naming the next
15
+ // call. The tool runs ONCE: the whole answer is held in the server's process and a later part is read from
16
+ // it, so nothing is searched, fetched or written again. With no size given, nothing here runs and every
17
+ // answer is served as it always was.
18
+ //
19
+ // Two places use it, because every tool answer the engine reads passes through one of them: serve() in
20
+ // driver/engine/mcp/stdio-server.mjs, for every local server, and the OAuth bridge, for the case-law servers.
21
+
22
+ /** The argument a later part is asked for by. No tool uses this name for anything else. */
23
+ export const ANSWER_PART_ARG = "answer_part";
24
+
25
+ /** Room left in each part for its banner; a tool's name is counted on top. */
26
+ const BANNER_RESERVE = 400;
27
+
28
+ /** The answer size this server was given, in bytes, or 0 when none was. Never under 8 KB. */
29
+ export function answerBudget(env = process.env) {
30
+ const n = Number(env.CLEAROTRON_TOOL_ANSWER_BYTES);
31
+ return n > 0 ? Math.max(8192, Math.floor(n)) : 0;
32
+ }
33
+
34
+ /** What a character costs once the text is a quoted string: its UTF-8 bytes, or its escape. PURE. */
35
+ function quotedCost(code) {
36
+ if (code === 0x22 || code === 0x5c || code === 0x0a || code === 0x0d || code === 0x09 || code === 0x08 || code === 0x0c) return 2;
37
+ if (code < 0x20) return 6;
38
+ return code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
39
+ }
40
+
41
+ /** The size of text as a quoted string, in bytes, quotes not counted: never less than its own bytes. PURE. */
42
+ export function quotedBytes(text) {
43
+ let n = 0;
44
+ for (const ch of String(text)) n += quotedCost(ch.codePointAt(0));
45
+ return n;
46
+ }
47
+
48
+ /**
49
+ * Split text into consecutive pieces of at most `budget` bytes, counted as a quoted string. PURE. A piece
50
+ * ends after its last line break when it holds one, so a line is cut only when it is longer than a piece;
51
+ * every cut falls between characters. Joining the pieces with nothing between them gives the text back.
52
+ */
53
+ export function splitAnswer(text, budget) {
54
+ const chars = Array.from(String(text));
55
+ const pieces = [];
56
+ let start = 0;
57
+ while (start < chars.length) {
58
+ let cost = 0, end = start, lastBreak = -1;
59
+ while (end < chars.length) {
60
+ const c = quotedCost(chars[end].codePointAt(0));
61
+ if (cost + c > budget && end > start) break;
62
+ cost += c;
63
+ if (chars[end] === "\n") lastBreak = end;
64
+ end++;
65
+ }
66
+ if (end < chars.length && lastBreak >= start) end = lastBreak + 1;
67
+ pieces.push(chars.slice(start, end).join(""));
68
+ start = end;
69
+ }
70
+ return pieces.length ? pieces : [""];
71
+ }
72
+
73
+ /** The pieces of an answer too long for one response; one piece when it fits. PURE. */
74
+ export function answerPieces(text, budget, tool) {
75
+ if (!budget || quotedBytes(text) <= budget) return [String(text)];
76
+ return splitAnswer(text, Math.max(1024, budget - BANNER_RESERVE - 2 * quotedBytes(String(tool))));
77
+ }
78
+
79
+ /** Part `part` of `pieces`, between the banners that say what it is and how to read the next one. PURE. */
80
+ export function servedPart(tool, pieces, part) {
81
+ const total = pieces.length;
82
+ const p = Math.min(Math.max(1, Math.floor(Number(part)) || 1), total);
83
+ const head = `[${tool} answer part ${p}/${total} — this answer exceeds one tool response, so it is served in ${total} parts.`
84
+ + ` It is COMPLETE only once ALL ${total} parts are read — never reason from a partial answer.]`;
85
+ const tail = p < total
86
+ ? `[end of part ${p}/${total} — call ${tool} again with the same arguments plus {"${ANSWER_PART_ARG}": ${p + 1}} for the next part]`
87
+ : `[end of part ${total}/${total} — the answer is complete once parts 1..${total} are all read]`;
88
+ return `${head}\n${pieces[p - 1]}\n${tail}`;
89
+ }
90
+
91
+ /** What a part request is told when this process no longer holds that answer. PURE. */
92
+ export function partNotHeld(tool, part) {
93
+ return `${tool}: no answer for these arguments is held, so part ${part} cannot be served — `
94
+ + `call ${tool} again without "${ANSWER_PART_ARG}" for the whole answer.`;
95
+ }
96
+
97
+ /** The arguments of a call without the part it asks for, and that part (null when it asks for none). PURE. */
98
+ export function takePart(args) {
99
+ if (!args || typeof args !== "object" || !(ANSWER_PART_ARG in args)) return { args: args || {}, part: null };
100
+ const { [ANSWER_PART_ARG]: asked, ...rest } = args;
101
+ const part = Math.floor(Number(asked));
102
+ return { args: rest, part: part >= 1 ? part : null };
103
+ }
104
+
105
+ const canonical = (v) => (Array.isArray(v) ? v.map(canonical)
106
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])])) : v);
107
+
108
+ /** The answers this process holds, by tool and arguments; the oldest goes once `limit` are held. */
109
+ export function heldAnswers(limit = 16) {
110
+ const held = new Map();
111
+ const key = (tool, args) => `${tool}\u0000${JSON.stringify(canonical(args ?? {}))}`;
112
+ return {
113
+ hold(tool, args, pieces) {
114
+ const k = key(tool, args);
115
+ held.delete(k);
116
+ held.set(k, pieces);
117
+ while (held.size > limit) held.delete(held.keys().next().value);
118
+ },
119
+ get: (tool, args) => held.get(key(tool, args)) ?? null,
120
+ };
121
+ }
122
+
123
+ /** A tool's input schema with the part argument added, for a server that was given an answer size. PURE. */
124
+ export function withAnswerPart(schema) {
125
+ const s = schema && typeof schema === "object" ? schema : { type: "object" };
126
+ return { ...s, properties: { ...(s.properties || {}), [ANSWER_PART_ARG]: { type: "number" } } };
127
+ }
@@ -177,6 +177,7 @@ export const NAMES_IN_FORCE = Object.freeze([
177
177
  "CLEAROTRON_SYNTHESIS_MODEL",
178
178
  "CLEAROTRON_TAINT_CHAIN",
179
179
  "CLEAROTRON_TEST_FIXTURE_PROFILES",
180
+ "CLEAROTRON_TOOL_ANSWER_BYTES",
180
181
  "CLEAROTRON_TURN_CAP",
181
182
  "CLEAROTRON_TURN_LOCK_POLL_MS",
182
183
  "CLEAROTRON_UNREACHABLE_SENIOR",