clearotron 0.4.1-beta.1 → 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": "0ac7d328864dbb4446ea2dd472f64acff14b7a4f",
3
- "version": "0.4.1-beta.1"
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,17 @@
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
+
9
+ ## 0.4.1-beta.2
10
+
11
+ ### Patch Changes
12
+
13
+ - Fixed: A clearance no longer stops before its report when findings link to an office's own page instead of the register record.
14
+
3
15
  ## 0.4.1-beta.1
4
16
 
5
17
  ### 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
@@ -849,6 +861,10 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
849
861
  // the server per turn so the acceptance boundary can refuse a payload naming any other index, which
850
862
  // turns O3c's measured 224/0 habit into a structure. Absent for every non-fan-out stage.
851
863
  if (recordAxis) e.CLEAROTRON_RECORD_AXIS = String(recordAxis);
864
+ // — THE RUN'S REGISTER, so a recording tool gates a record link at the call as verify.mjs gates the
865
+ // saved file (record-origins.mjs). Handed over explicitly: Codex gives a server only its own `env` and
866
+ // the names it forwards, so a value left to inheritance reaches a Claude server and never a Codex one.
867
+ if (process.env.CLEAROTRON_DATABASE) e.CLEAROTRON_DATABASE = process.env.CLEAROTRON_DATABASE;
852
868
  // — the ledger paths are RESOLVED HERE and handed to the child explicitly, not forwarded as raw
853
869
  // env. Two reasons, and the second is the one that kept this rename parked for months:
854
870
  // · the old line was `if (process.env[v]) e[v] = …`, and NO box sets those vars — prod, test and dev
@@ -877,6 +893,7 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
877
893
  if (runDir) e.CLEAROTRON_REGISTER_RECORD_LOG = runRecordLogPath(runDir);
878
894
  for (const v of ["CLEAROTRON_GATHER_SESSION_ID", "EUIPO_ENVIRONMENT"])
879
895
  if (process.env[v]) e[v] = process.env[v];
896
+ e.CLEAROTRON_TOOL_ANSWER_BYTES = String(claudeToolAnswerBytes()); // CLAUDE_TOOL_ANSWER_BYTES, above
880
897
  return e;
881
898
  }
882
899
 
@@ -931,6 +948,8 @@ export function buildGatherMcpConfig(groups = [], { sessionKey, agent, runDir, r
931
948
  if (runDir) auditEnv.CLEAROTRON_BAND_RUN_DIR = runDir;
932
949
  if (sessionKey) auditEnv.CLEAROTRON_GATHER_SESSION_KEY = sessionKey;
933
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());
934
953
  for (const k of bridgeKeys) mcpServers[k] = { command: NODE, args: [BRIDGE, "--server", k], connectionTimeoutMs: 60000, ...(Object.keys(auditEnv).length ? { env: auditEnv } : {}) };
935
954
  return Object.keys(mcpServers).length ? { mcpServers } : null;
936
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}`);
@@ -858,7 +858,7 @@ export function parseFindingsJson(raw, opts = {}) {
858
858
  method: "method" in opts ? (opts.method ?? null) : undefined,
859
859
  // — null means the caller named no provider, and the record-URL gate stays inactive. An empty
860
860
  // ARRAY is a real answer ("this provider publishes no per-record page") and is not the same thing.
861
- recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null };
861
+ recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null, foreignLinks: Array.isArray(opts.recordOrigins) ? [] : null };
862
862
  onlyKeys(doc, topKeysFor(mode), (k) => `findings_key_unknown:${short(k)}`);
863
863
  if (mode.v4) {
864
864
  if (typeof doc.rated_under_framework !== "string" || !doc.rated_under_framework.trim())
@@ -879,7 +879,7 @@ export function parseFindingsJson(raw, opts = {}) {
879
879
  throw new Error("findings_empty: the record carries no findings AND no coverage areas — at least one is required");
880
880
 
881
881
  const seenOrdinals = new Set();
882
- const outFindings = findings.map((f, idx) => validateFinding(f, idx, seenOrdinals, mode));
882
+ const outFindings = findings.map((f, idx) => validateFinding(f, idx, seenOrdinals, mode)); throwForeignLinks(mode);
883
883
  const outCoverage = coverage.map((c) => validateCoverage(c));
884
884
  const outContextNotes = contextNotes.map((n) => validateContextNote(n));
885
885
  const outActions = validateActionsList(doc.actions, outFindings);
@@ -1381,10 +1381,10 @@ function validateFinding(f, idx, seenOrdinals, mode = { v4: false, manifest: nul
1381
1381
  if (f.withdrawn_reason != null && f.disposition !== "withdrawn")
1382
1382
  throw new Error(`finding_withdrawn_reason_orphan:${ord} (withdrawn_reason is only valid when disposition is "withdrawn")`);
1383
1383
 
1384
- validateOwner(f.owner, ord, mode?.recordOrigins ?? null);
1384
+ validateOwner(f.owner, ord, mode?.recordOrigins ?? null, mode?.foreignLinks ?? null);
1385
1385
  validateMeters(f.meters);
1386
1386
  validateQuadrant(f.quadrant);
1387
- validateSource(f.source, ord, mode?.recordOrigins ?? null);
1387
+ validateSource(f.source, ord, mode?.recordOrigins ?? null, mode?.foreignLinks ?? null);
1388
1388
 
1389
1389
  // Instance #5 — per-finding use-check / own-rights cite. SHAPE-ONLY here (present ⇒ a { source } object
1390
1390
  // whose source is a string). The composite>=3 NON-EMPTY-source enforcement lives in verify.mjs
@@ -1969,13 +1969,13 @@ export function isDeadRecordLink(value) {
1969
1969
  return t === "" || t.startsWith("#") || t.startsWith("/");
1970
1970
  }
1971
1971
 
1972
- function checkRecordUrlHost(value, origins, ord, what) {
1972
+ function checkRecordUrlHost(value, origins, ord, what, collect = null) {
1973
1973
  if (origins == null) return; // gate inactive — see the header
1974
1974
  const origin = recordUrlOrigin(value);
1975
- if (origin == null) return;
1976
- if (origins.includes(origin)) return;
1975
+ if (origin == null || origins.includes(origin)) return;
1976
+ if (collect) { collect.push({ ord, what, value }); return; } // the strict parse names EVERY offender in one refusal
1977
1977
  throw new Error(
1978
- `finding_record_url_foreign_host:${ord} (${what} points at ${origin}, which is not a register this run searched — `
1978
+ `finding_record_url_foreign_host:${Array.isArray(ord) ? ord.join(",") : ord} (${what} points at ${origin}, which is not a register this run searched — `
1979
1979
  + (origins.length
1980
1980
  ? `this provider's record host is ${origins.join(" or ")}. Compose the URL from the record's own uri and THIS provider's base host, per providers/<name>.md.`
1981
1981
  : "this provider publishes no per-record page at all — cite the office register in the text and leave the URL as the record's own uri path.")
@@ -1983,7 +1983,7 @@ function checkRecordUrlHost(value, origins, ord, what) {
1983
1983
  );
1984
1984
  }
1985
1985
 
1986
- function validateOwner(owner, ord, origins = null) {
1986
+ function validateOwner(owner, ord, origins = null, collect = null) {
1987
1987
  if (!isPlainObject(owner)) throw new Error(`finding_owner_invalid:${ord} (owner must be an object { name, country, registrations })`);
1988
1988
  onlyKeys(owner, OWNER_KEYS, (k) => `finding_owner_key_unknown:${short(k)}`);
1989
1989
  if (typeof owner.name !== "string" || !owner.name.trim()) throw new Error(`finding_owner_invalid:${ord} (owner.name must be a non-empty string)`);
@@ -1999,7 +1999,7 @@ function validateOwner(owner, ord, origins = null) {
1999
1999
  if (!isPlainObject(r)) throw new Error(`finding_registration_invalid:${ord} (each registration must be a plain object)`);
2000
2000
  onlyKeys(r, REGISTRATION_KEYS, (k) => `finding_registration_key_unknown:${short(k)}`);
2001
2001
  if (typeof r.uri !== "string" || !r.uri.trim()) throw new Error(`finding_registration_invalid:${short(r.uri)} (registration.uri must be a non-empty string)`);
2002
- checkRecordUrlHost(r.uri, origins, ord, `registration.uri "${short(r.uri)}"`);
2002
+ checkRecordUrlHost(r.uri, origins, ord, `registration.uri "${short(r.uri)}"`, collect);
2003
2003
  if (r.classes != null && !(Array.isArray(r.classes) && r.classes.every((c) => typeof c === "string")))
2004
2004
  throw new Error(`finding_registration_invalid:${short(r.uri)} (registration.classes must be an array of strings)`);
2005
2005
  }
@@ -2042,7 +2042,7 @@ function validateQuadrant(q) {
2042
2042
  }
2043
2043
  }
2044
2044
 
2045
- function validateSource(source, ord, origins = null) {
2045
+ function validateSource(source, ord, origins = null, collect = null) {
2046
2046
  if (!isPlainObject(source)) throw new Error(`finding_source_invalid:${ord} (source must be { source_type, resolved_link })`);
2047
2047
  onlyKeys(source, SOURCE_KEYS, (k) => `finding_source_key_unknown:${short(k)}`);
2048
2048
  if (!SOURCE_TYPES.includes(source.source_type))
@@ -2061,7 +2061,7 @@ function validateSource(source, ord, origins = null) {
2061
2061
  + "dead anchor in the report. Give the record's real URL, or — if this provider publishes no "
2062
2062
  + 'per-record page — leave it "" and cite the office register in the text.)',
2063
2063
  );
2064
- checkRecordUrlHost(source.resolved_link, origins, ord, "source.resolved_link");
2064
+ checkRecordUrlHost(source.resolved_link, origins, ord, "source.resolved_link", collect);
2065
2065
  }
2066
2066
  }
2067
2067
 
@@ -2571,3 +2571,70 @@ export function knockoutFindingRange(mark) {
2571
2571
  if (!ords.length || !markName) return "";
2572
2572
  return ords.length === 1 ? `${markName} #${ords[0]}` : `${markName} #${ords[0]}–#${ords[ords.length - 1]}`;
2573
2573
  }
2574
+
2575
+ /**
2576
+ * ONE REFUSAL FOR EVERY FOREIGN RECORD LINK the strict parse collected.
2577
+ *
2578
+ * The gate used to throw at the first offending link, so each refusal named one finding and a corrective
2579
+ * attempt fixed one. A seat that had copied an office's page into eleven findings ran out of attempts at
2580
+ * the third. The token now carries every offending ordinal; the sentence after it is the gate's own,
2581
+ * unchanged, about the first offender.
2582
+ */
2583
+ function throwForeignLinks(mode) {
2584
+ const found = mode?.foreignLinks ?? [];
2585
+ if (!found.length) return;
2586
+ const ords = [...new Set(found.map((f) => f.ord))];
2587
+ checkRecordUrlHost(found[0].value, mode.recordOrigins, ords.length === 1 ? ords[0] : ords, found[0].what);
2588
+ }
2589
+
2590
+ /** The canonical record path inside an absolute URL (`/mark/<cc>/<rest>`), or null. PURE. */
2591
+ function canonicalRecordPath(value) {
2592
+ try {
2593
+ const path = new URL(String(value)).pathname;
2594
+ return /^\/mark\/[a-z]{2}\/[^/]+/i.test(path) ? path : null;
2595
+ } catch { return null; }
2596
+ }
2597
+
2598
+ /**
2599
+ * THE SALVAGE FOR A FOREIGN RECORD LINK: blank it, and deliver. PURE.
2600
+ *
2601
+ * A record link on a host the run's register does not publish is refused by the gate above. When a seat
2602
+ * keeps writing one, the report still goes out: a register-sourced `resolved_link` on a foreign host
2603
+ * becomes `""`, the value the findings model already reads as "this register publishes no page for the
2604
+ * record", and publish still links a register that publishes no pages to the office's own page from the
2605
+ * record itself. A `registration.uri` written as a foreign URL keeps the record's canonical path when
2606
+ * the URL carries one, since the path is the record's identity. One that does not is left as written and
2607
+ * returned in `kept`, because no identity can be read from it.
2608
+ *
2609
+ * `origins` is the gate's own list (recordOriginsFor); null turns this off, as it turns the gate off.
2610
+ * Returns the repaired document, every value it changed (`blanked`, with what it was), and `kept`.
2611
+ */
2612
+ export function blankForeignRecordLinks(doc, origins) {
2613
+ const blanked = [], kept = [];
2614
+ if (origins == null || !isPlainObject(doc) || !Array.isArray(doc.findings)) return { doc, blanked, kept };
2615
+ const foreign = (value) => { const o = recordUrlOrigin(value); return o != null && !origins.includes(o); };
2616
+ const findings = doc.findings.map((f) => {
2617
+ if (!isPlainObject(f)) return f;
2618
+ let out = f;
2619
+ const s = f.source;
2620
+ if (isPlainObject(s) && String(s.source_type ?? "").startsWith("register") && typeof s.resolved_link === "string" && foreign(s.resolved_link)) {
2621
+ out = { ...out, source: { ...s, resolved_link: "" } };
2622
+ blanked.push({ ordinal: f.ordinal ?? null, field: "source.resolved_link", was: s.resolved_link, now: "" });
2623
+ }
2624
+ const regs = isPlainObject(f.owner) && Array.isArray(f.owner.registrations) ? f.owner.registrations : null;
2625
+ if (regs) {
2626
+ let changed = false;
2627
+ const next = regs.map((r, index) => {
2628
+ if (!isPlainObject(r) || typeof r.uri !== "string" || !foreign(r.uri)) return r;
2629
+ const path = canonicalRecordPath(r.uri);
2630
+ if (!path) { kept.push({ ordinal: f.ordinal ?? null, field: "registration.uri", index, was: r.uri }); return r; }
2631
+ changed = true;
2632
+ blanked.push({ ordinal: f.ordinal ?? null, field: "registration.uri", index, was: r.uri, now: path });
2633
+ return { ...r, uri: path };
2634
+ });
2635
+ if (changed) out = { ...out, owner: { ...out.owner, registrations: next } };
2636
+ }
2637
+ return out;
2638
+ });
2639
+ return { doc: { ...doc, findings }, blanked, kept };
2640
+ }
@@ -0,0 +1,50 @@
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
+ // foreign-record-links.mjs — a record link on a host the run's register does not publish: refused once, then
5
+ // blanked, and the run delivers.
6
+ //
7
+ // The findings gate refuses such a link (`finding_record_url_foreign_host`, findings-model.mjs). R2 on
8
+ // 0.4.1-beta.1 delivered no report over eleven of them: the seat copied each office's own page into a
9
+ // record link, every attempt was refused, and nothing could repair it. Two layers now act on it, and both
10
+ // record what they did under one event name and in one file, so a reader meets one fact however it arose:
11
+ // · the synthesis transport, at the call (synthesis-record.mjs): the first offending call in a run is
12
+ // refused, naming every finding, so the seat can fix them in the same turn; a repeat is accepted with
13
+ // the foreign links blanked;
14
+ // · the synthesis salvage lane (pipeline.mjs), for a findings file the transport did not write.
15
+ // Blanking is blankForeignRecordLinks (findings-model.mjs): a register link becomes "", the value that
16
+ // already means "this register publishes no page for the record".
17
+ import { writeFileSync } from "node:fs";
18
+ import { driverDir } from "../shared/driver-dir.mjs";
19
+ import { blankForeignRecordLinks } from "./findings-model.mjs";
20
+ import { activeRecordOrigins } from "./record-origins.mjs";
21
+ import { runLog } from "./log.mjs";
22
+
23
+ export const FOREIGN_HOST_TOKEN = "finding_record_url_foreign_host";
24
+ export const BLANKED_FILE = "foreign-record-links.json";
25
+
26
+ /** The gate's list of record hosts: the caller's when it names one, else the run's register. */
27
+ export const recordOriginsOr = (given) => (given === undefined ? activeRecordOrigins() : given);
28
+
29
+ /**
30
+ * The synthesis call, refused once and then blanked. PURE. `refusedBefore` is whether this run's transport
31
+ * already refused a call for a foreign record link; until it has, the call is returned unchanged and the
32
+ * gate refuses it.
33
+ */
34
+ export function blankOnRepeat(call, origins, refusedBefore) {
35
+ if (!refusedBefore || !Array.isArray(origins) || !call?.findings) return { call, blanked: [], kept: [] };
36
+ const { doc, blanked, kept } = blankForeignRecordLinks(call.findings, origins);
37
+ return { call: blanked.length ? { ...call, findings: doc } : call, blanked, kept };
38
+ }
39
+
40
+ /** What a blanking changed: the originals kept beside the run, and one run-log row. Never throws. */
41
+ export function recordBlanking(runDir, { stage, layer, blanked, kept, fail = null }) {
42
+ try {
43
+ writeFileSync(driverDir(runDir, BLANKED_FILE),
44
+ JSON.stringify({ stage, layer, ...(fail ? { fail: String(fail).slice(0, 300) } : {}), blanked, kept }, null, 2) + "\n");
45
+ } catch { /* the file is forensics; the run-log row below is the record */ }
46
+ try {
47
+ runLog(runDir, { event: "foreign-record-links-blanked", stage, layer, blanked: blanked.length, kept: kept.length,
48
+ ordinals: [...new Set(blanked.map((b) => b.ordinal))] });
49
+ } catch { /* a log that cannot be written must not stop the record it describes */ }
50
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.1",
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": {
@@ -45,7 +45,7 @@ import { parseCoverageLedgerJson, parseCoverageLedgerFull, deriveCoverageStatus,
45
45
  import { receiptSettled, readEnvelopeDecision, settleReceipt, settledDeferralsSection, readStickyGaps } from "./envelope-settle.mjs";
46
46
  import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
47
47
  import { parseNamedBand, mergeNamedBands, findCollapsedBands, quarantineUnknownStates, taintQuarantineCleanBlocks, bandRecords } from "./named-band.mjs";
48
- import { recordOriginsFor } from "./record-origins.mjs";
48
+ import { recordOriginsFor, activeRecordOrigins } from "./record-origins.mjs"; import { recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs";
49
49
  import { REGISTER_PROVIDER } from "./driver.config.mjs";
50
50
  import { noteRegisterServed, registersServedFrom, providerUsageCaveat } from "./register-served.mjs"; // which registers actually served this run — noted eagerly from the dispatch's own resolver, and reconciled at publish against the ledger rows, which carry the vendor per call
51
51
  import { FACTS_FILE as DIGEST_FACTS_FILE, ACCOUNTING_STAMP as DIGEST_ACCOUNTING_STAMP, recordedFindingUris,
@@ -163,7 +163,7 @@ import { parsePlacementsJson } from "./placement-model.mjs"; // B2 — the str
163
163
  import { readAnchors } from "./anchor-reader.mjs";
164
164
  import { findEngagementReceipts } from "./engagement-receipt.mjs";
165
165
  import { plainRegisterFlags } from "./plain-register.mjs";
166
- import { parseFindingsJson, parseFindingsJsonLenient, consolidateFindings, deriveDisplayVerdict, bindRecommendation, compareBlockingPower, inDispositionMode, DISPOSITION_GROUP, deriveActionConditions, cardedParties, actionPartyReferences, quarantinedConditionRows, salvageRepairTargets, riskStatement, verdictStance, remapActionOrdinals, joinAskToAnswer, stripAskLabel, bandBorderlineDeclarations, reasonedNegativeGroups } from "./findings-model.mjs";
166
+ import { parseFindingsJson, parseFindingsJsonLenient, consolidateFindings, deriveDisplayVerdict, bindRecommendation, compareBlockingPower, inDispositionMode, DISPOSITION_GROUP, deriveActionConditions, cardedParties, actionPartyReferences, quarantinedConditionRows, salvageRepairTargets, riskStatement, verdictStance, remapActionOrdinals, joinAskToAnswer, stripAskLabel, bandBorderlineDeclarations, reasonedNegativeGroups, blankForeignRecordLinks } from "./findings-model.mjs";
167
167
  import { foldCaption, foldCardRead } from "./card-budget.mjs";
168
168
  // S2 — the report card's mechanical frame, composed from the record instead of dictated (see below).
169
169
  import { carriesOwnFrame, composeCard } from "./card-frame.mjs";
@@ -5035,7 +5035,7 @@ export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortR
5035
5035
  // a top-level defect, or nothing salvageable — fails the run exactly as before. One bad object never silently
5036
5036
  // suppresses a real conflict, and never silently kills a ~$40 run.
5037
5037
  async function quarantineSynth(r, ctx, name) {
5038
- if (r.ok) return r;
5038
+ if (r.ok) return r; if (foreignRecordLinksBlanked(r, ctx, name)) return { ...r, ok: true, fail: undefined, repaired: "foreign-record-links-blanked" };
5039
5039
  // A6: a max_tokens-named content fault wraps the underlying validator token — strip the wrapper so a
5040
5040
  // per-finding shape defect on a ceiling-cut turn still reaches the lenient-salvage lane.
5041
5041
  const fail = String(r.fail || "").replace(/^max_tokens_no_output:/, "");
@@ -16993,3 +16993,30 @@ function orphanAreaLabel(qid, axis, plan) {
16993
16993
  export function instructedScopeToWrite(job, { jobRebuiltFromStatus = false, scopeOnDisk = false } = {}) { // @internal
16994
16994
  return jobRebuiltFromStatus && scopeOnDisk ? null : instructedScopeOf(job);
16995
16995
  }
16996
+
16997
+ // ── THE SALVAGE FOR A FOREIGN RECORD LINK: BLANK IT, RE-CHECK, DELIVER ──────────────────────────────────
16998
+ //
16999
+ // A seat that writes an office's page, or another register's, into a record link is refused by the
17000
+ // findings gate (`finding_record_url_foreign_host`). The salvage lane below takes that token, because it is
17001
+ // a `finding_…` token, but its lenient re-parse does not judge hosts, so it found nothing to repair and
17002
+ // failed the run: R2 on 0.4.1-beta.1 delivered no report over eleven copied office links. Here the driver
17003
+ // blanks every foreign link itself (blankForeignRecordLinks says how), keeps what it changed beside the run,
17004
+ // and re-checks the file with the stage's own validator. A pass delivers; anything still refused falls
17005
+ // through to the lanes below exactly as before, with fewer defects in the file.
17006
+ export function foreignRecordLinksBlanked(r, ctx, name) { // exported for its test
17007
+ const fail = String(r?.fail || "").replace(/^max_tokens_no_output:/, "");
17008
+ if (!fail.includes(FOREIGN_HOST_TOKEN) || !existsSync(ctx.paths.findings)) return false;
17009
+ let doc;
17010
+ try { doc = JSON.parse(readFileSync(ctx.paths.findings, "utf8")); } catch { return false; }
17011
+ const { doc: repaired, blanked, kept } = blankForeignRecordLinks(doc, activeRecordOrigins());
17012
+ if (!blanked.length) return false;
17013
+ atomicWrite(ctx.paths.findings, JSON.stringify(repaired, null, 2) + "\n");
17014
+ recordBlanking(ctx.paths.runDir, { stage: name, layer: "salvage", blanked, kept, fail });
17015
+ note(`[${name}] ${blanked.length} record link(s) on a host this register does not publish were blanked by the driver; the originals are in _driver/foreign-record-links.json`);
17016
+ const def = STAGES[name];
17017
+ const out = def?.out ? def.out(ctx.paths, ctx.axis) : null;
17018
+ const v = out && existsSync(out) && def.validate ? def.validate(out, readFileSync(out, "utf8")) : null;
17019
+ if (v?.ok) return true;
17020
+ runLog(ctx.paths.runDir, { event: "foreign-record-links-recheck", stage: name, ok: false, fail: String(v?.reason ?? "no validator").slice(0, 200) });
17021
+ return false;
17022
+ }
@@ -585,6 +585,12 @@
585
585
  "skips": 0,
586
586
  "todos": 0
587
587
  },
588
+ "a-foreign-record-link-is-named-once-and-blanked-at-salvage.test.mjs": {
589
+ "tests": 8,
590
+ "asserts": 31,
591
+ "skips": 0,
592
+ "todos": 0
593
+ },
588
594
  "a-frame-call-with-places-per-name-passes-the-gate.test.mjs": {
589
595
  "tests": 2,
590
596
  "asserts": 7,
@@ -760,8 +766,8 @@
760
766
  "todos": 0
761
767
  },
762
768
  "a-large-grid-is-served-in-parts-under-the-tool-cap.test.mjs": {
763
- "tests": 4,
764
- "asserts": 19,
769
+ "tests": 5,
770
+ "asserts": 23,
765
771
  "skips": 0,
766
772
  "todos": 0
767
773
  },
@@ -789,6 +795,18 @@
789
795
  "skips": 0,
790
796
  "todos": 0
791
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
+ },
792
810
  "a-mark-name-cannot-lie-about-how-it-reads.test.mjs": {
793
811
  "tests": 9,
794
812
  "asserts": 38,
@@ -1359,6 +1377,12 @@
1359
1377
  "skips": 0,
1360
1378
  "todos": 0
1361
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
+ },
1362
1386
  "a-secret-file-is-compared-not-printed.test.mjs": {
1363
1387
  "tests": 5,
1364
1388
  "asserts": 16,
@@ -2014,8 +2038,8 @@
2014
2038
  "todos": 0
2015
2039
  },
2016
2040
  "band-server.test.mjs": {
2017
- "tests": 24,
2018
- "asserts": 126,
2041
+ "tests": 26,
2042
+ "asserts": 136,
2019
2043
  "skips": 0,
2020
2044
  "todos": 0
2021
2045
  },
@@ -3082,8 +3106,8 @@
3082
3106
  "todos": 0
3083
3107
  },
3084
3108
  "engine.codex-config.test.mjs": {
3085
- "tests": 20,
3086
- "asserts": 70,
3109
+ "tests": 23,
3110
+ "asserts": 89,
3087
3111
  "skips": 0,
3088
3112
  "todos": 0
3089
3113
  },
@@ -53,7 +53,7 @@ import { declinationCallPaths, readDeclinations } from "./declination-tool.mjs";
53
53
  import { reconcileDeclinationDuty, declinationDutyRefusal } from "./declination-duty.mjs";
54
54
  import { findingUris } from "./record-carry.mjs"; // one derivation of "which records did the findings name", called not copied
55
55
  import { foldAfterSave } from "./record-fold.mjs"; // once the delivery seam is passed, the saved record is folded again
56
-
56
+ import { recordOriginsOr, blankOnRepeat, recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs"; // a foreign record link: refused once, then blanked
57
57
  export const NARRATIVE_FILE = "narrative.md";
58
58
  export const FINDINGS_FILE = "findings.json";
59
59
 
@@ -269,7 +269,7 @@ export function withoutWithheldRows(rows, ledger) {
269
269
  return (Array.isArray(rows) ? rows : []).filter((r) => !namesIn(r?.area).some((n) => withheld.has(n)));
270
270
  }
271
271
 
272
- export function acceptSynthesis(params, { asks = [], ledger = null, manifest = null, method = undefined, methodInvalid = null, owed = null, declined = null } = {}) {
272
+ export function acceptSynthesis(params, { asks = [], ledger = null, manifest = null, method = undefined, methodInvalid = null, owed = null, declined = null, recordOrigins = null } = {}) {
273
273
  const doc = params?.findings;
274
274
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
275
275
  return { ok: false, reason: "synthesis_findings_missing: `findings` must be the findings document object { schema_version, rated_under_framework, findings, coverage, … } — this is the record the report and the workbook are built from, and there is no path that renders without it" };
@@ -383,7 +383,7 @@ export function acceptSynthesis(params, { asks = [], ledger = null, manifest = n
383
383
  // own file gone wrong, and the seat cannot fix it — so it is refused by name, never read as "no method".
384
384
  if (methodInvalid) return { ok: false, reason: `framework_method_unreadable: _driver/${FROZEN_METHOD_FILE} does not read back (${String(methodInvalid).slice(0, 160)}) — a driver fault, not the writer's` };
385
385
  let parsedDoc;
386
- try { parsedDoc = parseFindingsJson(findings, manifest ? { manifest, ...(method !== undefined ? { method } : {}) } : {}); }
386
+ try { parsedDoc = parseFindingsJson(findings, { ...(manifest ? { manifest, ...(method !== undefined ? { method } : {}) } : {}), ...(Array.isArray(recordOrigins) ? { recordOrigins } : {}) }); }
387
387
  catch (e) {
388
388
  return { ok: false, reason: `synthesis_findings_invalid: ${String(e?.message ?? e).slice(0, 300)}` };
389
389
  }
@@ -608,7 +608,7 @@ export function recordSynthesis(runDir, received, opts = {}) {
608
608
  const auto = driverReadsFor(dir0);
609
609
  const { asks = auto.asks, ledger = auto.ledger, manifest = auto.manifest,
610
610
  method = auto.method, methodInvalid = auto.methodInvalid ?? null,
611
- owed = auto.owed, declined = auto.declined,
611
+ owed = auto.owed, declined = auto.declined, recordOrigins = undefined,
612
612
  now = () => new Date().toISOString() } = opts;
613
613
  const { dir, payload, accepted, refusals } = synthesisCallPaths(dir0);
614
614
  // Its sibling rule — ONE FILE PER CALL, refusals included. This used to
@@ -644,7 +644,16 @@ export function recordSynthesis(runDir, received, opts = {}) {
644
644
  call = merged.merged;
645
645
  }
646
646
 
647
- const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined });
647
+ // A RECORD LINK ON A HOST THIS REGISTER DOES NOT PUBLISH is gated here as verify.mjs gates the saved
648
+ // file. The run's first such call is refused, naming every finding, so the seat can fix them in this
649
+ // turn; a repeat is accepted with those links blanked and the originals kept (foreign-record-links.mjs).
650
+ const origins = recordOriginsOr(recordOrigins);
651
+ const repeat = blankOnRepeat(call, origins, refusalsFor(dir0).some((r) => String(r?.reason ?? "").includes(FOREIGN_HOST_TOKEN)));
652
+ if (repeat.blanked.length) {
653
+ call = repeat.call;
654
+ recordBlanking(dir0, { stage: "synthesis", layer: "call", blanked: repeat.blanked, kept: repeat.kept });
655
+ }
656
+ const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined, recordOrigins: origins });
648
657
  if (!v.ok) {
649
658
  noteRefusal(v.reason);
650
659
  return { written: null, refused: v.reason,
@@ -1,5 +1,13 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.3
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.1-beta.2
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.1-beta.1
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.1-beta.1",
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.1",
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.1",
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,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.3
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.4.1-beta.2
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.4.1-beta.1
4
12
 
5
13
  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.1",
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).",
@@ -86,7 +86,7 @@ export const COUNTED_EVENTS = {
86
86
  "record-carry-failed": FAILED, "record-discard-failed": FAILED, "register-digest-facts-failed": FAILED,
87
87
  "register-plan-infeasible": FAILED, "register-positions-failed": FAILED, "register-presence-failed": FAILED,
88
88
  "remedy-accounting-failed": FAILED, "report-cards-rate-limited": FAILED, "restamp-miss": FAILED,
89
- "reviewer-degenerate": FAILED, "salvage-lane-no-target": FAILED, "searched-jurisdictions-unresolved": FAILED,
89
+ "reviewer-degenerate": FAILED, "salvage-lane-no-target": FAILED, "searched-jurisdictions-unresolved": FAILED, "foreign-record-links-recheck": FAILED,
90
90
  "skeptic-skipped": FAILED, "stage-input-over-ceiling": FAILED, "status-write-failed": FAILED,
91
91
  "stale-repair-entry-done": { kind: FAILED, when: (e) => (Array.isArray(e.failed) ? e.failed.length : Number(e.failed)) > 0 },
92
92
  // written only where the step threw
@@ -138,7 +138,7 @@ export const COUNTED_EVENTS = {
138
138
  "commonlaw-channels-unstated": REPAIR, "commonlaw-channels-unusable": REPAIR,
139
139
  "corrective-unnamed-removal-repaired": REPAIR, "coverage-floor-clamp": REPAIR, "jx-receipt-repaired": REPAIR,
140
140
  "knockout-next-step-removed": REPAIR, "recall-reconciliation-positions-rederived": REPAIR, "registry-auto-correct": REPAIR,
141
- "repair-attempted": REPAIR, "terminal-guard-clamp": REPAIR, "verdict-conditions-dropped": REPAIR,
141
+ "repair-attempted": REPAIR, "terminal-guard-clamp": REPAIR, "verdict-conditions-dropped": REPAIR, "foreign-record-links-blanked": REPAIR,
142
142
  "verdict-hardened-by-repair": REPAIR, "verdict-rederive-repair": REPAIR,
143
143
  "common-law-half-quarantined": QUARANTINE, "coverage-ledger-quarantined": QUARANTINE,
144
144
  "findings-actions-quarantined": QUARANTINE, "named-band-state-quarantined": QUARANTINE, "quarantine": QUARANTINE,
@@ -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",