clearotron 0.4.0-beta.0 → 0.4.0-beta.2
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 +9 -0
- package/INSTALL.md +4 -31
- package/README.md +5 -5
- package/bin/brandowner.mjs +1 -1
- package/bin/onboard.mjs +18 -74
- package/bin/start.mjs +58 -14
- package/bin/update.mjs +10 -2
- package/build-info.json +2 -2
- package/demo/full-country-search/run/_driver/search-policy.json +1 -1
- package/demo/full-country-search/run/status.json +1 -1
- package/demo/global-preliminary-search/run/_driver/search-policy.json +1 -1
- package/demo/global-preliminary-search/run/status.json +1 -1
- package/demo/knockout-search/run/_driver/search-policy.json +1 -1
- package/demo/knockout-search/run/status.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/search-policy.json +1 -1
- package/demo/multi-country-focus-search/run/status.json +1 -1
- package/docs/README.md +1 -0
- package/docs/SECURITY-OWASP.md +298 -0
- package/docs/SECURITY.md +18 -13
- package/docs/architecture/02-architecture.md +5 -5
- package/docs/architecture/03-run-lifecycle.md +28 -29
- package/docs/architecture/04-configuration-reference.md +5 -10
- package/docs/architecture/05-config-governance.md +5 -7
- package/docs/architecture/06-operations-runbook.md +0 -12
- package/docs/architecture/07-quality-and-audit.md +12 -13
- package/docs/architecture/09-security-and-data.md +9 -4
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +4 -2
- package/driver/authority-trees.mjs +90 -8
- package/driver/band-shape.mjs +13 -13
- package/driver/blind-frame-model.mjs +1 -1
- package/driver/case-law-ledger.mjs +19 -3
- package/driver/claim-liveness.mjs +30 -1
- package/driver/clearance-variants-record.mjs +10 -3
- package/driver/common-law-receipts.mjs +64 -14
- package/driver/commonlaw-carry.mjs +2 -2
- package/driver/compare.mjs +2 -2
- package/driver/connotation-search.mjs +33 -104
- package/driver/contract-arm2-baseline.json +6 -5
- package/driver/contract-e3-backlog.mjs +62 -62
- package/driver/contract-vocabulary.mjs +38 -31
- package/driver/coverage-form-io.mjs +13 -1
- package/driver/coverage-form.mjs +42 -4
- package/driver/coverage-ledger.mjs +3 -5
- package/driver/cross-check-wait.mjs +43 -0
- package/driver/declination-call.mjs +34 -10
- package/driver/declination-tool.mjs +27 -15
- package/driver/deferral-row.mjs +139 -0
- package/driver/degraded-parts.mjs +377 -0
- package/driver/deliver-trigger.sh +1 -1
- package/driver/demo-container.mjs +4 -1
- package/driver/dev-portal.mjs +5 -5
- package/driver/dispatch-record.mjs +2 -2
- package/driver/driver.config.mjs +193 -96
- package/driver/e2e/README.md +1 -1
- package/driver/effective-scope.mjs +58 -2
- package/driver/engine/anthropic-agent.mjs +103 -27
- package/driver/engine/auth.mjs +2 -2
- package/driver/engine/child-record.mjs +11 -0
- package/driver/engine/cli-version.mjs +4 -3
- package/driver/engine/common.mjs +17 -2
- package/driver/engine/deny-authority-write.mjs +3 -1
- package/driver/engine/engine-env.mjs +174 -0
- package/driver/engine/engine-spawn.mjs +179 -0
- package/driver/engine/mcp/band-server.mjs +1 -1
- package/driver/engine/mcp/clarivate-server.mjs +2 -1
- package/driver/engine/mcp/codex-config.mjs +112 -3
- package/driver/engine/mcp/corsearch-server.mjs +1 -0
- package/driver/engine/mcp/declination-server.mjs +9 -8
- package/driver/engine/mcp/dispositions-server.mjs +2 -1
- package/driver/engine/mcp/euipo-server.mjs +1 -0
- package/driver/engine/mcp/fetch-server.mjs +14 -11
- package/driver/engine/mcp/free-tier-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +23 -11
- package/driver/engine/mcp/perplexity-server.mjs +97 -17
- package/driver/engine/mcp/probe-server.mjs +43 -21
- package/driver/engine/mcp/public-fetch.mjs +195 -0
- package/driver/engine/mcp/recording-server.mjs +79 -14
- package/driver/engine/mcp/signa-server.mjs +1 -0
- package/driver/engine/mcp/supplemental.mjs +14 -14
- package/driver/engine/mcp/unit-note-server.mjs +50 -1
- package/driver/engine/mcp/uspto-local-server.mjs +1 -0
- package/driver/engine/openai-agent.mjs +109 -28
- package/driver/engine/probe.mjs +152 -19
- package/driver/enqueue-schema.mjs +5 -5
- package/driver/feedback-issues.mjs +1 -1
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +67 -16
- package/driver/flag-snapshot.mjs +1 -1
- package/driver/floor-duty.mjs +23 -7
- package/driver/form-neighbourhood.mjs +52 -17
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/framework-method.mjs +304 -0
- package/driver/gateway.mjs +49 -21
- package/driver/hand-off-exits.mjs +129 -0
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +8 -5
- package/driver/knockout-assess-record.mjs +7 -1
- package/driver/knockout-frame-record.mjs +96 -34
- package/driver/knockout-review-record.mjs +7 -2
- package/driver/log.mjs +2 -2
- package/driver/matter-frame-record.mjs +31 -2
- package/driver/methodology-witness.mjs +6 -3
- package/driver/named-band.mjs +1 -1
- package/driver/owner-use-check.mjs +45 -10
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +4 -0
- package/driver/phase0.mjs +2 -2
- package/driver/pipeline-knockout.mjs +256 -129
- package/driver/pipeline.mjs +417 -614
- package/driver/placement-carry.mjs +22 -3
- package/driver/placement-form-io.mjs +18 -4
- package/driver/placement-form.mjs +53 -4
- package/driver/placement-union.mjs +32 -5
- package/driver/portal-report.mjs +3 -3
- package/driver/portal-service.mjs +6 -5
- package/driver/portal-static.mjs +11 -18
- package/driver/predelivery-lint.mjs +10 -10
- package/driver/profile-page.html +5 -5
- package/driver/profiles.mjs +5 -5
- package/driver/progress.mjs +6 -4
- package/driver/provider-usage.mjs +25 -2
- package/driver/publish/index.mjs +97 -25
- package/driver/publish/knockout.mjs +136 -14
- package/driver/publish/profiles-page.mjs +2 -0
- package/driver/publish/publish-inputs.mjs +10 -4
- package/driver/publish/render-knockout.mjs +48 -21
- package/driver/publish/render.mjs +13 -8
- package/driver/publish/report-data.mjs +2 -0
- package/driver/publish/search-depth.mjs +77 -4
- package/driver/publish/templates/report.css +1 -1
- package/driver/publish/xlsx.mjs +34 -15
- package/driver/reasoning-tripwires.mjs +2 -160
- package/driver/recall-receipt.mjs +27 -0
- package/driver/recall-reconciliation.mjs +1 -1
- package/driver/record-carry.mjs +14 -14
- package/driver/recording-agreement.mjs +2 -2
- package/driver/reference-score.mjs +169 -47
- package/driver/register-count.mjs +61 -4
- package/driver/register-digest-record.mjs +14 -1
- package/driver/register-plan.mjs +161 -80
- package/driver/register-records.mjs +37 -4
- package/driver/registration-scripts.mjs +22 -0
- package/driver/registry-fidelity.mjs +4 -4
- package/driver/repair-composers.mjs +23 -7
- package/driver/repair-contract.mjs +1 -1
- package/driver/repairs.mjs +5 -2
- package/driver/reviewer-open-points.mjs +33 -0
- package/driver/rule-shape.mjs +8 -8
- package/driver/run-economics.mjs +3 -3
- package/driver/run-integrity.mjs +2 -2
- package/driver/runner.mjs +17 -5
- package/driver/scope-facts.mjs +89 -12
- package/driver/score-redaction.mjs +247 -0
- package/driver/screen-gate.mjs +1 -1
- package/driver/search-policy.mjs +17 -0
- package/driver/skills/README.md +2 -2
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/SKILL.md +29 -36
- package/driver/skills/clearance-common-law/perplexity-prompts.md +8 -30
- package/driver/skills/clearance-register/SKILL.md +15 -16
- package/driver/skills/clearance-register/digest.md +15 -31
- package/driver/skills/clearance-register/register-recipes.md +14 -56
- package/driver/skills/clearance-register/unit.md +37 -48
- package/driver/skills/clearance-search/SKILL.md +6 -8
- package/driver/skills/clearance-search/delivery-contract.md +0 -7
- package/driver/skills/clearance-search/firm-wide-reasoning.md +5 -4
- package/driver/skills/clearance-search/phase2-execution.md +5 -6
- package/driver/skills/clearance-search/report-prose.md +5 -5
- package/driver/skills/clearance-search/risk-framework-triage.md +2 -2
- package/driver/skills/clearance-search/synthesis-rules.md +9 -9
- package/driver/skills/clearance-search/template-formatting.md +2 -2
- package/driver/skills/clearance-variants/SKILL.md +8 -8
- package/driver/skills/clearance-variants/transliteration-scripts.md +2 -2
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +40 -10
- package/driver/skills/knockout-frame/SKILL.md +42 -10
- package/driver/skills/matter-frame/SKILL.md +12 -5
- package/driver/skills/matter-frame/watchlist-reference.md +1 -1
- package/driver/skills/narrative-refutation/SKILL.md +5 -3
- package/driver/skills/placement-inquiry/SKILL.md +5 -1
- package/driver/stage-context.mjs +27 -6
- package/driver/stages-knockout.mjs +92 -79
- package/driver/stages.mjs +111 -68
- package/driver/stray-artifacts.mjs +9 -5
- package/driver/suite-census.json +696 -150
- package/driver/synthesis-record.mjs +26 -4
- package/driver/systemd/README.md +5 -6
- package/driver/systemd/clearotron-client-mcp.service +24 -0
- package/driver/systemd/clearotron-mcp-face.service +24 -0
- package/driver/systemd/clearotron-portal.service +24 -0
- package/driver/systemd/clearotron-worker.service +29 -0
- package/driver/tokens.mjs +10 -9
- package/driver/turnaround-bands.mjs +1 -1
- package/driver/unit-inventory.mjs +24 -43
- package/driver/variant-manifest-model.mjs +22 -3
- package/driver/verify-knockout.mjs +163 -4
- package/driver/verify.mjs +42 -16
- package/driver/web-grid.mjs +150 -0
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/withheld-families.mjs +113 -4
- package/driver/worker-heartbeat.mjs +13 -2
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/README.md +1 -1
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +10 -10
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/trace.mjs +3 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +3 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-DVtz44vH.js → index-BPAUjcI0.js} +3 -2
- package/portal-ui/dist/assets/{index-5CCwiJG7.css → index-D2wrw9cH.css} +18 -2
- package/portal-ui/dist/assets/plus-jakarta-sans-BUCHxqJ-.woff2 +0 -0
- package/portal-ui/dist/index.html +2 -15
- package/portal-ui/package.json +1 -1
- package/providers/_shared/README.md +1 -1
- package/providers/_shared/answer-memory.mjs +199 -0
- package/providers/_shared/enumerate.mjs +155 -50
- package/providers/_shared/execute-plan.mjs +62 -16
- package/providers/_shared/ledger-path.mjs +1 -1
- package/providers/_shared/ledger.mjs +48 -6
- package/providers/_shared/plan-guards.mjs +7 -0
- package/providers/_shared/script-form.mjs +24 -5
- package/providers/_shared/term-shape.mjs +5 -5
- package/providers/clarivate/src/capabilities.js +11 -0
- package/providers/clarivate/src/core.js +193 -18
- package/providers/corsearch/README.md +1 -2
- package/providers/corsearch/src/core.js +2 -2
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/README.md +8 -7
- package/providers/oauth-mcp-bridge/bridge.mjs +21 -5
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/providers/oauth-mcp-bridge/warm-server.mjs +16 -5
- package/providers/perplexity/src/core.js +246 -21
- package/providers/signa/src/capabilities.js +24 -0
- package/providers/signa/src/core.js +93 -25
- package/scripts/README.md +2 -1
- package/scripts/added-reference-check.mjs +2 -1
- package/scripts/backup-recall-stores.mjs +5 -5
- package/scripts/demo-evidence.mjs +2 -1
- package/scripts/deprecate-below.mjs +114 -2
- package/scripts/drive-env-check.mjs +2 -1
- package/scripts/e2e-first-time.mjs +469 -0
- package/scripts/e2e-scenario-ops.mjs +441 -0
- package/scripts/e2e.mjs +104 -16
- package/scripts/env-audit.mjs +8 -0
- package/scripts/freeze-example-run.mjs +7 -7
- package/scripts/generated-files-are-current.mjs +2 -1
- package/scripts/hand-off-exits-probe.mjs +75 -0
- package/scripts/import-cycle-check.mjs +2 -1
- package/scripts/merge-shape-check.mjs +2 -1
- package/scripts/package-size-budget.mjs +2 -1
- package/scripts/record-carry-probe.mjs +1 -1
- package/scripts/release-entry-catch-up.mjs +77 -8
- package/scripts/release-install-check.mjs +4 -1
- package/scripts/release-note-required.mjs +139 -9
- package/scripts/release-rehearsal-version.mjs +60 -0
- package/scripts/release-sbom.mjs +104 -0
- package/scripts/release-visible-check.mjs +7 -5
- package/scripts/render-check.mjs +5 -2
- package/scripts/report-offline-render-check.mjs +169 -0
- package/scripts/score.mjs +79 -8
- package/scripts/strip-titles-and-attributions.mjs +2 -1
- package/scripts/strip-tracker-citations.mjs +2 -1
- package/scripts/test-full.mjs +2 -1
- package/scripts/test-run.mjs +10 -3
- package/scripts/third-party-notices.mjs +3 -1
- package/scripts/travelling-predicates.mjs +1 -1
- package/scripts/writing-standard-check.mjs +2 -1
- package/shared/brand-fonts.mjs +59 -0
- package/shared/brand.mjs +6 -6
- package/shared/browser-temp-root.mjs +3 -2
- package/shared/doctrine-overlay.mjs +1 -1
- package/shared/driver-dir.mjs +47 -11
- package/shared/fonts/OFL-fira-code.txt +93 -0
- package/shared/fonts/OFL-plus-jakarta-sans.txt +93 -0
- package/shared/fonts/README.md +37 -0
- package/shared/fonts/fira-code.woff2 +0 -0
- package/shared/fonts/plus-jakarta-sans.woff2 +0 -0
- package/shared/names-in-force.mjs +2 -0
- package/shared/npm-cli.mjs +23 -0
- package/shared/os-advice.mjs +14 -2
- package/shared/path-seps.mjs +42 -0
- package/shared/process-table.mjs +71 -3
- package/shared/reference-guard-classes.mjs +30 -6
- package/shared/root-doc-commands.mjs +6 -2
- package/shared/running-start.mjs +24 -7
- package/shared/scope.mjs +2 -2
- package/shared/wsl.mjs +1 -1
- package/driver/known-conflicts.mjs +0 -327
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
// `register-records.jsonl`; providers/_shared/ledger-path.mjs still resolves the older
|
|
32
32
|
// `corsearch-*` names on a box that already has them.)
|
|
33
33
|
|
|
34
|
-
import { makeLedger } from "../../_shared/ledger.mjs";
|
|
34
|
+
import { makeLedger, heldRecordBodies } from "../../_shared/ledger.mjs";
|
|
35
|
+
import { answerKey, compareAnswers, forgetAnswer, noteAnswer, openAnswerMemory, recallAnswer, rememberAnswer } from "../../_shared/answer-memory.mjs";
|
|
35
36
|
import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_shared/http-body.mjs";
|
|
36
37
|
import {
|
|
37
38
|
BATCH_SCREEN_CHUNK, chunk, isAllClass, makeClassifyStatus, screenVerdict,
|
|
@@ -76,13 +77,36 @@ export function refToOffice(ref) {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
// ── HTTP helper (with the metered chokepoint) ──────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
+
//
|
|
81
|
+
// THE RUN MEMORY (providers/_shared/answer-memory.mjs) answers an identical count, search or owner lookup
|
|
82
|
+
// from what this run already asked: the question is the base address, the path and the whole body, never
|
|
83
|
+
// the key. Records are held by id instead (heldRecords, below), because a record is the same whichever
|
|
84
|
+
// search found it. Images and filing dates are not repeated in a run and go to the register every time.
|
|
85
|
+
// With no memory for the run, this is the plain fetch it always was.
|
|
86
|
+
//
|
|
87
|
+
// `ledgerExtra` rides on the call ledger's row: a /text call names the record ids it fetched, which is
|
|
88
|
+
// what lets a run's usage count a record fetched twice (driver/provider-usage.mjs).
|
|
89
|
+
const REMEMBERED_PATHS = new Set(["/count", "/search", "/resolution/company"]);
|
|
90
|
+
|
|
91
|
+
export async function clarivateFetch(apiKey, base, path, { body = null, method = "POST", retries = 1, tctx = null, ledgerExtra = null } = {}) {
|
|
80
92
|
const url = `${base}${path}`;
|
|
81
93
|
const headers = { "X-ApiKey": apiKey, "Accept": "application/json" };
|
|
82
94
|
const init = { method, headers };
|
|
83
95
|
if (body !== null) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
|
|
84
96
|
|
|
85
97
|
const t0 = Date.now();
|
|
98
|
+
const mem = REMEMBERED_PATHS.has(path) ? openAnswerMemory(tctx?.recordLog) : null;
|
|
99
|
+
const key = mem ? answerKey({ base, method, path, body }) : null;
|
|
100
|
+
const held = mem ? recallAnswer(mem, key) : null;
|
|
101
|
+
const note = (row) => noteAnswer(mem, { ts: new Date().toISOString(), mode: mem.mode, key, method, path, tool: tctx?.kind ?? null,
|
|
102
|
+
via: tctx?.recordLog ? "driver" : "tool-server", session: tctx?.sessionKey ?? null, ...row });
|
|
103
|
+
if (held && mem.mode === "on") {
|
|
104
|
+
const { body: parsed, parseError } = parseJsonBody(held.raw);
|
|
105
|
+
const ok = held.status >= 200 && held.status < 300;
|
|
106
|
+
logCall(tctx, { http_status: held.status, ok, attempts: 0, took_ms: Date.now() - t0, bytes: held.raw.length, cache_hit: true });
|
|
107
|
+
note({ held: true, served: true, status: held.status });
|
|
108
|
+
return { status: held.status, ok, url, body: parsed, raw: held.raw, parseError };
|
|
109
|
+
}
|
|
86
110
|
let attempts = 0;
|
|
87
111
|
let resp;
|
|
88
112
|
try {
|
|
@@ -96,17 +120,91 @@ export async function clarivateFetch(apiKey, base, path, { body = null, method =
|
|
|
96
120
|
await new Promise((r) => setTimeout(r, 1500 * (i + 1)));
|
|
97
121
|
}
|
|
98
122
|
} catch (err) {
|
|
99
|
-
logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false });
|
|
123
|
+
logCall(tctx, { http_status: 0, ok: false, attempts, took_ms: Date.now() - t0, bytes: 0, cache_hit: false, ...(ledgerExtra ?? {}) });
|
|
124
|
+
if (mem) note({ held: Boolean(held), status: 0 });
|
|
100
125
|
throw err;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
const raw = await resp.text();
|
|
104
129
|
// The parse failure travels on `parseError` instead of being swallowed — providers/_shared/http-body.mjs.
|
|
105
130
|
const { body: parsed, parseError } = parseJsonBody(raw);
|
|
106
|
-
|
|
131
|
+
// A FAILED CALL'S BODY IS KEPT WHOLE on its row. Everything downstream shortens it — the error text
|
|
132
|
+
// quotes 200 characters of it and a band's reason 240 — and a gateway's error body is often the only
|
|
133
|
+
// sentence that says what happened. A successful body is never written here: the record ledger holds
|
|
134
|
+
// records, and this row is the call's.
|
|
135
|
+
logCall(tctx, { http_status: resp.status, ok: resp.ok, attempts, took_ms: Date.now() - t0, bytes: raw.length, cache_hit: false, ...(ledgerExtra ?? {}),
|
|
136
|
+
...(resp.ok ? {} : { error_body: raw }) });
|
|
137
|
+
if (mem) {
|
|
138
|
+
const summary = rememberableAnswer(path, resp.status, parsed, parseError);
|
|
139
|
+
const row = { held: Boolean(held), status: resp.status, rememberable: Boolean(summary) };
|
|
140
|
+
if (held && summary) Object.assign(row, compareAnswers(held.summary, summary));
|
|
141
|
+
if (summary && !held) row.stored = rememberAnswer(mem, key, { status: resp.status, raw, summary });
|
|
142
|
+
note(row);
|
|
143
|
+
}
|
|
107
144
|
return { status: resp.status, ok: resp.ok, url, body: parsed, raw, parseError };
|
|
108
145
|
}
|
|
109
146
|
|
|
147
|
+
/**
|
|
148
|
+
* What the run memory may keep from a count, a search or an owner lookup, as the short summary a watch
|
|
149
|
+
* compares — or null when the question must be asked again next time.
|
|
150
|
+
*
|
|
151
|
+
* KEPT: a 200 that parsed into the answer its path gives — `counts{}` for /count, `ids{}` for /search
|
|
152
|
+
* (isSearchResponseBody), `companies[]` for /resolution/company.
|
|
153
|
+
*
|
|
154
|
+
* NEVER KEPT: anything else, the refusals included — a result past the ceiling, a query the service
|
|
155
|
+
* refused as written, an error envelope served with a 200, a body that did not parse. Those are the
|
|
156
|
+
* answers this core reports as failures, and remembering one would turn a passing fault into the run's
|
|
157
|
+
* permanent answer. No answer here points at a next page: /search returns the whole set at once.
|
|
158
|
+
*/
|
|
159
|
+
export function rememberableAnswer(path, status, body, parseError) {
|
|
160
|
+
if (status !== 200 || parseError || body == null || typeof body !== "object" || Array.isArray(body)) return null;
|
|
161
|
+
if (path === "/count") {
|
|
162
|
+
if (!body.counts || typeof body.counts !== "object" || Array.isArray(body.counts)) return null;
|
|
163
|
+
const entries = Object.entries(body.counts).filter(([, n]) => Number.isFinite(Number(n)));
|
|
164
|
+
return { total: entries.reduce((t, [, n]) => t + Number(n), 0), ids: entries.map(([o, n]) => `${o}:${Number(n)}`).sort(), next_page: false };
|
|
165
|
+
}
|
|
166
|
+
if (path === "/search") {
|
|
167
|
+
if (!isSearchResponseBody(body)) return null;
|
|
168
|
+
const ids = Object.entries(body.ids).flatMap(([o, gs]) => (Array.isArray(gs) ? gs : []).map((g) => `${o}/${g}`));
|
|
169
|
+
return { total: ids.length, ids, next_page: false };
|
|
170
|
+
}
|
|
171
|
+
if (path === "/resolution/company") {
|
|
172
|
+
if (!Array.isArray(body.companies)) return null;
|
|
173
|
+
const ids = body.companies.map((c) => `${c?.applicantName ?? ""}|${c?.registrationOfficeCode ?? ""}|${c?.confidenceScore ?? ""}`);
|
|
174
|
+
return { total: ids.length, ids, next_page: false };
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* THE RECORDS THIS RUN ALREADY HOLDS, by id, for a /text call about to be made — Map<lowercased guid, raw
|
|
181
|
+
* record>. Read off the run's record log, which every process that fetches a record for the run writes
|
|
182
|
+
* to (providers/_shared/ledger.mjs heldRecordBodies), and only while the run memory is `on`.
|
|
183
|
+
*
|
|
184
|
+
* The raw vendor record is what comes back, not the normalized one stored beside it, so the caller runs
|
|
185
|
+
* it through exactly the normalization and screening a fetched record gets, with this request's own office
|
|
186
|
+
* hint. A held record therefore yields the row a fresh fetch would, and a record whose office segment
|
|
187
|
+
* differs between two searches is addressed the way this search addresses it.
|
|
188
|
+
*
|
|
189
|
+
* Why no watch period (the owner's ruling): across 38 past runs that fetched the same record twice, every
|
|
190
|
+
* second copy was byte-identical apart from the order of one list inside the vendor's raw record, and
|
|
191
|
+
* every normalized field matched.
|
|
192
|
+
*/
|
|
193
|
+
function heldRecords(tctx, guids) {
|
|
194
|
+
const out = new Map();
|
|
195
|
+
if (openAnswerMemory(tctx?.recordLog)?.mode !== "on") return out;
|
|
196
|
+
const log = typeof tctx?.recordLog === "string" && tctx.recordLog.trim()
|
|
197
|
+
? tctx.recordLog.trim() : String(process.env.CLEAROTRON_REGISTER_RECORD_LOG ?? "").trim();
|
|
198
|
+
for (const [g, body] of heldRecordBodies(log, guids)) if (body?._raw && typeof body._raw === "object") out.set(g, body._raw);
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The one ledger row for records served from the run's own store: no call made, none paid for. */
|
|
203
|
+
function logHeldRecords(tctx, held) {
|
|
204
|
+
if (!held.size) return;
|
|
205
|
+
logCall({ ...tctx, target: `held:${held.size}` }, { http_status: 200, ok: true, attempts: 0, took_ms: 0, bytes: 0, cache_hit: true, records: [...held.keys()] });
|
|
206
|
+
}
|
|
207
|
+
|
|
110
208
|
const errText = (r) => r?.body?.errorMessage ?? r?.body?.message ?? (r?.raw ? String(r.raw).slice(0, 200) : "");
|
|
111
209
|
|
|
112
210
|
// ══ QUERY BUILDING ═════════════════════════════════════════════════════════════════════════════════
|
|
@@ -192,7 +290,7 @@ const PHRASE_OPERATOR = "ADJ";
|
|
|
192
290
|
// once `exact`:
|
|
193
291
|
// "SLUSH FREEZE, SLUSH ICE, SLUSH POP" three terms crammed into one
|
|
194
292
|
// "TIKTOK / TIK- famous-neighbour family" a description of a family, not a name
|
|
195
|
-
// "
|
|
293
|
+
// "WAVU, WAPO one-keystroke neighbours of WAVO"
|
|
196
294
|
//
|
|
197
295
|
// Searched, they return 0 — and a 0 here reads as CLEAN, which is the one outcome this contract must
|
|
198
296
|
// never produce by accident. On that run the `default` twins deferred loudly while the `exact` twins
|
|
@@ -305,7 +403,7 @@ export function compilePhraseValue(term, { pre = "", post = "", dropReserved = f
|
|
|
305
403
|
const last = kept.length - 1;
|
|
306
404
|
const tailWrap = (kept.length > 1 && kept[last].length === 1) ? "" : post;
|
|
307
405
|
// THE WILDCARD PREDICATE CARRIES ITS OWN STAR, so `post` is empty there and the two rules above
|
|
308
|
-
// cannot reach it: the caller writes `
|
|
406
|
+
// cannot reach it: the caller writes `FOLD A*` and the star is part of the token. Same shape, same
|
|
309
407
|
// refusal, so the same subtraction — the star comes off a one-character final token. Nothing else is
|
|
310
408
|
// touched: a longer final token keeps the caller's pattern exactly as written, and a `?` is a
|
|
311
409
|
// single-character class rather than a sub-query, so it is not this.
|
|
@@ -561,7 +659,7 @@ export function resolveOffices(regions) {
|
|
|
561
659
|
* answers the deduplicated union of the deleted 4-call per-class fan-out.
|
|
562
660
|
*/
|
|
563
661
|
// ── THE APOSTROPHE IS NOT SEARCHABLE IN APPLICANT_NAME ────────────────────────────────────────────
|
|
564
|
-
// An apostrophe is not searchable: "TRADER VIC'S",
|
|
662
|
+
// An apostrophe is not searchable: "TRADER VIC'S", a company's possessive name and
|
|
565
663
|
// "…L'ETAT DU DELAWARE" all answer HTTP 400 — "the following characters are not searchable by
|
|
566
664
|
// themselves" — while the same names without the apostrophe answer 200. It is the character itself,
|
|
567
665
|
// not the elision and not the possessive; an earlier fix narrowed this to elided articles only, on the
|
|
@@ -569,7 +667,7 @@ export function resolveOffices(regions) {
|
|
|
569
667
|
// is exactly what failed next.
|
|
570
668
|
//
|
|
571
669
|
// Dropping the name is the wrong remedy: `?` is a native single-character wildcard on this field, so
|
|
572
|
-
// the apostrophe is EXPRESSIBLE. "
|
|
670
|
+
// the apostrophe is EXPRESSIBLE. That possessive name written with "?" returns 27 where the literal returns 400.
|
|
573
671
|
//
|
|
574
672
|
// Emit BOTH spellings OR-joined, because registers hold both: "TRADER VIC?S" matches the apostrophe'd
|
|
575
673
|
// record and "TRADER VICS" the stripped one (probe: 3 records each). One form alone silently loses
|
|
@@ -1107,6 +1205,7 @@ export async function doSearch(apiKey, base, params, tctx) {
|
|
|
1107
1205
|
if (!isSearchResponseBody(r.body)) {
|
|
1108
1206
|
return { type: "text", text: nonAnswerBodyError("clarivate_search", r, "a search response (no ids{} — the one key POST /search answers with)", ` query=${String(echo).slice(0, 200)}`) };
|
|
1109
1207
|
}
|
|
1208
|
+
forgetContradictedAnswers(base, body, r.body, tctx);
|
|
1110
1209
|
const out = normalizeSearchResponse(r.body, echo, p.match_mode || "default");
|
|
1111
1210
|
// The resolution note rides on the answer, exactly as it does on the enumerate result: a reader has
|
|
1112
1211
|
// to be able to see WHICH applicant styling this sweep actually asked for, and a zero over an owner
|
|
@@ -1115,6 +1214,22 @@ export async function doSearch(apiKey, base, params, tctx) {
|
|
|
1115
1214
|
return { type: "text", text: JSON.stringify(out, null, 2) };
|
|
1116
1215
|
}
|
|
1117
1216
|
|
|
1217
|
+
// A SEARCH SHORTER THAN ITS OWN COUNT IS NOT KEPT, AND NEITHER IS THE COUNT. The enumerate kernel reads the
|
|
1218
|
+
// shortfall as the register contradicting itself and hands the slice to the repair ladder to ask again
|
|
1219
|
+
// (the count/search reconciliation in providers/_shared/enumerate.mjs). Kept, both answers would be served
|
|
1220
|
+
// again on every retry, and the slice could not be cured within the attempt. The two share one body, so
|
|
1221
|
+
// the count held for this search is found under the same question.
|
|
1222
|
+
function forgetContradictedAnswers(base, body, searchBody, tctx) {
|
|
1223
|
+
const mem = openAnswerMemory(tctx?.recordLog);
|
|
1224
|
+
if (!mem) return;
|
|
1225
|
+
const countKey = answerKey({ base, method: "POST", path: "/count", body });
|
|
1226
|
+
const counted = recallAnswer(mem, countKey)?.summary?.total;
|
|
1227
|
+
const returned = rememberableAnswer("/search", 200, searchBody, null)?.total;
|
|
1228
|
+
if (!Number.isFinite(counted) || !Number.isFinite(returned) || returned >= counted) return;
|
|
1229
|
+
forgetAnswer(mem, countKey);
|
|
1230
|
+
forgetAnswer(mem, answerKey({ base, method: "POST", path: "/search", body }));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1118
1233
|
// ── Count ─────────────────────────────────────────────────────────────────────────────────────────
|
|
1119
1234
|
// POST /count takes the SAME SearchRequest body as /search, is cheap, works at ANY magnitude and
|
|
1120
1235
|
// returns PER-OFFICE counts in one call. This is the enumerate
|
|
@@ -1269,7 +1384,7 @@ async function fetchText(apiKey, base, group, testMode, tctx) {
|
|
|
1269
1384
|
const body = { ids: group };
|
|
1270
1385
|
if (testMode) body.test = true;
|
|
1271
1386
|
const target = group.slice(0, 5).join(",") + (group.length > 5 ? `+${group.length - 5}` : "");
|
|
1272
|
-
const r = await clarivateFetch(apiKey, base, "/text", { body, tctx: { ...tctx, target } });
|
|
1387
|
+
const r = await clarivateFetch(apiKey, base, "/text", { body, tctx: { ...tctx, target }, ledgerExtra: { records: group.map((g) => String(g).toLowerCase()) } });
|
|
1273
1388
|
if (!r.ok) return { ok: false, raw: [], error: `HTTP ${r.status} for a ${group.length}-id chunk: ${errText(r)}` };
|
|
1274
1389
|
// On this provider /text is the ONLY source of mark text, classes, status and owner (the search
|
|
1275
1390
|
// returns bare guids), and the kernel's contentFromScreen seam reads a chunk error to decide whether
|
|
@@ -1305,21 +1420,31 @@ export async function doRecordFetch(apiKey, base, params, tctx) {
|
|
|
1305
1420
|
if (inputs.length === 0) return { type: "text", text: "ERROR: clarivate_record_fetch — record_ids is required (non-empty array of refs or guids)." };
|
|
1306
1421
|
|
|
1307
1422
|
const { officeByGuid, guids } = splitRefs(inputs);
|
|
1308
|
-
|
|
1423
|
+
// A record this run already holds is not fetched again (heldRecords); test-mode bodies are obfuscated
|
|
1424
|
+
// and are never held.
|
|
1425
|
+
const held = params.test_mode ? new Map() : heldRecords(tctx, guids);
|
|
1426
|
+
logHeldRecords(tctx, held);
|
|
1427
|
+
const groups = chunk(guids.filter((g) => !held.has(String(g).toLowerCase())), TEXT_BATCH_MAX);
|
|
1309
1428
|
const records = [];
|
|
1429
|
+
const fetched = [];
|
|
1310
1430
|
const errors = [];
|
|
1431
|
+
for (const rec of held.values()) { const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null); records.push(nr); fetched.push(nr); }
|
|
1311
1432
|
for (const group of groups) {
|
|
1312
1433
|
const { ok, raw, error } = await fetchText(apiKey, base, group, params.test_mode, tctx);
|
|
1313
1434
|
if (!ok) { errors.push(error); continue; }
|
|
1314
|
-
for (const rec of raw)
|
|
1435
|
+
for (const rec of raw) { const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null); records.push(nr); fetched.push(nr); }
|
|
1315
1436
|
}
|
|
1316
1437
|
if (!records.length && errors.length) {
|
|
1317
1438
|
return { type: "text", text: `ERROR: clarivate_record_fetch — ${errors.join("; ")}` };
|
|
1318
1439
|
}
|
|
1319
1440
|
// A1: persist each normalized record keyed by its synthetic ref so the driver can field-verify
|
|
1320
1441
|
// registry identifiers and archive the record into the run. (test_mode bodies are obfuscated — skip.)
|
|
1442
|
+
// A HELD RECORD IS WRITTEN TOO, under the address this request gives it. The screen gate matches exact
|
|
1443
|
+
// addresses against this log, and a record first fetched under another office's address was otherwise
|
|
1444
|
+
// absent under this one, so a drop citing it read as a record nobody examined. The log keeps one row per
|
|
1445
|
+
// address (writeRecordOnce), so a held record already logged under this address writes nothing.
|
|
1321
1446
|
if (!params.test_mode) {
|
|
1322
|
-
for (const nr of
|
|
1447
|
+
for (const nr of fetched) if (nr?.uri) logRecordBody({ ...tctx, kind: "record_fetch" }, nr.uri, nr);
|
|
1323
1448
|
}
|
|
1324
1449
|
return { type: "text", text: JSON.stringify({ count: records.length, records, errors: errors.length ? errors : undefined }, null, 2) };
|
|
1325
1450
|
}
|
|
@@ -1337,15 +1462,21 @@ export async function doBatchScreen(apiKey, base, params, tctx) {
|
|
|
1337
1462
|
? params.in_scope_classes.map(Number).filter(Number.isFinite) : [];
|
|
1338
1463
|
|
|
1339
1464
|
const { officeByGuid, guids } = splitRefs(inputs);
|
|
1340
|
-
|
|
1465
|
+
// A record this run already holds is screened from its stored raw copy and not fetched again
|
|
1466
|
+
// (heldRecords); only the rest go to /text. Test-mode bodies are obfuscated and are never held.
|
|
1467
|
+
const held = params.test_mode ? new Map() : heldRecords(tctx, guids);
|
|
1468
|
+
logHeldRecords(tctx, held);
|
|
1469
|
+
const groups = chunk(guids.filter((g) => !held.has(String(g).toLowerCase())), TEXT_BATCH_MAX);
|
|
1341
1470
|
const rows = [];
|
|
1342
1471
|
const errors = [];
|
|
1343
1472
|
const normalized = [];
|
|
1344
|
-
|
|
1345
|
-
|
|
1473
|
+
const answers = held.size ? [{ ok: true, raw: [...held.values()], held: true }] : [];
|
|
1474
|
+
for (const group of groups) answers.push({ ...(await fetchText(apiKey, base, group, params.test_mode, tctx)), held: false });
|
|
1475
|
+
for (const { ok, raw, error } of answers) {
|
|
1346
1476
|
if (!ok) { errors.push(error); continue; }
|
|
1347
1477
|
for (const rec of raw) {
|
|
1348
1478
|
const nr = normalizeRecord(rec, rec?.id ? officeByGuid[rec.id] : null);
|
|
1479
|
+
// A held record is logged as well, under this request's address, for the reason doRecordFetch gives.
|
|
1349
1480
|
normalized.push(nr);
|
|
1350
1481
|
const row = {
|
|
1351
1482
|
uri: nr.uri,
|
|
@@ -1398,6 +1529,7 @@ export async function doBatchScreen(apiKey, base, params, tctx) {
|
|
|
1398
1529
|
for (const row of rows) verdict_summary[row.screen_verdict] = (verdict_summary[row.screen_verdict] ?? 0) + 1;
|
|
1399
1530
|
return { type: "text", text: JSON.stringify({
|
|
1400
1531
|
requested: inputs.length, returned: rows.length, chunks: groups.length,
|
|
1532
|
+
...(held.size ? { held: held.size } : {}),
|
|
1401
1533
|
in_scope_classes: inScopeClasses.length ? inScopeClasses : "NOT PROVIDED — live marks fail-safe to surface:in-scope-live (no class-drop)",
|
|
1402
1534
|
errors: errors.length ? errors : undefined,
|
|
1403
1535
|
note: "Per-row `screen_verdict` (CLOSED SET) is the keep/drop authority — NOT the mark name or owner. drop:dead = a confidently dead status; drop:out-of-class = live but no in-scope-class overlap and not all_class — these two are batch-screen-authoritative drops. surface:in-scope-live and surface:all-class = a real in-scope candidate: decide it on the record's goods & services (this provider returns them on this very call). deepfetch:ambiguous = status unrecognised, never auto-drop.",
|
|
@@ -1645,6 +1777,33 @@ export async function doFilingDate(apiKey, base, params, tctx) {
|
|
|
1645
1777
|
return { type: "text", text: JSON.stringify({ count: rows.length, registers: rows }, null, 2) };
|
|
1646
1778
|
}
|
|
1647
1779
|
|
|
1780
|
+
// ── ONE OFFICE WITH NO GOODS FIELD REFUSES THE WHOLE REQUEST ─────────────────────────────────────
|
|
1781
|
+
// The vendor answers a goods-narrowed request with HTTP 400 "Search field INT_GOODS_SERVICES_DESCRIPTION
|
|
1782
|
+
// is not supported for registrationOfficeCode XX" when any one office in it lacks the field, so every
|
|
1783
|
+
// other office goes unasked. A retry of the same request cannot change that; the request without that
|
|
1784
|
+
// office can. Ask again without it, as often as another office refuses, and name each office left out on
|
|
1785
|
+
// the result. No call is added unless an office refuses.
|
|
1786
|
+
export function officeRefusingGoodsField(reason) {
|
|
1787
|
+
const m = new RegExp(`Search field ${GOODS_FIELD} is not supported for registrationOfficeCode ([A-Z]{2})\\b`).exec(String(reason ?? ""));
|
|
1788
|
+
return m ? m[1] : null;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
export async function withoutOfficesRefusingGoods(params, ask, reasonOf) {
|
|
1792
|
+
const leftOut = [];
|
|
1793
|
+
let p = params;
|
|
1794
|
+
let out = await ask(p);
|
|
1795
|
+
for (let office = officeRefusingGoodsField(reasonOf(out)); office && !leftOut.includes(office); office = officeRefusingGoodsField(reasonOf(out))) {
|
|
1796
|
+
const regions = (Array.isArray(p?.regions) ? p.regions : []).filter((r) => !resolveOffices([r]).codes.includes(office));
|
|
1797
|
+
// The refusing office is not in the request as the caller wrote it, or it is the only one: the
|
|
1798
|
+
// refusal stands as it was given.
|
|
1799
|
+
if (!regions.length || regions.length === (p?.regions ?? []).length) break;
|
|
1800
|
+
leftOut.push(office);
|
|
1801
|
+
p = { ...p, regions };
|
|
1802
|
+
out = await ask(p);
|
|
1803
|
+
}
|
|
1804
|
+
return { out, params: p, leftOut };
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1648
1807
|
// ── Enumerate — WIRED FROM THE SHARED KERNEL ──────────────────────────────────────────────────────
|
|
1649
1808
|
//
|
|
1650
1809
|
// The control flow (states, ceilings, the wide-`names` chunking, the count-first per-term rescue) lives
|
|
@@ -1685,8 +1844,14 @@ export async function doEnumerate(apiKey, base, params, tctx) {
|
|
|
1685
1844
|
const chunked = Array.isArray(p?.names) && p.names.filter(Boolean).length > namesChunk;
|
|
1686
1845
|
|
|
1687
1846
|
const sink = [];
|
|
1688
|
-
|
|
1689
|
-
|
|
1847
|
+
const asked = await withoutOfficesRefusingGoods(p, async (q) => {
|
|
1848
|
+
sink.length = 0;
|
|
1849
|
+
const rr = await __enumerate({ apiKey, base }, { ...q, __countSink: sink }, tctx);
|
|
1850
|
+
return { rr, parsed: parseToolText(rr) };
|
|
1851
|
+
}, (o) => o.parsed?.reason);
|
|
1852
|
+
p = asked.params;
|
|
1853
|
+
let r = asked.out.rr;
|
|
1854
|
+
let parsed = asked.out.parsed;
|
|
1690
1855
|
if (!parsed) return r;
|
|
1691
1856
|
|
|
1692
1857
|
// ── the ADDITIVE invariant, ENFORCED AT RUNTIME TOO ─────────────────────────────────────────────
|
|
@@ -1711,7 +1876,7 @@ export async function doEnumerate(apiKey, base, params, tctx) {
|
|
|
1711
1876
|
// without it the kernel's calls underneath this fallback would re-resolve the very names whose
|
|
1712
1877
|
// expansion the provider has just rejected — reinstating the stack the fallback exists to drop.
|
|
1713
1878
|
// The un-resolved sweep has to be genuinely un-resolved, at every seam.
|
|
1714
|
-
const rawOnly = { ...params, resolve_owner: false };
|
|
1879
|
+
const rawOnly = { ...params, regions: p.regions, resolve_owner: false };
|
|
1715
1880
|
delete rawOnly.owners;
|
|
1716
1881
|
const sink2 = [];
|
|
1717
1882
|
const r2 = await __enumerate({ apiKey, base }, { ...rawOnly, __countSink: sink2 }, tctx);
|
|
@@ -1732,6 +1897,9 @@ export async function doEnumerate(apiKey, base, params, tctx) {
|
|
|
1732
1897
|
}
|
|
1733
1898
|
}
|
|
1734
1899
|
|
|
1900
|
+
// After the owner fallback, which may replace the answer: it asks with the same reduced offices.
|
|
1901
|
+
if (asked.leftOut.length) parsed.offices_without_goods_field = asked.leftOut;
|
|
1902
|
+
|
|
1735
1903
|
if (parsed.state === "incomplete") {
|
|
1736
1904
|
// The 30000 ceiling is the provider truthfully reporting a CROWD, not a fault. Restate it as a
|
|
1737
1905
|
// crowd descriptor — and strip the kernel's generic "provider error" framing, because an
|
|
@@ -1750,6 +1918,13 @@ export async function doEnumerate(apiKey, base, params, tctx) {
|
|
|
1750
1918
|
if (chunked) {
|
|
1751
1919
|
parsed.per_office_counts = null;
|
|
1752
1920
|
parsed.per_office_counts_unavailable = `the ${p.names.length}-name stack was enumerated in windows of ${namesChunk} (the provider's OR-width bound), so no whole-stack per-office count exists — per-window counts would misreport the jurisdictional shape of this crowd.`;
|
|
1921
|
+
} else if (parsed.region_split) {
|
|
1922
|
+
// ASKED IN REGION HALVES after a gateway timeout, so sink[0] may be one half's count. Each count
|
|
1923
|
+
// covers exactly its own offices, and an office's count is the same whichever count asked it, so
|
|
1924
|
+
// the first count that names an office answers for it.
|
|
1925
|
+
const perOffice = {};
|
|
1926
|
+
for (const entry of sink) for (const [office, n] of Object.entries(entry?.per_office ?? {})) if (!(office in perOffice)) perOffice[office] = n;
|
|
1927
|
+
if (Object.keys(perOffice).length) parsed.per_office_counts = perOffice;
|
|
1753
1928
|
} else if (sink.length && sink[0]?.per_office) {
|
|
1754
1929
|
parsed.per_office_counts = sink[0].per_office;
|
|
1755
1930
|
}
|
|
@@ -13,8 +13,7 @@ a global aggregator, so it declares no enumerable covered office set; see the la
|
|
|
13
13
|
| `test/` | Characterisation and fault-lane tests |
|
|
14
14
|
|
|
15
15
|
Environment: `CORSEARCH_SESSION_KEY` is required, and it is a **session cookie**, not a bearer token —
|
|
16
|
-
`core.js` sends it as `Cookie: sessionKey=…`.
|
|
17
|
-
`plugins.entries.clawdi-corsearch.config.sessionKey`. There is no base-URL variable: `BASE_SEARCH`,
|
|
16
|
+
`core.js` sends it as `Cookie: sessionKey=…`. There is no base-URL variable: `BASE_SEARCH`,
|
|
18
17
|
`BASE_DETAIL` and `BASE_IMAGE` are constants in `core.js`. The per-call ledger paths come from
|
|
19
18
|
`CLEAROTRON_REGISTER_CALL_LOG` / `CLEAROTRON_REGISTER_RECORD_LOG`, resolved in
|
|
20
19
|
[`../_shared/ledger-path.mjs`](../_shared/ledger-path.mjs) and shared with the other register adapters
|
|
@@ -54,7 +54,7 @@ export const MATCH_MODE_PREFIX = {
|
|
|
54
54
|
// `provider` discriminator on every row.
|
|
55
55
|
//
|
|
56
56
|
// SECURITY — the ids logged (agentId / sessionKey / sessionId) are the GATEWAY tool-call context
|
|
57
|
-
// (e.g. "
|
|
57
|
+
// (e.g. "localagent", "clearotron-acme-…"), the per-run attribution. They are NOT the Corsearch `sessionKey`
|
|
58
58
|
// COOKIE (the live credential, which merely shares the name). logCall is only ever handed `tctx`
|
|
59
59
|
// (kind + gateway ids + target) and response metrics — the cookie is never passed in. Keep it so.
|
|
60
60
|
export const { logCall, logRecordBody, tctxOf } = makeLedger("corsearch");
|
|
@@ -269,7 +269,7 @@ export function normalizeSearchResponse(body, echoQuery, matchMode) {
|
|
|
269
269
|
// ── Search (with per-run dedup) ───────────────────────────────────────────────
|
|
270
270
|
// The plugin runs in the long-lived gateway daemon, so this module-level Map persists across tool
|
|
271
271
|
// calls within a run. A run issues the SAME search more than once (~12% of this build's baseline run
|
|
272
|
-
// were exact-duplicate sweeps — name
|
|
272
|
+
// were exact-duplicate sweeps — the name sweep and the JP and CN transliteration sweeps, each issued twice),
|
|
273
273
|
// invisible to telemetry because the dup counter watched record_fetch only. We cache the normalized
|
|
274
274
|
// result keyed on the GATEWAY session key (per-run, unique → no cross-run collision) + the FULL
|
|
275
275
|
// canonical upstream query tail (so a different page / field-set is NOT a dup). On an exact repeat we
|
|
@@ -83,7 +83,7 @@ export function subclassesFor(db, { country, term, niceClass }) {
|
|
|
83
83
|
cn_goods_code: row.group_code, // what the 12th says, kept even when the 13th governs
|
|
84
84
|
})),
|
|
85
85
|
source: from13 === ruled.length
|
|
86
|
-
? "NCL 13-2026 concordance — the 13th edition governs the group code
|
|
86
|
+
? "NCL 13-2026 concordance — the 13th edition governs the group code; CNIPA's 区分表 found the good"
|
|
87
87
|
: from13 === 0
|
|
88
88
|
? "CNIPA 区分表, 12th edition (2023 text) — the 13th edition holds no group for this good, so there is nothing it disagrees with"
|
|
89
89
|
: `mixed: ${from13} of ${ruled.length} matched goods take the 13th edition's group, the rest have no 13th-edition group at all`,
|
|
@@ -43,11 +43,12 @@ The bridge owns `<creds-dir>/<server>.json` from the moment the recipe below
|
|
|
43
43
|
writes it, and refreshes are written back there. mcporter's own credential store
|
|
44
44
|
can rotate or clear without affecting the bridge.
|
|
45
45
|
|
|
46
|
-
**Where `<creds-dir>` is.** `bridge.mjs`
|
|
47
|
-
`OAUTH_BRIDGE_CREDS_DIR`, else `~/.config/trademark-oauth-mcp`.
|
|
48
|
-
|
|
49
|
-
so a deployment
|
|
50
|
-
|
|
46
|
+
**Where `<creds-dir>` is.** Both `bridge.mjs` and `warm-server.mjs` read
|
|
47
|
+
`--creds-dir`, else `OAUTH_BRIDGE_CREDS_DIR`, else `~/.config/trademark-oauth-mcp`.
|
|
48
|
+
They disagreed until 2026-09-27 — the warm server defaulted elsewhere and took no
|
|
49
|
+
variable at all, so a deployment running both had to pass the directory explicitly.
|
|
50
|
+
A deployment whose credentials sit in the folder an earlier build used points
|
|
51
|
+
`OAUTH_BRIDGE_CREDS_DIR` at that folder.
|
|
51
52
|
|
|
52
53
|
## One-time setup (per remote MCP server)
|
|
53
54
|
|
|
@@ -247,7 +248,7 @@ that way.
|
|
|
247
248
|
|
|
248
249
|
Two caveats on "courtlistener is warm", both about WHERE. First, no box runs a unit of
|
|
249
250
|
that name: `driver/unit-inventory.mjs` declares the shipped one an orphan and records the
|
|
250
|
-
live service
|
|
251
|
+
live service under a name of its own, run from another checkout — so editing
|
|
251
252
|
`warm-server.mjs` or the unit file here changes nothing on the running proxy. Second, the
|
|
252
253
|
clearance engine does not reach the warm service at all: `driver/engine/mcp/gather-config.mjs`
|
|
253
254
|
mounts courtlistener as a per-session `bridge.mjs` spawn, exactly like legaldatahunter, so
|
|
@@ -274,7 +275,7 @@ checkout.
|
|
|
274
275
|
|
|
275
276
|
**Why this unit does not take `CLEAROTRON_CHECKOUT_DIR` the way the driver units do**: those load
|
|
276
277
|
`EnvironmentFile=%h/.env` and expand `${CLEAROTRON_CHECKOUT_DIR}` in `ExecStart`. This one deliberately
|
|
277
|
-
loads no environment file — it reads the OAuth cache
|
|
278
|
+
loads no environment file — it reads the OAuth cache under the credentials directory
|
|
278
279
|
and needs nothing else — and on systemd an `EnvironmentFile` **overrides** the unit's own `Environment=`
|
|
279
280
|
lines, so adding one to gain the variable would also let `~/.env` silently replace this unit's `PATH`.
|
|
280
281
|
Editing one line is the smaller cost. Keep `loginctl enable-linger <user>` on if the service must
|
|
@@ -33,13 +33,13 @@ import { driverDir } from "../../shared/driver-dir.mjs"; //
|
|
|
33
33
|
import { RefreshLock } from "./refresh-lock.mjs";
|
|
34
34
|
import { exitOnStdinClose } from "./stdin-guard.mjs";
|
|
35
35
|
|
|
36
|
-
// Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR).
|
|
37
|
-
//
|
|
36
|
+
// Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR). A deployment whose credentials still sit in the
|
|
37
|
+
// folder an earlier build used keeps them by pointing this variable at that folder.
|
|
38
38
|
const DEFAULT_CREDS_DIR = process.env.OAUTH_BRIDGE_CREDS_DIR || path.join(homedir(), ".config", "trademark-oauth-mcp");
|
|
39
39
|
|
|
40
40
|
// Per-server tool allowlist. Upstream MCP servers may add or rename tools
|
|
41
41
|
// without notice — we explicitly opt in. To add a tool: verify it's read-only
|
|
42
|
-
// (or otherwise intended for
|
|
42
|
+
// (or otherwise intended for this product's use) against the upstream API docs, then
|
|
43
43
|
// add the unprefixed upstream name below and restart the bridge.
|
|
44
44
|
const ALLOWED_TOOLS = {
|
|
45
45
|
courtlistener: new Set([
|
|
@@ -146,7 +146,7 @@ process.on("exit", () => refreshLock.releaseSync());
|
|
|
146
146
|
// of holding the upstream HTTP transport (and any in-flight upstream call) open forever (see
|
|
147
147
|
// stdin-guard.mjs for the 3.5-day PPID-1 orphan this closes). The 'exit' hook above releases the
|
|
148
148
|
// refresh lock on the way out.
|
|
149
|
-
exitOnStdinClose({ name: `
|
|
149
|
+
exitOnStdinClose({ name: `clearotron-oauth-bridge:${serverName}` });
|
|
150
150
|
|
|
151
151
|
class CachedOAuthProvider {
|
|
152
152
|
constructor(initialCreds) {
|
|
@@ -278,6 +278,20 @@ function resultBytes(res) {
|
|
|
278
278
|
try { return JSON.stringify(res ?? null).length; } catch { return null; }
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* AN ERROR ANSWER IS A FAILED CALL. Under MCP a tool reports its own failure, a quota or an outage behind
|
|
283
|
+
* the server, inside its result as `isError: true`, and the call itself returns. Logged as ok, that answer
|
|
284
|
+
* read as a source that answered: a case-law pass whose every query met a failing source would then read
|
|
285
|
+
* as having searched and found nothing. Returns the source's words, cut like a thrown error's, or null
|
|
286
|
+
* for an answer that is not an error.
|
|
287
|
+
*/
|
|
288
|
+
function toolError(res) {
|
|
289
|
+
if (res?.isError !== true) return null;
|
|
290
|
+
const said = (Array.isArray(res.content) ? res.content : [])
|
|
291
|
+
.filter((c) => c?.type === "text").map((c) => String(c.text ?? "")).join(" ").trim();
|
|
292
|
+
return (said || "the tool answered with an error").slice(0, 200);
|
|
293
|
+
}
|
|
294
|
+
|
|
281
295
|
function logCall(server, tool, args, result) {
|
|
282
296
|
if (!AUDIT_RUN_DIR) return;
|
|
283
297
|
try {
|
|
@@ -341,7 +355,9 @@ async function main() {
|
|
|
341
355
|
}
|
|
342
356
|
try {
|
|
343
357
|
const res = await upstream.callTool({ name, arguments: req.params.arguments });
|
|
344
|
-
|
|
358
|
+
const refused = toolError(res);
|
|
359
|
+
if (refused === null) logCall(serverName, name, req.params.arguments, { ok: true, bytes: resultBytes(res) });
|
|
360
|
+
else logCall(serverName, name, req.params.arguments, { ok: false, error: refused });
|
|
345
361
|
return res;
|
|
346
362
|
} catch (err) {
|
|
347
363
|
// A FAILED CALL IS LOGGED TOO, and it is the half that matters: without it, "the search ran and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.2",
|
|
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).",
|
|
@@ -5,7 +5,7 @@ After=network-online.target
|
|
|
5
5
|
Wants=network-online.target
|
|
6
6
|
|
|
7
7
|
[Service]
|
|
8
|
-
# Reads the OAuth cache at
|
|
8
|
+
# Reads the OAuth cache at <creds-dir>/courtlistener.json (no ~/.env needed).
|
|
9
9
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
10
10
|
# ── RESOLVED AT INSTALL (option B) ───────────────────────────────────────────────────────────
|
|
11
11
|
# `@CLEAROTRON_CHECKOUT_DIR@` is substituted by render-units.mjs when it writes the resolved copy.
|
|
@@ -53,8 +53,19 @@ import { randomUUID } from "node:crypto";
|
|
|
53
53
|
import http from "node:http";
|
|
54
54
|
import path from "node:path";
|
|
55
55
|
import { listenOrDie } from "../../shared/listen.mjs"; // — a taken port is a sentence, not a stack
|
|
56
|
+
import { warnRetiredEnv } from "../../shared/env-aliases.mjs";
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
// THE REFUSAL ONLY, NO ENVIRONMENT FILE. This process reads one variable and deliberately loads no
|
|
59
|
+
// `.env` — its unit says so and explains why — but an operator who renamed the retired settings in that
|
|
60
|
+
// unit must not have this process silently stop seeing them. Reading the environment is what owes the
|
|
61
|
+
// refusal, and until 2026-09-27 this file read none, which is why it owed nothing.
|
|
62
|
+
warnRetiredEnv();
|
|
63
|
+
|
|
64
|
+
// Creds dir: env-overridable (OAUTH_BRIDGE_CREDS_DIR), and the same folder `bridge.mjs` already
|
|
65
|
+
// defaults to. The two entry points disagreed — this one named a folder after the old agent id and took
|
|
66
|
+
// no override at all, which is what made an install on the default unable to keep its sign-in. A
|
|
67
|
+
// deployment whose credentials sit in the old folder points the variable at it.
|
|
68
|
+
const DEFAULT_CREDS_DIR = process.env.OAUTH_BRIDGE_CREDS_DIR || path.join(homedir(), ".config", "trademark-oauth-mcp");
|
|
58
69
|
|
|
59
70
|
// Per-server tool allowlist — upstream servers may add/rename tools without
|
|
60
71
|
// notice, so we explicitly opt in. Kept in sync with bridge.mjs ALLOWED_TOOLS.
|
|
@@ -132,7 +143,7 @@ if (!allowedTools) {
|
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
const log = (msg) =>
|
|
135
|
-
process.stderr.write(`[
|
|
146
|
+
process.stderr.write(`[clearotron-warm-mcp:${serverName}] ${msg}\n`);
|
|
136
147
|
|
|
137
148
|
async function loadCreds() {
|
|
138
149
|
let raw;
|
|
@@ -199,7 +210,7 @@ class WarmOAuthProvider {
|
|
|
199
210
|
get clientMetadata() {
|
|
200
211
|
return (
|
|
201
212
|
this._creds.clientInfo?.metadata ?? {
|
|
202
|
-
client_name: "
|
|
213
|
+
client_name: "clearotron-oauth-mcp-bridge",
|
|
203
214
|
grant_types: ["authorization_code", "refresh_token"],
|
|
204
215
|
response_types: ["code"],
|
|
205
216
|
redirect_uris: [],
|
|
@@ -279,7 +290,7 @@ function scheduleKeepWarm(expiresInSec) {
|
|
|
279
290
|
// warm upstream through the serialization mutex, applying the allowlist.
|
|
280
291
|
function makeProxyServer() {
|
|
281
292
|
const server = new Server(
|
|
282
|
-
{ name: `
|
|
293
|
+
{ name: `clearotron-warm-${serverName}`, version: "0.1.0" },
|
|
283
294
|
{ capabilities: { tools: {} } },
|
|
284
295
|
);
|
|
285
296
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -344,7 +355,7 @@ async function main() {
|
|
|
344
355
|
const authProvider = new WarmOAuthProvider(creds);
|
|
345
356
|
|
|
346
357
|
upstream = new Client(
|
|
347
|
-
{ name: "
|
|
358
|
+
{ name: "clearotron-warm-mcp-client", version: "0.1.0" },
|
|
348
359
|
{ capabilities: {} },
|
|
349
360
|
);
|
|
350
361
|
const clientTransport = new StreamableHTTPClientTransport(
|