clearotron 0.4.0 → 0.4.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THIRD-PARTY-NOTICES.md +2 -2
- package/build-info.json +2 -2
- package/docs/SECURITY-OWASP.md +21 -13
- package/docs/SECURITY.md +20 -14
- package/docs/architecture/04-configuration-reference.md +3 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/docs/architecture/06-operations-runbook.md +1 -1
- package/docs/architecture/09-security-and-data.md +16 -11
- package/driver/CHANGELOG.md +18 -0
- package/driver/binding-layers.mjs +28 -6
- package/driver/citation-census.json +3 -3
- package/driver/common-law-coverage-status.mjs +52 -0
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/connotation-search.mjs +5 -5
- package/driver/contract-dictation-registry.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +18 -18
- package/driver/degraded-parts.mjs +10 -3
- package/driver/dispatch-record.mjs +19 -0
- package/driver/doubt-closure-tool.mjs +2 -2
- package/driver/driver.config.mjs +39 -6
- package/driver/engagement-receipt.mjs +1 -1
- package/driver/engine/CONTRACT.md +41 -1
- package/driver/engine/anthropic-agent.mjs +56 -4
- package/driver/engine/mcp/codex-config.mjs +1 -1
- package/driver/engine/mcp/coverage-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +31 -4
- package/driver/engine/mcp/stdio-server.mjs +45 -7
- package/driver/engine/openai-agent.mjs +100 -17
- package/driver/engine/session-record.mjs +223 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/findings-model.mjs +3 -3
- package/driver/form-neighbourhood.mjs +7 -7
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/framework.mjs +22 -0
- package/driver/gateway.mjs +94 -12
- package/driver/named-band.mjs +2 -0
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +13 -2
- package/driver/pipeline-knockout.mjs +1 -1
- package/driver/pipeline.mjs +46 -33
- package/driver/portal-families.mjs +3 -3
- package/driver/portal-service.mjs +1 -1
- package/driver/predelivery-lint.mjs +19 -19
- package/driver/publish/index.mjs +1 -1
- package/driver/publish/knockout.mjs +5 -4
- package/driver/publish/office-record-links.mjs +17 -2
- package/driver/publish/publish-inputs.mjs +1 -1
- package/driver/publish/render-knockout.mjs +17 -5
- package/driver/publish/render.mjs +4 -2
- package/driver/record-fold.mjs +177 -0
- package/driver/register-count.mjs +18 -1
- package/driver/register-plan.mjs +71 -17
- package/driver/register-records.mjs +7 -0
- package/driver/registry-fidelity.mjs +2 -2
- package/driver/repairs.mjs +1 -1
- package/driver/report-card-record.mjs +4 -4
- package/driver/scope-facts.mjs +11 -3
- package/driver/search-policy.mjs +20 -1
- package/driver/skills/clearance-common-law/SKILL.md +22 -22
- package/driver/skills/clearance-register/digest.md +3 -3
- package/driver/skills/clearance-register/register-recipes.md +11 -11
- package/driver/skills/clearance-register/status-rules.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/firm-wide-reasoning.md +3 -2
- package/driver/skills/clearance-search/phase2-execution.md +6 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-search/worked-examples.md +39 -39
- package/driver/skills/clearance-variants/SKILL.md +16 -16
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/stage-context.mjs +27 -1
- package/driver/stages.mjs +12 -12
- package/driver/suite-census.json +175 -31
- package/driver/synthesis-record.mjs +5 -0
- package/driver/unit-inventory.mjs +17 -28
- package/driver/verify.mjs +6 -6
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/evidence.mjs +8 -8
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/package.json +2 -2
- package/package.json +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/count.mjs +4 -1
- package/providers/_shared/enumerate.mjs +61 -22
- package/providers/_shared/execute-plan.mjs +2 -0
- package/providers/corsearch/src/capabilities.js +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +2 -2
- package/providers/signa/src/capabilities.js +17 -2
- package/providers/signa/src/core.js +234 -37
- package/scripts/band-shape-probe.mjs +2 -2
- package/scripts/deprecate-below.mjs +28 -4
- package/scripts/e2e-first-time.mjs +16 -1
- package/scripts/freeze-example-run.mjs +2 -2
- package/shared/scope.mjs +18 -2
package/driver/verify.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { matterFrameWasRecorded, frameRatifiedForms } from "./matter-frame-recor
|
|
|
16
16
|
import { findConnotationViolations, parsePrRiskResults, prRiskPopulation,
|
|
17
17
|
CONNOTATION_UNMATCHED_MARK, CONNOTATION_NO_RESEMBLANCE_MARK, MEANING_ANGLES_RE,
|
|
18
18
|
parseDispositionForm, CONNOTATION_UNRULED_REASONS, queryKey } from "./connotation-search.mjs";
|
|
19
|
-
import { formSidecarName, formSidecarPath } from "./disposition-union.mjs";
|
|
19
|
+
import { formSidecarName, formSidecarPath } from "./disposition-union.mjs";
|
|
20
20
|
// B — the transport's own four failure states. The audit reads the run's records; this file locates them.
|
|
21
21
|
import { auditDispositionCalls, CALL_FAILURE_REASONS } from "./disposition-call-audit.mjs";
|
|
22
22
|
import { callRecordPaths } from "./disposition-tool.mjs";
|
|
@@ -37,7 +37,7 @@ import { registerPlanCallKilled } from "./tool-calls.mjs"; // — did the dict
|
|
|
37
37
|
import { parseFindingsJson, parseFindingsJsonLenient, CLIENT_TIER_BY_COMPOSITE, isUnconditionalProceed, joinFindingToBlock, parseBlockOrd } from "./findings-model.mjs";
|
|
38
38
|
import { parsePlacementsJson } from "./placement-model.mjs"; import { placementFormSidecarName, parsePlacementForm, placementRenderAccount } from "./placement-form.mjs";
|
|
39
39
|
import { parseCaseLawLedger, findCaseLawLedgerViolations, caseLawLedgerFail } from "./case-law-ledger.mjs";
|
|
40
|
-
import { parseFrameworkManifest,
|
|
40
|
+
import { parseFrameworkManifest, materialBand, normalizeBand } from "./framework.mjs"; import { readFrozenMethod, FROZEN_METHOD_FILE } from "./framework-method.mjs";
|
|
41
41
|
import { parseNamedBand, findCollapsedBands } from "./named-band.mjs";
|
|
42
42
|
import { parseBlindFrameModel } from "./blind-frame-model.mjs";
|
|
43
43
|
import { parseFrameDiff } from "./frame-diff-model.mjs";
|
|
@@ -681,7 +681,7 @@ function commonLawStructural(c, p = null) {
|
|
|
681
681
|
needsSection(c, "coverage-ledger", [/coverage[\s-]ledger/i], "coverage-ledger"),
|
|
682
682
|
needsSection(c, "audit-trail", [/audit[\s-]trail/i], "audit-trail"),
|
|
683
683
|
needs(c, [/\|/], "platform matrix"),
|
|
684
|
-
|
|
684
|
+
// no status check here (ruling 2026-10-01): a ledger with no status word and no record passes, and code records what it states — recordStatusesFromFindings() in common-law-coverage-status.mjs
|
|
685
685
|
);
|
|
686
686
|
}
|
|
687
687
|
|
|
@@ -1203,9 +1203,9 @@ function checkFindingsSibling(p, c) {
|
|
|
1203
1203
|
const v4 = (parsed.schemaVersion ?? 1) >= 4;
|
|
1204
1204
|
if (v4 && !fw.manifest) return fail("framework_manifest_missing_for_v4: schema_version 4 findings need the frozen _driver/framework.json to judge band words");
|
|
1205
1205
|
const v3 = (parsed.schemaVersion ?? 1) >= 3; // gates key on schema_version — v2/archived runs and replay never flip
|
|
1206
|
-
// "material finding" — the line the receipt gates key on: composite>=3 on the legacy scale; on v4,
|
|
1207
|
-
//
|
|
1208
|
-
const material = (f) => v4 ? (f.band != null &&
|
|
1206
|
+
// "material finding" — the line the receipt gates key on: composite>=3 on the legacy scale; on v4, banded on
|
|
1207
|
+
// a rung whose own tone is medium risk or higher (materialBand — by tone, never by position on the ladder).
|
|
1208
|
+
const material = (f) => v4 ? (f.band != null && materialBand(fw.manifest, f.band)) : f.composite >= 3;
|
|
1209
1209
|
for (const f of parsed.findings) {
|
|
1210
1210
|
if (material(f) && f.use_check != null && !String(f.use_check.source ?? "").trim())
|
|
1211
1211
|
return fail(`finding_use_check_source_missing:${f.ordinal}`);
|
package/mcp-server/CHANGELOG.md
CHANGED
|
@@ -294,7 +294,7 @@ const MATCH_SHAPES = [
|
|
|
294
294
|
// `*` wildcard, and the term extractor must see the word, not the markup.
|
|
295
295
|
const deMarkup = (s) => String(s ?? "").replace(/\*\*|__/g, "");
|
|
296
296
|
|
|
297
|
-
// Record COUNTS ride along in parentheses — `
|
|
297
|
+
// Record COUNTS ride along in parentheses — `HAVEN (424)`, `ヘイヴン (34) / 헤이븐 (20)`. They are
|
|
298
298
|
// process exhaust rather than the query, and dropping them first is what lets the rest of the string be
|
|
299
299
|
// recognised as the bare term it is.
|
|
300
300
|
const deCount = (s) => String(s ?? "").replace(/\s*\(\s*~?\s*[\d,]+\s*\)/g, "");
|
|
@@ -302,26 +302,26 @@ const deCount = (s) => String(s ?? "").replace(/\s*\(\s*~?\s*[\d,]+\s*\)/g, "");
|
|
|
302
302
|
// A searched term is recognised three ways, in order, and NOT recognised otherwise:
|
|
303
303
|
// 1. explicitly quoted — `exact "Drivers Haven" (default)`
|
|
304
304
|
// 2. the whole string, when it carries no query scaffolding at all. This is how the common-law sweep
|
|
305
|
-
// writes them and it is the bulk of the corpus:
|
|
306
|
-
// `
|
|
305
|
+
// writes them and it is the bulk of the corpus: `드라이버스 헤이븐`, `车手港湾`, `DRIVERZ`,
|
|
306
|
+
// `HAVEN WORLD`. Those scripts are caseless, so a capitals rule cannot see them at all.
|
|
307
307
|
// "No scaffolding" is checked positively: no match-shape keyword, no residual parenthetical, and
|
|
308
308
|
// none of the structural punctuation the audit uses for anything OTHER than a query (a record path,
|
|
309
309
|
// a bracketed class list, an em-dash clause, a key: value tail).
|
|
310
|
-
// 3. an ALL-CAPS token — `exact
|
|
310
|
+
// 3. an ALL-CAPS token — `exact HAVEN`, `*HAVEN / fuzzy`. Two-letter tokens (ZH, JP, DE) are
|
|
311
311
|
// language/jurisdiction codes, not marks, and the register offices above are offices, not marks.
|
|
312
312
|
const SCAFFOLD = /[[\]`:—]|\/mark\//;
|
|
313
313
|
|
|
314
314
|
// "Does this read as a NAME?" — the property that separates a searched mark from a description of a
|
|
315
315
|
// search. Every alphabetic token must start upper-case or be in a caseless script (CJK, Hangul, Kana,
|
|
316
|
-
// Arabic); a token that is entirely lower-case Latin is prose. So `
|
|
317
|
-
// `Вензи` and
|
|
316
|
+
// Arabic); a token that is entirely lower-case Latin is prose. So `Driver's Haven`, `HAVEN WORLD`,
|
|
317
|
+
// `Вензи` and `드라이버스 헤이븐` qualify and `national register`, `formative phrases` and
|
|
318
318
|
// `one-swap lead-noun neighbours` do not. A property, not a list of banned words — a description nobody
|
|
319
319
|
// anticipated still fails it.
|
|
320
320
|
function looksLikeName(s) {
|
|
321
321
|
const tokens = String(s).split(/\s+/).filter((t) => /\p{L}/u.test(t));
|
|
322
322
|
if (!tokens.length || tokens.length > 5) return false;
|
|
323
323
|
// A lone two-letter capital is a language or jurisdiction code (ZH, DE, JP), never a mark. Caseless
|
|
324
|
-
// scripts are exempt from the length floor —
|
|
324
|
+
// scripts are exempt from the length floor — 港湾 is a whole word in two characters.
|
|
325
325
|
const cased = tokens.filter((t) => /\p{Lu}|\p{Ll}/u.test(t));
|
|
326
326
|
if (cased.length && cased.every((t) => t.replace(/[^\p{L}]/gu, "").length <= 2)) return false;
|
|
327
327
|
return tokens.every((t) => !/^\p{Ll}/u.test(t.replace(/^[^\p{L}\p{N}]+/u, "")));
|
|
@@ -361,7 +361,7 @@ function extractTerm(raw) {
|
|
|
361
361
|
const asClasses = (nums) => [...new Set(nums.map(Number).filter((n) => Number.isInteger(n) && n >= 1 && n <= 45))];
|
|
362
362
|
|
|
363
363
|
// In a SEARCH TERM, classes are read only where they are LABELLED ("cl 9/28/41/42", "cl.25", "class 35") —
|
|
364
|
-
// never from a bare parenthesised number, which in this corpus is a record COUNT ("
|
|
364
|
+
// never from a bare parenthesised number, which in this corpus is a record COUNT ("HAVEN (424)").
|
|
365
365
|
function extractClasses(s) {
|
|
366
366
|
const m = String(s ?? "").match(/\bcl(?:ass(?:es)?)?\.?\s*([\d]{1,2}(?:\s*[/,]\s*\d{1,2})*)/i);
|
|
367
367
|
return m ? asClasses(m[1].split(/[/,]/)) : [];
|
|
@@ -388,7 +388,7 @@ export function traceKnockout(run, target, events = []) {
|
|
|
388
388
|
kind: "verdict",
|
|
389
389
|
verdict: doc?.batch?.overall ?? runVerdictFromEvents(events) ?? null,
|
|
390
390
|
marks: marksOf(doc).map((m) => ({ mark: m.name, band: m.rating ?? null, basis: m.basis ?? null })),
|
|
391
|
-
note: "A Knockout verdict is the
|
|
391
|
+
note: "A Knockout verdict is the highest rating across the batch's marks; each mark's own rating and the "
|
|
392
392
|
+ "one-sentence ground for it are listed here.",
|
|
393
393
|
});
|
|
394
394
|
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.4.0",
|
|
3
|
+
"version": "0.4.1-beta.0",
|
|
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.",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"test:full": "npm run test"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
17
17
|
"jose": "^6.2.3"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clearotron",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.4.0",
|
|
4
|
+
"version": "0.4.1-beta.0",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"globals": "^17.11.0"
|
|
74
74
|
},
|
|
75
75
|
"dependencies": {
|
|
76
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
76
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
77
77
|
"buffers": "file:vendor/buffers",
|
|
78
78
|
"exceljs": "^4.4.0",
|
|
79
79
|
"jose": "^6.2.3",
|
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.4.0",
|
|
5
|
+
"version": "0.4.1-beta.0",
|
|
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": {
|
|
@@ -112,6 +112,9 @@ export function makeCountProbe(deps) {
|
|
|
112
112
|
return { ok: false, total: null, probe: "cheap",
|
|
113
113
|
reason: "the search response carried no usable total_hits, so nothing was counted — the number is UNKNOWN, which is not the same as none" };
|
|
114
114
|
}
|
|
115
|
-
|
|
115
|
+
// What the total counts rides with it when the register said (a provider's `total_counts`, see
|
|
116
|
+
// enumerate.mjs): a per-class or per-spelling count keeps the register's number and says so.
|
|
117
|
+
return { ok: true, total: parsed.total_hits, probe: "cheap", reason: null,
|
|
118
|
+
...(typeof parsed.total_counts === "string" ? { total_counts: parsed.total_counts } : {}) };
|
|
116
119
|
};
|
|
117
120
|
}
|
|
@@ -220,6 +220,13 @@ export function makeEnumerate(deps) {
|
|
|
220
220
|
// against the tuned ceiling below and can only ever narrow it — `CLEAROTRON_ENUMERATE_CEILING` cannot
|
|
221
221
|
// raise a band past a window the vendor answers with an HTTP 400.
|
|
222
222
|
ceilingFor = null,
|
|
223
|
+
// ── HOW MANY NAMES ONE REQUEST CARRIES, WHEN THAT IS A PROPERTY OF THE QUERY ──────────────────
|
|
224
|
+
//
|
|
225
|
+
// `namesChunkDefault` models a provider with one width for every names band. A register can take a
|
|
226
|
+
// list for one kind of question and not another — signa takes up to 100 exact spellings as one ranked
|
|
227
|
+
// list, and one term per request for everything else. `(params) => number|null`; `null` means the
|
|
228
|
+
// shape declares nothing and the default (or `CLEAROTRON_ENUMERATE_NAMES_CHUNK`) stands.
|
|
229
|
+
namesChunkFor = null,
|
|
223
230
|
} = deps;
|
|
224
231
|
|
|
225
232
|
// ── the I/O seam: a network REJECTION degrades like a 503, it does not abort the stage ────────────
|
|
@@ -288,19 +295,17 @@ export function makeEnumerate(deps) {
|
|
|
288
295
|
// asks a register "how many", so the two can never drift. `null` on any failure is preserved
|
|
289
296
|
// exactly: a term whose probe failed is dispositioned `error`, never `verified-zero`.
|
|
290
297
|
const countHits = makeCountProbe({ search, count, capabilities: { countProbe }, cheapCountParams });
|
|
291
|
-
const probeTermCount = async (auth, params, term, tctx) => {
|
|
292
|
-
const c = await countHits(auth, { ...params, [namesKey]: [term] }, tctx);
|
|
293
|
-
return c.ok ? c.total : null;
|
|
294
|
-
};
|
|
295
298
|
|
|
296
299
|
async function countFirstRescue(auth, params, names, ceiling, stackTotal, tctx, incomplete) {
|
|
297
300
|
const term_counts = {};
|
|
298
301
|
for (const t of names) {
|
|
299
|
-
const
|
|
302
|
+
const probe = await countHits(auth, { ...params, [namesKey]: [t] }, tctx);
|
|
303
|
+
const n = probe.ok ? probe.total : null;
|
|
304
|
+
const said = countsOf(probe); // what this spelling's own count counts, when the register said
|
|
300
305
|
if (n == null) { term_counts[t] = { total_hits: null, disposition: "error" }; continue; }
|
|
301
|
-
if (n === 0) term_counts[t] = { total_hits: 0, disposition: "verified-zero" };
|
|
302
|
-
else if (n > ceiling) term_counts[t] = { total_hits: n, disposition: "crowd" };
|
|
303
|
-
else term_counts[t] = { total_hits: n, disposition: "unenumerated" };
|
|
306
|
+
if (n === 0) term_counts[t] = { total_hits: 0, disposition: "verified-zero", ...said };
|
|
307
|
+
else if (n > ceiling) term_counts[t] = { total_hits: n, disposition: "crowd", ...said };
|
|
308
|
+
else term_counts[t] = { total_hits: n, disposition: "unenumerated", ...said };
|
|
304
309
|
}
|
|
305
310
|
const records = [];
|
|
306
311
|
const tally = { "verified-zero": 0, enumerated: 0, crowd: 0, unenumerated: 0, error: 0 };
|
|
@@ -344,20 +349,21 @@ export function makeEnumerate(deps) {
|
|
|
344
349
|
for (const c of classes) {
|
|
345
350
|
const probe = await countHits(auth, { ...params, nice_classes: [c] }, tctx);
|
|
346
351
|
const n = probe.ok ? probe.total : null;
|
|
352
|
+
const said = countsOf(probe); // what this class's own count counts, when the register said (ruling 712)
|
|
347
353
|
if (n == null) { class_counts[c] = { total_hits: null, disposition: "error" }; continue; }
|
|
348
|
-
if (n === 0) class_counts[c] = { total_hits: 0, disposition: "verified-zero" };
|
|
349
|
-
else if (n > ceiling) class_counts[c] = { total_hits: n, disposition: "crowd" };
|
|
350
|
-
else tractable.push({ c, n });
|
|
354
|
+
if (n === 0) class_counts[c] = { total_hits: 0, disposition: "verified-zero", ...said };
|
|
355
|
+
else if (n > ceiling) class_counts[c] = { total_hits: n, disposition: "crowd", ...said };
|
|
356
|
+
else tractable.push({ c, n, said });
|
|
351
357
|
}
|
|
352
358
|
tractable.sort((a, b) => a.n - b.n); // cheapest first — maximizes fully-enumerated legs under the budget
|
|
353
359
|
const merged = new Map();
|
|
354
|
-
for (const { c, n } of tractable) {
|
|
355
|
-
if (merged.size + n > ceiling) { class_counts[c] = { total_hits: n, disposition: "unenumerated" }; continue; }
|
|
360
|
+
for (const { c, n, said } of tractable) {
|
|
361
|
+
if (merged.size + n > ceiling) { class_counts[c] = { total_hits: n, disposition: "unenumerated", ...said }; continue; }
|
|
356
362
|
const r = await enumerate(auth, { ...params, nice_classes: [c] }, tctx);
|
|
357
363
|
const parsed = isToolError(r) ? null : parseToolText(r);
|
|
358
|
-
if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error" }; continue; }
|
|
364
|
+
if (!parsed) { class_counts[c] = { total_hits: n, disposition: "error", ...said }; continue; }
|
|
359
365
|
if (parsed.state === "enumerated") {
|
|
360
|
-
class_counts[c] = { total_hits: n, disposition: "enumerated" };
|
|
366
|
+
class_counts[c] = { total_hits: n, disposition: "enumerated", ...said };
|
|
361
367
|
for (const rec of (parsed.records ?? [])) {
|
|
362
368
|
const k = recordKeyOf(rec);
|
|
363
369
|
if (!merged.has(k)) merged.set(k, rec);
|
|
@@ -365,7 +371,7 @@ export function makeEnumerate(deps) {
|
|
|
365
371
|
} else {
|
|
366
372
|
// drifted past the probe count (provider re-count) or hit the provider window — honest per-leg state
|
|
367
373
|
const m = parsed.total_hits ?? n;
|
|
368
|
-
class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error" };
|
|
374
|
+
class_counts[c] = { total_hits: m, disposition: m > ceiling ? "crowd" : "error", ...(Number.isFinite(parsed.total_hits) ? countsOf(parsed) : said) };
|
|
369
375
|
}
|
|
370
376
|
}
|
|
371
377
|
const records = [...merged.values()];
|
|
@@ -399,7 +405,7 @@ export function makeEnumerate(deps) {
|
|
|
399
405
|
// half's own reason, as a names window does: a clean never ships over a part that did not run.
|
|
400
406
|
// `region_split` rides the answer, naming the timeout that started it and the size of every part that
|
|
401
407
|
// answered, so a reader can see the question was asked in pieces and where.
|
|
402
|
-
async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause }) {
|
|
408
|
+
async function regionHalves(auth, params, tctx, { ceiling, incomplete, cause, hear = () => {} }) {
|
|
403
409
|
const regions = params.regions;
|
|
404
410
|
const mid = Math.ceil(regions.length / 2);
|
|
405
411
|
const halves = [regions.slice(0, mid), regions.slice(mid)];
|
|
@@ -435,9 +441,10 @@ export function makeEnumerate(deps) {
|
|
|
435
441
|
// A half that timed out while its sibling answered is the one place fewer regions may help: it is
|
|
436
442
|
// halved in turn, under the same rule.
|
|
437
443
|
const r = timedOut({ r: first })
|
|
438
|
-
? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause })
|
|
444
|
+
? await regionHalves(auth, { ...params, regions: half }, tctx, { ceiling, incomplete, cause: parseToolText(first)?.reason ?? cause, hear })
|
|
439
445
|
: first;
|
|
440
446
|
const parsed = isToolError(r) ? null : parseToolText(r);
|
|
447
|
+
hear(parsed);
|
|
441
448
|
const which = `the ${half.length}-region half ${half[0]}${half.length > 1 ? `…${half[half.length - 1]}` : ""}`;
|
|
442
449
|
// A stall from a deeper split keeps its own reason, stall first, so the executor reads it however
|
|
443
450
|
// deep the split went and however short the reason is later cut.
|
|
@@ -464,8 +471,22 @@ export function makeEnumerate(deps) {
|
|
|
464
471
|
...(screenLift ? { screen_lift: screenLift } : {}), region_split: { cause, parts } }, null, 2) };
|
|
465
472
|
}
|
|
466
473
|
|
|
467
|
-
|
|
474
|
+
// ── WHAT THE TOTAL COUNTS, STATED ONCE FOR THE WHOLE ANSWER ────────────────────────────────────────
|
|
475
|
+
//
|
|
476
|
+
// A register can say that a total counts records, one per country a mark covers, rather than marks
|
|
477
|
+
// (`total_counts` on a provider's parsed answer). The answer to the question states it, whichever of the
|
|
478
|
+
// exits below produced it: the page loop, the name windows, the region halves and the rescues each tell
|
|
479
|
+
// the question what they heard, and it is stamped here, on the one way out. Recorded only: the ceiling
|
|
480
|
+
// still reads the register's own number (ruled 2026-10-02). An answer with no `total_counts` is one whose
|
|
481
|
+
// register said nothing about it, never a count of marks.
|
|
482
|
+
async function enumerate(auth, params, tctx, opts = {}) {
|
|
483
|
+
const said = { totalCounts: null };
|
|
484
|
+
return stampTotalCounts(await enumerateQuestion(auth, params, tctx, opts, said), said.totalCounts);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function enumerateQuestion(auth, params, tctx, { split = true } = {}, said = { totalCounts: null }) {
|
|
468
488
|
if (!hasAnyElement(params)) return { type: "text", text: missingElementError };
|
|
489
|
+
const hear = (p) => { if (typeof p?.total_counts === "string") said.totalCounts = p.total_counts; };
|
|
469
490
|
|
|
470
491
|
// The tuned ceiling, then narrowed by any window the QUERY SHAPE imposes. Min, never max:
|
|
471
492
|
// a shape window is a vendor limit, and a band that pages past it does not return more records, it
|
|
@@ -499,7 +520,9 @@ export function makeEnumerate(deps) {
|
|
|
499
520
|
// descriptor total — `count` is the deduped truth); the resource ceiling applies to the RUNNING total
|
|
500
521
|
// so a crowd still returns an incomplete descriptor. ANY chunk error/incomplete makes the WHOLE slice
|
|
501
522
|
// incomplete — a clean can never ship over a partially-executed slice.
|
|
502
|
-
const
|
|
523
|
+
const shapeChunk = typeof namesChunkFor === "function" ? namesChunkFor(params) : null;
|
|
524
|
+
const namesChunk = Number.isFinite(shapeChunk) && shapeChunk >= 1
|
|
525
|
+
? Math.floor(shapeChunk) : envInt("CLEAROTRON_ENUMERATE_NAMES_CHUNK", namesChunkDefault);
|
|
503
526
|
const allNames = Array.isArray(params[namesKey]) ? params[namesKey].filter(Boolean) : null;
|
|
504
527
|
if (allNames && allNames.length > namesChunk) {
|
|
505
528
|
const merged = new Map();
|
|
@@ -518,6 +541,7 @@ export function makeEnumerate(deps) {
|
|
|
518
541
|
const part = allNames.slice(i * namesChunk, (i + 1) * namesChunk);
|
|
519
542
|
const r = await enumerate(auth, { ...params, [namesKey]: part }, tctx);
|
|
520
543
|
const parsed = parseToolText(r);
|
|
544
|
+
hear(parsed);
|
|
521
545
|
if (isToolError(r) || !parsed) {
|
|
522
546
|
return incomplete(totalSum, merged.size, [...merged.values()],
|
|
523
547
|
`chunk ${i + 1}/${chunks} (${part.length} names) failed: ${String(r?.text ?? "unparseable").slice(0, 140)}`,
|
|
@@ -599,10 +623,11 @@ export function makeEnumerate(deps) {
|
|
|
599
623
|
+ `Provider's own words: ${clipProviderText(c?.reason ?? "", COUNT_PROBE_BUDGET)}`,
|
|
600
624
|
{ crowd_basis: "provider-refused-count", count_unavailable: true });
|
|
601
625
|
}
|
|
602
|
-
if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason) });
|
|
626
|
+
if (splittable && isGatewayTimeout(c?.reason)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(c.reason), hear });
|
|
603
627
|
return incomplete(0, 0, [],
|
|
604
628
|
`provider error on the count probe before enumeration: ${String(c?.reason ?? "count unavailable")}`, timeoutMark(c?.reason));
|
|
605
629
|
}
|
|
630
|
+
hear(c);
|
|
606
631
|
total = Number.isFinite(c.total) ? c.total : 0;
|
|
607
632
|
probeTotal = total;
|
|
608
633
|
if (total > ceiling) {
|
|
@@ -625,11 +650,12 @@ export function makeEnumerate(deps) {
|
|
|
625
650
|
for (let page = 0; ; page += 1) {
|
|
626
651
|
const r = await search(auth, { ...params, ...pageParams(page, pageSize, prevParsed) }, tctx);
|
|
627
652
|
if (isToolError(r)) {
|
|
628
|
-
if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text) });
|
|
653
|
+
if (splittable && isGatewayTimeout(r.text)) return regionHalves(auth, params, tctx, { ceiling, incomplete, cause: String(r.text), hear });
|
|
629
654
|
return incomplete(total, results.length, results, `provider error during enumeration (page ${page}): ${String(r.text ?? "")}`, timeoutMark(r.text));
|
|
630
655
|
}
|
|
631
656
|
const parsed = parseToolText(r);
|
|
632
657
|
if (!parsed) return incomplete(total, results.length, results, `unparseable search response during enumeration (page ${page})`);
|
|
658
|
+
hear(parsed);
|
|
633
659
|
// ── the number the completeness claim rests on must BE a number ────────────────────────────────
|
|
634
660
|
// On the "cheap" seam the page response IS the count — the ceiling is tested off `total_hits` and
|
|
635
661
|
// nothing else. A response that parsed but carries no usable total therefore cannot support a
|
|
@@ -811,3 +837,16 @@ export function makeEnumerate(deps) {
|
|
|
811
837
|
|
|
812
838
|
return { enumerate, countFirstRescue, classSplitRescue };
|
|
813
839
|
}
|
|
840
|
+
|
|
841
|
+
/** `{ total_counts }` from a probe or a parsed answer that carries it, else nothing to spread. PURE. */
|
|
842
|
+
function countsOf(p) {
|
|
843
|
+
return typeof p?.total_counts === "string" ? { total_counts: p.total_counts } : {};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** The answer with what its total counts stated on it; an error, or nothing heard, passes unchanged. */
|
|
847
|
+
function stampTotalCounts(r, totalCounts) {
|
|
848
|
+
if (!totalCounts || isToolError(r)) return r;
|
|
849
|
+
const parsed = parseToolText(r);
|
|
850
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return r;
|
|
851
|
+
return { ...r, text: JSON.stringify({ ...parsed, total_counts: totalCounts }, null, 2) };
|
|
852
|
+
}
|
|
@@ -509,6 +509,8 @@ export function makeExecutePlan(deps) {
|
|
|
509
509
|
// the resolution the sweep actually ran on, verbatim — a reader must be able to see WHICH
|
|
510
510
|
// applicant styling was asked for, on a verified zero as much as on an unverified one.
|
|
511
511
|
...(parsed?.owner_resolution ? { owner_resolution: parsed.owner_resolution } : {}),
|
|
512
|
+
// what the counted total counts, when the register said (enumerate.mjs, "what the total counts")
|
|
513
|
+
...(counted && typeof parsed?.total_counts === "string" ? { total_counts: parsed.total_counts } : {}),
|
|
512
514
|
reason: ownerUnverified ? unresolvedOwnerCountReason(e.term ?? (e.terms ?? [])[0], coveredBy)
|
|
513
515
|
: parsed ? descriptorReason
|
|
514
516
|
: `provider error on the count probe (after one in-tool retry): ${clipProviderText(r?.text ?? "", 100)}` });
|
|
@@ -38,7 +38,7 @@ export const CAPABILITIES = Object.freeze({
|
|
|
38
38
|
countStatusFilter: "none",
|
|
39
39
|
// HTTP-414 URI guard: the whole query is a GET query string, so ~80 backtick-quoted names (≈2–4KB
|
|
40
40
|
// encoded) is the safe OR-stack. This MIRRORS the executor's ENUMERATE_NAMES_CHUNK_DEFAULT — the
|
|
41
|
-
// planner must never dictate an OR-stack the executor would have to chunk-rescue (
|
|
41
|
+
// planner must never dictate an OR-stack the executor would have to chunk-rescue (Open Country 414).
|
|
42
42
|
maxOrWidth: 80,
|
|
43
43
|
// `nice-class` clauses ride the same query string (implicit OR within the field) — one call, no fan-out.
|
|
44
44
|
classFilter: "native",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.4.0",
|
|
3
|
+
"version": "0.4.1-beta.0",
|
|
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).",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "bridge.mjs",
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
10
|
+
"@modelcontextprotocol/sdk": "^1.31.0"
|
|
11
11
|
},
|
|
12
12
|
"engines": { "node": ">=22.13.0" }
|
|
13
13
|
}
|
|
@@ -120,9 +120,24 @@ export const CAPABILITIES = Object.freeze({
|
|
|
120
120
|
// NOTE the key. `filters.status` does not exist and the API rejects unknown filter keys outright
|
|
121
121
|
// (`HTTP 400 Unrecognized key: status`) — which is what core.js was sending until this issue.
|
|
122
122
|
countStatusFilter: "native",
|
|
123
|
-
// The request carries ONE `query` string
|
|
124
|
-
//
|
|
123
|
+
// The request carries ONE `query` string for every predicate but one, so the general OR width stays 1:
|
|
124
|
+
// a phonetic, wildcard or owner band is one call per term, and anything that reads this width as
|
|
125
|
+
// "no OR at all" (the supplemental batch's one-question-per-name split) keeps reading it that way.
|
|
125
126
|
maxOrWidth: 1,
|
|
127
|
+
// ── THE ONE PREDICATE THAT TAKES A LIST: exact spellings, up to 100 per request ─────────────────
|
|
128
|
+
//
|
|
129
|
+
// A list in `q` is a ranked search over every spelling through the channels the request names, and
|
|
130
|
+
// each record says which spellings found it (`match.terms`). The register takes up to 100 spellings
|
|
131
|
+
// when the channels are only `identical` and `lookalike` — which is exactly what an exact question
|
|
132
|
+
// sends — and 10 otherwise. Measured live against one search per spelling, by record id and paged to
|
|
133
|
+
// exhaustion: the same records on the exact band at 32 and at 100 spellings, look-alike-only records
|
|
134
|
+
// included, with every record's `match.terms` naming exactly the spellings that found it alone. The
|
|
135
|
+
// figures are on the tracker. The one documented difference — a list skips the word-by-word fuzzy
|
|
136
|
+
// retry — belongs to a channel the exact question does not send.
|
|
137
|
+
//
|
|
138
|
+
// So the planner stacks an exact spelling band at this width, and the kernel sends each stack as one
|
|
139
|
+
// request (`namesChunkFor` in core.js); every other predicate stays at `maxOrWidth`.
|
|
140
|
+
exactOrWidth: 100,
|
|
126
141
|
// filters.nice_classes[] is a top-level OR filter — one call, no fan-out.
|
|
127
142
|
classFilter: "native",
|
|
128
143
|
// ── THE GOODS-AND-SERVICES TEXT FILTER ──────────────────────────────────────────────────────────
|