clearotron 0.3.2-beta.13 → 0.3.2-beta.15
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/bin/clearotron.mjs +7 -1
- package/bin/example.mjs +49 -22
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +22 -0
- package/driver/contract-vocabulary.mjs +5 -5
- package/driver/engine/mcp/codex-config.mjs +3 -11
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +3 -1
- package/driver/pipeline.mjs +30 -7
- package/driver/publish/index.mjs +10 -1
- package/driver/publish/render-knockout.mjs +31 -6
- package/driver/publish/render.mjs +54 -2
- package/driver/publish/templates/report.css +16 -1
- package/driver/register-availability.mjs +2 -2
- package/driver/register-plan.mjs +229 -55
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-variants/SKILL.md +10 -4
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +45 -3
- package/driver/variant-manifest-model.mjs +39 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/audit-view.mjs +5 -3
- package/mcp-server/lib/brief.mjs +10 -4
- package/mcp-server/lib/runs.mjs +35 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +6 -3
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-7Lq-dXDV.css → index-5CCwiJG7.css} +10 -0
- package/portal-ui/dist/assets/{index-w8GFZftk.js → index-DMthc7PQ.js} +8 -1
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/execute-plan.mjs +11 -1
- package/providers/_shared/term-shape.mjs +76 -0
- package/providers/clarivate/src/capabilities.js +36 -0
- package/providers/clarivate/src/core.js +119 -0
- package/providers/corsearch/src/capabilities.js +24 -0
- package/providers/corsearch/src/core.js +7 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +29 -0
- package/providers/signa/src/core.js +17 -0
- package/shared/demo-start-args.mjs +10 -0
- package/shared/stdio-connect.mjs +41 -10
- package/shared/toml-string.mjs +25 -0
|
@@ -285,8 +285,10 @@ const TIMELINE_FIELDS = ["ts", "seq", "kind", "phase", "stage", "decision", "tri
|
|
|
285
285
|
"changedFromPrevious", "attempt", "axes", "axis", "escalated", "verdict", "display", "cause",
|
|
286
286
|
"recovered", "count", "uris", "findings", "negatives", "audit", "snapshot", "resume"];
|
|
287
287
|
|
|
288
|
-
// `state` and `
|
|
289
|
-
//
|
|
288
|
+
// `state` and `tier` TRAVEL — the run's state and its BAND. The band replaced the gate's word here (824):
|
|
289
|
+
// CLEAR / CONDITIONAL / BLOCKING is engine vocabulary, and a client's assistant reading it beside a
|
|
290
|
+
// rating of Medium reported the run as delivered BLOCKING. The gate's decisions are not lost — every one
|
|
291
|
+
// of them is a timeline entry and a `verdictHistory` row, which is what this surface exists to narrate.
|
|
290
292
|
//
|
|
291
293
|
// `riskLadderAvailable` and `note` do NOT, together and for one reason: the flag exists only to say
|
|
292
294
|
// whether `diff_artifact` could show the word-by-word change, and `diff_artifact` is sealed. A flag
|
|
@@ -303,7 +305,7 @@ export function accountTimeline(result, { brandName = "The firm" } = {}) {
|
|
|
303
305
|
return out;
|
|
304
306
|
};
|
|
305
307
|
return {
|
|
306
|
-
...pick(result, ["runId", "state", "
|
|
308
|
+
...pick(result, ["runId", "state", "tier", "_note"]),
|
|
307
309
|
timeline: Array.isArray(result.timeline) ? result.timeline.map(entry) : [],
|
|
308
310
|
verdictHistory: Array.isArray(result.verdictHistory)
|
|
309
311
|
? result.verdictHistory.map((v) => pick(v, ["ts", "kind", "verdict", "stage"]))
|
package/mcp-server/lib/brief.mjs
CHANGED
|
@@ -74,9 +74,12 @@ export function buildBrief(run) {
|
|
|
74
74
|
const delivered = run.state === "delivered" || Boolean(run.deliveredAt);
|
|
75
75
|
const lines = [];
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
// THE BAND, AND NEVER THE GATE'S WORD. This chain used to fall through to the delivery verdict — the
|
|
78
|
+
// sidecar's `verdict`, then the run's — so a run whose report reads Medium could be briefed as BLOCKING.
|
|
79
|
+
// The band is what the report shows; where no band is recorded the line is not drawn at all.
|
|
80
|
+
const overall = clearance?.verdict?.band ?? clearance?.verdict?.tier
|
|
78
81
|
?? (koDocs.length === 1 ? (koDocs[0].overall ?? null) : null)
|
|
79
|
-
?? fm.overall_label ?? run.
|
|
82
|
+
?? fm.overall_label ?? run.tier ?? null;
|
|
80
83
|
|
|
81
84
|
// headline — the mark, the product THIS run actually is, and the run date. A null product prints
|
|
82
85
|
// nothing rather than a fallback name.
|
|
@@ -100,7 +103,10 @@ export function buildBrief(run) {
|
|
|
100
103
|
const paused = run.state === "postponed" ? ` — paused on a usage-limit cap, auto-resumes ${run.resetsAt ? `at ${String(run.resetsAt).replace("T", " ").slice(0, 16)} UTC` : "when the cap resets"}`
|
|
101
104
|
: run.state === "recovering" ? ` — auto-recovery backoff, resumes ${run.recoveryResumesAt ? `at ${String(run.recoveryResumesAt).replace("T", " ").slice(0, 16)} UTC` : "on its own"}`
|
|
102
105
|
: run.state === "parked-for-human" ? ` — parked by a runner stop (deploy/restart), resumes on the next runner activation` : "";
|
|
103
|
-
lines.push(`Status: ${run.state}${paused}
|
|
106
|
+
lines.push(`Status: ${run.state}${paused}.`);
|
|
107
|
+
// The run's own sentence, composed once by the driver and rendered on every client surface. It says
|
|
108
|
+
// what the gate word used to be reached for, in the words the report itself uses.
|
|
109
|
+
if (run.statement) lines.push(String(run.statement));
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
let source = "none";
|
|
@@ -198,7 +204,7 @@ export function buildBrief(run) {
|
|
|
198
204
|
return {
|
|
199
205
|
runId: run.runId, markName: run.markName ?? clearance?.markName ?? fm.title ?? null,
|
|
200
206
|
product,
|
|
201
|
-
overall,
|
|
207
|
+
overall, tier: run.tier ?? null, statement: run.statement ?? null, state: run.state ?? null, date: run.date ?? null,
|
|
202
208
|
source, brief: lines.join("\n"),
|
|
203
209
|
};
|
|
204
210
|
}
|
package/mcp-server/lib/runs.mjs
CHANGED
|
@@ -36,6 +36,28 @@ function findStatusFiles(root, depth, acc) {
|
|
|
36
36
|
* workspace, lists exactly as before, and an empty list from either still means an empty list. PURE given
|
|
37
37
|
* its inputs.
|
|
38
38
|
*/
|
|
39
|
+
// The three words the delivery gate decides in. They are engine vocabulary and never a rating.
|
|
40
|
+
const GATE_WORDS = new Set(["CLEAR", "CONDITIONAL", "BLOCKING"]);
|
|
41
|
+
/** A recorded outcome word read as a rating band, or null where it is the gate's decision. PURE. */
|
|
42
|
+
export const bandWord = (v) => {
|
|
43
|
+
const w = String(v ?? "").trim();
|
|
44
|
+
return w && !GATE_WORDS.has(w.toUpperCase()) ? w : null;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The band a run recorded before `status.json` carried one: the verdict record's own `tier`. Read only
|
|
49
|
+
* where the status has neither a band nor a band-shaped outcome word, so a current run costs no read.
|
|
50
|
+
* Null where there is no record to read — an absence, never a guess. PURE of everything but the file.
|
|
51
|
+
*/
|
|
52
|
+
export function tierFromRecord(runDir) {
|
|
53
|
+
if (!runDir) return null;
|
|
54
|
+
try {
|
|
55
|
+
const v = JSON.parse(readFileSync(driverDir(runDir, "verdict.json"), "utf8"));
|
|
56
|
+
const t = String(v?.tier ?? "").trim();
|
|
57
|
+
return t || null;
|
|
58
|
+
} catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
export function unreadableRunsReason({ workSet, workRoot, workExists, poolSet }) {
|
|
40
62
|
if (workSet || workExists || poolSet) return null;
|
|
41
63
|
return `no searches can be read here: CLEAROTRON_WORK_DIR is unset and ${workRoot} does not exist, `
|
|
@@ -68,7 +90,19 @@ function runFromStatusFile(statusFile, agent) {
|
|
|
68
90
|
runId: s.runId ?? `${s.slug}-${s.date}-${s.codename}`,
|
|
69
91
|
slug: s.slug, codename: s.codename, date: s.date,
|
|
70
92
|
agent: s.agent ?? agent,
|
|
71
|
-
|
|
93
|
+
// The BAND and the run's own composed sentence, never the delivery gate's word: `verdict` is
|
|
94
|
+
// engine vocabulary (CLEAR / CONDITIONAL / BLOCKING) and stays in the run record.
|
|
95
|
+
//
|
|
96
|
+
// ONE FIELD, TWO LANES. The knockout lane records its BAND in `verdict` — "High", "Manageable" — and
|
|
97
|
+
// every archived run of either lane has only that field, so the band is read from it where it is a
|
|
98
|
+
// band and dropped where it is the gate's word. A clearance run recorded since 2026-09-20 carries
|
|
99
|
+
// `tier` and needs no such reading.
|
|
100
|
+
// THE BAND IS ALWAYS HERE, and the gate's word is not. Reading `bandWord` alone left a run recorded
|
|
101
|
+
// before the band was written with NO band at all — `bandWord` answers null for a gate word — so an
|
|
102
|
+
// assistant saw the gate's word and nothing beside it, which is the shape this whole item is about.
|
|
103
|
+
// The verdict record holds the band for those runs, so it is read from there.
|
|
104
|
+
state: s.state ?? null,
|
|
105
|
+
tier: s.tier ?? bandWord(s.verdict) ?? tierFromRecord(runDir), statement: s.statement ?? null, url: s.url ?? null,
|
|
72
106
|
markName: s.markName ?? null, ref: s.ref ?? null, classes: s.classes ?? null,
|
|
73
107
|
stepN: s.stepN ?? null, stepLabel: s.stepLabel ?? null, stepTotal: s.stepTotal ?? null,
|
|
74
108
|
failedStage: s.failedStage ?? null, reason: s.reason ?? null,
|
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.15",
|
|
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/mcp-server/server.mjs
CHANGED
|
@@ -207,7 +207,8 @@ function runSummary(run) {
|
|
|
207
207
|
// once. null where the registry cannot name it — the row says nothing rather than guessing, because
|
|
208
208
|
// a hardcoded fallback is how a knockout once announced itself as a product it provably was not.
|
|
209
209
|
product: productIdentityFor(run),
|
|
210
|
-
|
|
210
|
+
// The band and the run's own sentence; the gate's word stays in the run record (824).
|
|
211
|
+
state: run.state, location: run.location, tier: run.tier, statement: run.statement, url: run.url,
|
|
211
212
|
markName: run.markName, ref: run.ref, classes: run.classes,
|
|
212
213
|
step: s.stepN ? `${s.stepN}/${s.stepTotal} ${s.stepLabel ?? ""}`.trim() : null,
|
|
213
214
|
startedAt: run.startedAt, updatedAt: run.updatedAt, deliveredAt: run.deliveredAt,
|
|
@@ -480,7 +481,9 @@ const tools = {
|
|
|
480
481
|
// a generic _history dir for some OTHER stage does not make register-findings diffable.
|
|
481
482
|
const riskLadderAvailable = listArtifactVersions(run.P, run.runDir, "register-digest", null).length > 1;
|
|
482
483
|
return {
|
|
483
|
-
|
|
484
|
+
// The chain narrates the gate's decisions, and every one of them is on the timeline and in
|
|
485
|
+
// `verdictHistory`, which is where they belong. The run's own headline is its BAND (824).
|
|
486
|
+
runId: run.runId, state: run.state, tier: run.tier, verdictHistory, timeline,
|
|
484
487
|
riskLadderAvailable,
|
|
485
488
|
note: riskLadderAvailable
|
|
486
489
|
? "A prior register-findings (digest) snapshot exists — diff_artifact can show the word-by-word change."
|
|
@@ -497,7 +500,7 @@ const tools = {
|
|
|
497
500
|
let changes = timeline;
|
|
498
501
|
if (Array.isArray(kinds) && kinds.length) changes = changes.filter((c) => kinds.includes(c.kind));
|
|
499
502
|
return {
|
|
500
|
-
runId: run.runId, state: run.state,
|
|
503
|
+
runId: run.runId, state: run.state, tier: run.tier, since: since ?? null, cursor,
|
|
501
504
|
count: changes.length, changes,
|
|
502
505
|
note: "Poll again with since=cursor (a stable sequence number) for only newer events — MCP has no push. cursor is independent of the kinds filter.",
|
|
503
506
|
};
|
package/package.json
CHANGED
|
@@ -2922,6 +2922,16 @@ details[open] > .fold-summary .fold-chev {
|
|
|
2922
2922
|
border: 1px solid var(--border-hairline); border-radius: 11px; background: var(--surface-raised);
|
|
2923
2923
|
}
|
|
2924
2924
|
|
|
2925
|
+
/* THE WAIT IS NOT A FAULT, AND IT MUST NOT BE READ AS ONE. It looks like `.home2-notice` because both
|
|
2926
|
+
are a quiet line above the lists, but it is deliberately NOT that class: `.home2-notice` is where
|
|
2927
|
+
this page states a fault, and home-render-check reads it as exactly that. Drawing the wait there
|
|
2928
|
+
made a page with nothing wrong report a fault in both themes — the very thing the wait was added to
|
|
2929
|
+
stop, arriving through the class attribute. */
|
|
2930
|
+
.home2-waiting {
|
|
2931
|
+
margin: 0 0 13px; padding: 11px 14px; font-size: 13px; color: var(--text-body);
|
|
2932
|
+
border: 1px solid var(--border-hairline); border-radius: 11px; background: var(--surface-raised);
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2925
2935
|
/* 340px minimum is LOAD-BEARING: the depth label must never be clipped, and three of the five depths
|
|
2926
2936
|
differ only in their suffix. */
|
|
2927
2937
|
.home2-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 13px; }
|
|
@@ -13299,7 +13299,10 @@ function AppShell({ render }) {
|
|
|
13299
13299
|
setDrawer(false);
|
|
13300
13300
|
setAvatarOpen(false);
|
|
13301
13301
|
}, [path]);
|
|
13302
|
-
if (!meResult) return (0, import_jsx_runtime.jsx)("div", {
|
|
13302
|
+
if (!meResult) return (0, import_jsx_runtime.jsx)("div", {
|
|
13303
|
+
className: "screen",
|
|
13304
|
+
children: (0, import_jsx_runtime.jsx)("p", { children: "Loading…" })
|
|
13305
|
+
});
|
|
13303
13306
|
if (sessionEnded || meResult && meResult.kind === "signedOut") return (0, import_jsx_runtime.jsx)(SessionEnded, {});
|
|
13304
13307
|
if (!meResult || meResult.kind !== "ok") return (0, import_jsx_runtime.jsx)("div", {
|
|
13305
13308
|
className: "screen",
|
|
@@ -15530,6 +15533,10 @@ function Home({ ctx }) {
|
|
|
15530
15533
|
label: "Filter by company"
|
|
15531
15534
|
})
|
|
15532
15535
|
}),
|
|
15536
|
+
answer === "loading" ? (0, import_jsx_runtime.jsx)("p", {
|
|
15537
|
+
className: "home2-waiting",
|
|
15538
|
+
children: "Loading…"
|
|
15539
|
+
}) : null,
|
|
15533
15540
|
answer === "error" ? (0, import_jsx_runtime.jsx)("p", {
|
|
15534
15541
|
className: "home2-notice",
|
|
15535
15542
|
children: result?.kind === "rateLimited" ? "Too many requests just now. The portal is pacing itself; this will refresh on its own." : "This did not load. Nothing is wrong with your runs — the list will try again."
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
-->
|
|
50
50
|
<link rel="preconnect" href="https://api.fontshare.com" crossorigin />
|
|
51
51
|
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
|
|
52
|
-
<script type="module" crossorigin src="/portal/assets/index-
|
|
53
|
-
<link rel="stylesheet" crossorigin href="/portal/assets/index-
|
|
52
|
+
<script type="module" crossorigin src="/portal/assets/index-DMthc7PQ.js"></script>
|
|
53
|
+
<link rel="stylesheet" crossorigin href="/portal/assets/index-5CCwiJG7.css">
|
|
54
54
|
</head>
|
|
55
55
|
<body>
|
|
56
56
|
<div id="root"></div>
|
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.3.2-beta.
|
|
5
|
+
"version": "0.3.2-beta.15",
|
|
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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
18
18
|
import { dirname } from "node:path";
|
|
19
19
|
import { nativeScriptIndexGap } from "./script-form.mjs";
|
|
20
|
-
import { entryTermIssues } from "./term-shape.mjs";
|
|
20
|
+
import { entryTermIssues, goodsTermsList } from "./term-shape.mjs";
|
|
21
21
|
import { faultText, guardToolCall } from "./transport-guard.mjs";
|
|
22
22
|
import { clipProviderText } from "./provider-text.mjs"; // — keep the discriminator
|
|
23
23
|
|
|
@@ -240,6 +240,16 @@ export function defaultBuildEntryQuery(e, pp) {
|
|
|
240
240
|
// F1 owner×term intersection: a mark-text entry carrying `owner` rides it as an additional
|
|
241
241
|
// owner filter beside the name clause (see the doc block above defaultBuildEntryQuery).
|
|
242
242
|
...(!__owner && typeof e.owner === "string" && e.owner.trim() ? { owner: e.owner.trim() } : {}),
|
|
243
|
+
// The goods-and-services narrowing, carried the same way and for the same reason as `owner`: an
|
|
244
|
+
// extra FIELD on the same request, never a second query. A provider that cannot send it declares
|
|
245
|
+
// `goodsTextSearch` false and the entry is refused before the query is built (goodsTextGap), so
|
|
246
|
+
// this line never reaches a connector that would quietly drop the clause and run the wide sweep.
|
|
247
|
+
...(goodsTermsList(e).length ? { goods_text: goodsTermsList(e) } : {}),
|
|
248
|
+
// …and the DISTANCES those words stood at. The compiler stripped the register's operator words
|
|
249
|
+
// once and stored what will be asked, so a term arrives here with nothing left to strip: without
|
|
250
|
+
// the gaps a connector would join "controllers peripherals" as a plain adjacency, which is the
|
|
251
|
+
// query that matches nothing. The plan states the distances; this carries them.
|
|
252
|
+
...(Array.isArray(e?.goods_text_gaps) && e.goods_text_gaps.length ? { goods_text_gaps: e.goods_text_gaps } : {}),
|
|
243
253
|
...modeParams,
|
|
244
254
|
nice_classes: (e.nice_classes ?? []).map(Number).filter(Number.isFinite),
|
|
245
255
|
...(Array.isArray(e.regions) && e.regions.length ? { regions: e.regions } : {}),
|
|
@@ -249,3 +249,79 @@ export function termSubstanceIssue(term) {
|
|
|
249
249
|
+ `match, under any predicate. Refused at the builder rather than bounced by the provider and `
|
|
250
250
|
+ `disclosed as a coverage gap the run could never have closed`;
|
|
251
251
|
}
|
|
252
|
+
|
|
253
|
+
// ── THE GOODS-AND-SERVICES TERMS AN ENTRY CARRIES ─────────────────────────────────────────────────
|
|
254
|
+
//
|
|
255
|
+
// ONE definition, because three places must agree on what "this entry asks for goods text" means: the
|
|
256
|
+
// plan compiler stamping the capability gap, the executor building the query, and each connector
|
|
257
|
+
// writing the clause. Two hand-rolled readings of the same field is how `filters.status` stayed wrong
|
|
258
|
+
// for two months on one provider while looking right on the other.
|
|
259
|
+
//
|
|
260
|
+
// A scalar and a one-element list are the SAME request. Blanks are dropped and duplicates collapse, so
|
|
261
|
+
// an entry asking for the same term twice compiles byte-identically to one asking once — the plan is a
|
|
262
|
+
// pure function of its input, and that must survive this field like every other. PURE.
|
|
263
|
+
export function goodsTermsList(entry) {
|
|
264
|
+
const raw = Array.isArray(entry?.goods_text) ? entry.goods_text
|
|
265
|
+
: (typeof entry?.goods_text === "string" ? [entry.goods_text] : []);
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const t of raw) {
|
|
268
|
+
const s = String(t ?? "").trim();
|
|
269
|
+
if (s && !out.includes(s)) out.push(s);
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── A GOODS TERM CARRYING A WORD THE REGISTER READS AS AN OPERATOR ────────────────────────────────
|
|
275
|
+
//
|
|
276
|
+
// `AND`, `OR`, `NOT`, `ADJ` and `NEAR` are operators INSIDE the value string on the register this
|
|
277
|
+
// engine runs on in production, and that field has no escape syntax. A goods term carrying one is not
|
|
278
|
+
// a narrower search there — it is a 400, and because the list rides ONE OR-joined value, a single bad
|
|
279
|
+
// term takes the whole narrowing down with it for the run.
|
|
280
|
+
//
|
|
281
|
+
// `NEAR` is why this is its own function rather than a reused check: the connector's own term
|
|
282
|
+
// validator tests AND/OR/NOT only, so "near field communication" passes every offline check and fails
|
|
283
|
+
// on the wire — the shape that reads as working right up until it does not.
|
|
284
|
+
//
|
|
285
|
+
// THE WORD IS REMOVED, THE ITEM IS NOT. "near field communication" still narrows usefully as
|
|
286
|
+
// "field communication", and dropping it whole would throw away a term the model chose on account of
|
|
287
|
+
// one word the vendor happens to reserve. Only an item that is NOTHING BUT reserved words disappears.
|
|
288
|
+
// Every removal is reported so the caller can disclose it: a term that reached the wire in a
|
|
289
|
+
// different shape than it was written must never do so silently.
|
|
290
|
+
//
|
|
291
|
+
// PURE.
|
|
292
|
+
const GOODS_RESERVED_WORDS = new Set(["and", "or", "not", "adj", "near"]);
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Strip the words this register parses as operators out of a goods term.
|
|
296
|
+
*
|
|
297
|
+
* `{ words, gaps, removed, cleaned }`. `gaps[i]` is the distance from `words[i]` to `words[i+1]` — 1
|
|
298
|
+
* when they were adjacent, 2 when one word was taken out between them, and so on.
|
|
299
|
+
*
|
|
300
|
+
* THE GAP IS THE WHOLE POINT and it is the rule the mark field already follows. Removing a word from
|
|
301
|
+
* the middle of a phrase leaves the survivors further apart than they were written: "controllers and
|
|
302
|
+
* peripherals" asked as a strict adjacency finds nothing, because no filing says "controllers
|
|
303
|
+
* peripherals". Widened by the gap it finds what was meant. A word taken off the FRONT or the BACK
|
|
304
|
+
* changes no distance between the words that remain, so "near field communication" stays a strict
|
|
305
|
+
* adjacency of "field" and "communication".
|
|
306
|
+
*
|
|
307
|
+
* PURE.
|
|
308
|
+
*/
|
|
309
|
+
export function stripGoodsReservedWords(term) {
|
|
310
|
+
const removed = [];
|
|
311
|
+
const words = [];
|
|
312
|
+
const gaps = [];
|
|
313
|
+
let owed = 1; // the distance owed to the NEXT kept word
|
|
314
|
+
for (const tok of String(term ?? "").trim().split(/\s+/)) {
|
|
315
|
+
if (!tok) continue;
|
|
316
|
+
// `ADJ2`/`NEAR3` are the numbered forms of the same operators.
|
|
317
|
+
if (GOODS_RESERVED_WORDS.has(tok.toLowerCase().replace(/\d+$/, ""))) {
|
|
318
|
+
removed.push(tok);
|
|
319
|
+
if (words.length) owed += 1; // …only widens a gap once there is something to widen it FROM
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (words.length) gaps.push(owed);
|
|
323
|
+
words.push(tok);
|
|
324
|
+
owed = 1;
|
|
325
|
+
}
|
|
326
|
+
return { words, gaps, removed, cleaned: words.join(" ") };
|
|
327
|
+
}
|
|
@@ -214,6 +214,42 @@ export const CAPABILITIES = Object.freeze({
|
|
|
214
214
|
// (expandOwnerTerms → assertSearchableTerm → degrade-to-unresolved) applies to the owner value on
|
|
215
215
|
// this path exactly as on a bare owner sweep — resolution stays additive-only.
|
|
216
216
|
ownerTermIntersection: true,
|
|
217
|
+
// ── CAN THE REGISTER BE ASKED WHAT A FILING COVERS, NOT JUST WHICH BUCKET IT SITS IN? ────────────
|
|
218
|
+
// `true` — `INT_GOODS_SERVICES_DESCRIPTION` is in the vendor's search-field enum, and it AND-joins
|
|
219
|
+
// with the mark and class clauses in one request exactly as the owner field does. That is the whole
|
|
220
|
+
// lever behind narrowing a crowded contains sweep: the Nice class is a filing bucket that holds
|
|
221
|
+
// headphones and jukeboxes alike, so class-scoping alone cannot cut a crowd on a common word.
|
|
222
|
+
//
|
|
223
|
+
// A provider that does not declare this gets a DISCLOSED DEFERRED ROW for any goods-narrowed slice
|
|
224
|
+
// (register-plan.mjs goodsTextGap → `unsupported`). It must never fall back to the un-narrowed
|
|
225
|
+
// sweep: that would return the crowd the narrowing exists to avoid and record it under the narrowed
|
|
226
|
+
// slice's qid — a widened search wearing a narrow slice's name.
|
|
227
|
+
goodsTextSearch: true,
|
|
228
|
+
// Does this register match a MULTI-WORD goods term as a phrase? YES, but only through `ADJ`, and the
|
|
229
|
+
// connector must do the joining. A BARE SPACE ON THIS FIELD IS AN IMPLICIT OR: both word orders
|
|
230
|
+
// return the same population, that population equals the explicit OR, and the explicit AND is a
|
|
231
|
+
// fraction of it. So a two-word value sent as written WIDENS the sweep to either word, answers 200
|
|
232
|
+
// and reads like a filter that worked — a clause meant to narrow doing the opposite, silently.
|
|
233
|
+
// `A ADJ B` is ordered and is the form core.js emits.
|
|
234
|
+
// What a MULTI-WORD goods term means on this register, named rather than flagged: the two registers
|
|
235
|
+
// that accept one do ENTIRELY DIFFERENT THINGS with it, and a shared boolean said only "yes".
|
|
236
|
+
// "ordered-phrase" — the words in that order, adjacent. Here, via the ADJ operator.
|
|
237
|
+
// "word-intersection" — filings whose description carries every word, anywhere, in any order.
|
|
238
|
+
// null — unmeasured or unsupported: a multi-word term must not be sent.
|
|
239
|
+
goodsTextMultiWord: "ordered-phrase",
|
|
240
|
+
// Several goods terms ride ONE clause joined by OR — this register expresses a list natively.
|
|
241
|
+
goodsTextListOr: true,
|
|
242
|
+
// The operator the goods clause rides. `EQUALS` on WHOLE WORDS: `CONTAINS` is a hard 400 here
|
|
243
|
+
// exactly as it is on APPLICANT_NAME, and so is a mid-word wildcard. Several words are asked for
|
|
244
|
+
// with `OR` inside the value.
|
|
245
|
+
//
|
|
246
|
+
// That makes this field the opposite of the mark field, where every mode is EQUALS with `*TERM*`
|
|
247
|
+
// infix wildcards. The two are NOT interchangeable, and the vendor's own documentation does not
|
|
248
|
+
// separate them. Do not "make it consistent" with the mark modes.
|
|
249
|
+
goodsTextOperator: "EQUALS",
|
|
250
|
+
// Whole words only: no wildcard may be sent on this field, so core.js splits a multi-word term into
|
|
251
|
+
// its words and ORs them rather than compiling an adjacency the field would reject.
|
|
252
|
+
goodsTextWholeWordOnly: true,
|
|
217
253
|
// ── WHICH FORM OF A NON-LATIN MARK DOES THE INDEX HOLD? ──────────────────────────────────────────
|
|
218
254
|
// `false` = the TRANSLITERATION ONLY. The characters are not indexed, so searching them returns 0
|
|
219
255
|
// with no error — the exact false-clean shape a reader calls CLEAN:
|
|
@@ -41,6 +41,7 @@ import { makeCountProbe } from "../../_shared/count.mjs";
|
|
|
41
41
|
import { CAPABILITY_GAP_MARKER, defaultBuildEntryQuery, makeExecutePlan, makeRegionRequiredBuildEntryQuery, planPredicateParams } from "../../_shared/execute-plan.mjs";
|
|
42
42
|
import { isNonLatinTerm } from "../../_shared/script-form.mjs";
|
|
43
43
|
import { CAPABILITIES, CLARIVATE_OFFICE_CODES } from "./capabilities.js";
|
|
44
|
+
import { stripGoodsReservedWords } from "../../_shared/term-shape.mjs"; // the goods field parses these words as operators
|
|
44
45
|
|
|
45
46
|
export const DEFAULT_BASE = "https://api.clarivate.com/compumark-content/api/v1";
|
|
46
47
|
|
|
@@ -116,6 +117,11 @@ const errText = (r) => r?.body?.errorMessage ?? r?.body?.message ?? (r?.raw ? St
|
|
|
116
117
|
// OR-stack — the operator stays EQUALS and the value does the vendor's query-string work.
|
|
117
118
|
export const MARK_FIELD = "WORD_MARK_SPECIFICATION";
|
|
118
119
|
export const OWNER_FIELD = "APPLICANT_NAME";
|
|
120
|
+
// The goods-and-services DESCRIPTION field — the text of what a filing covers, not the Nice class
|
|
121
|
+
// number it was filed under. The two are separate fields here and they answer separate questions.
|
|
122
|
+
export const GOODS_FIELD = "INT_GOODS_SERVICES_DESCRIPTION";
|
|
123
|
+
// Declared in capabilities.js, read here — one constant, so the operator changes in one place.
|
|
124
|
+
export const GOODS_OPERATOR = CAPABILITIES.goodsTextOperator ?? "EQUALS";
|
|
119
125
|
|
|
120
126
|
/**
|
|
121
127
|
* match_mode → { name, operator, pre, post, … }.
|
|
@@ -496,6 +502,18 @@ export function ownerTermsOf(p) {
|
|
|
496
502
|
return out;
|
|
497
503
|
}
|
|
498
504
|
|
|
505
|
+
/**
|
|
506
|
+
* The goods-and-services terms a request carries. Accepts a scalar or a list; a one-element list and
|
|
507
|
+
* a scalar are the same request. Never an element on its own — see hasAnyElement below.
|
|
508
|
+
*/
|
|
509
|
+
export function goodsTermsOf(p) {
|
|
510
|
+
const out = [];
|
|
511
|
+
for (const t of (Array.isArray(p?.goods_text) ? p.goods_text : [p?.goods_text])) {
|
|
512
|
+
if (t != null && String(t).trim()) out.push(String(t).trim());
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
499
517
|
export function hasAnyElement(p) {
|
|
500
518
|
return markTermsOf(p).length > 0 || ownerTermsOf(p).length > 0 || !!p?.representative;
|
|
501
519
|
}
|
|
@@ -651,6 +669,107 @@ export function buildSearchRequest(p) {
|
|
|
651
669
|
searchFields.push({ operator: "EQUALS", name: "INT_CLASS_NUMBER", value: joinOrValue([...new Set(classes)]) });
|
|
652
670
|
}
|
|
653
671
|
|
|
672
|
+
// ── the goods-and-services NARROWING ─────────────────────────────────────────────────────────────
|
|
673
|
+
//
|
|
674
|
+
// A class is a filing bucket, not a specification: class 9 holds headphones and jukeboxes alike, so
|
|
675
|
+
// a contains sweep scoped to the class alone crowds out on any common word. This clause asks the
|
|
676
|
+
// description text instead, and it AND-joins with the mark and class clauses like every other field
|
|
677
|
+
// here — narrowing the same sweep rather than running a second one.
|
|
678
|
+
//
|
|
679
|
+
// THIS FIELD IS NOT THE MARK FIELD, and assuming it was would ship a 400 on every narrowed sweep:
|
|
680
|
+
//
|
|
681
|
+
// EQUALS, whole word → works
|
|
682
|
+
// CONTAINS → hard 400, as on APPLICANT_NAME
|
|
683
|
+
// a mid-word wildcard (`*foo*`) → hard 400
|
|
684
|
+
// `WORD1 OR WORD2` → works, and is how several words are asked for
|
|
685
|
+
//
|
|
686
|
+
// So the value is a list of WHOLE WORDS joined by OR, with no wildcards anywhere — the opposite of
|
|
687
|
+
// the `*TERM*` infix every mark mode uses. THE CLAUSE MUST NOT ROUTE THROUGH `MATCH_MODE_TO_FIELD`:
|
|
688
|
+
// every mode there carries `pre:"*", post:"*"`, so a goods clause built through that map would
|
|
689
|
+
// inherit the wrap, pass every offline test, and 400 on the wire.
|
|
690
|
+
//
|
|
691
|
+
// A MULTI-WORD TERM IS REFUSED RATHER THAN SPLIT. Splitting "computer software" into
|
|
692
|
+
// `computer OR software` would match a filing that only ever says "computer" — a WIDER search than
|
|
693
|
+
// the caller asked for, arriving silently, which is the one thing this engine never does. The word
|
|
694
|
+
// list's contract is whole words (the variants manual says so), so a phrase here is a caller defect
|
|
695
|
+
// and it fails at the door. What the wire does with a two-word value is unprobed, and guessing it
|
|
696
|
+
// into an OR is the same guess wearing a different hat.
|
|
697
|
+
//
|
|
698
|
+
// PLURALS COME FROM THE VENDOR (queryOptions.plurals), SYNONYMS DO NOT. "headphones" does not reach
|
|
699
|
+
// "earphones" on any documented surface here. Nothing in this file invents one: a synonym list is a
|
|
700
|
+
// recall decision about what a search is allowed to miss, and it belongs to whoever writes the word
|
|
701
|
+
// list, never to the connector spending it.
|
|
702
|
+
const goodsTerms = goodsTermsOf(p);
|
|
703
|
+
if (goodsTerms.length) {
|
|
704
|
+
// A NARROWING WITH NOTHING TO NARROW IS NOT A NARROW SEARCH — IT IS A WIDE ONE. Goods text is not
|
|
705
|
+
// a search element (hasAnyElement excludes it deliberately): on its own it asks for every filing
|
|
706
|
+
// in these classes whose description carries the word, from every owner, which is far wider than
|
|
707
|
+
// the sweep it was added to cut. The request would succeed and read as a narrowed slice.
|
|
708
|
+
if (!markTerms.length && !ownerTermsOf(p).length && !p?.representative) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
"goods_text narrows a search and is not one: this request carries no mark term, owner or "
|
|
711
|
+
+ "representative, so the goods clause would be the whole query — every filing in these classes "
|
|
712
|
+
+ "whose description carries the word. Send it alongside the term it narrows.");
|
|
713
|
+
}
|
|
714
|
+
const words = [];
|
|
715
|
+
let gi = -1;
|
|
716
|
+
for (const t of goodsTerms) {
|
|
717
|
+
gi += 1;
|
|
718
|
+
// A PHRASE IS NEVER PASSED AS TYPED. A bare space on this field is an implicit OR — both word
|
|
719
|
+
// orders return the same population, it equals the explicit OR, and the explicit AND is a
|
|
720
|
+
// fraction of it. So "wireless headphones" sent as written would quietly search for EITHER word:
|
|
721
|
+
// a WIDER sweep than was asked for, answering 200 and reading like a filter that worked.
|
|
722
|
+
//
|
|
723
|
+
// `ADJ` is the operator that expresses a real phrase here, and it is ORDERED. So a multi-word
|
|
724
|
+
// term becomes its words joined by ADJ, and the list of terms is joined by OR — "either of these
|
|
725
|
+
// things, and this one is two words in this order".
|
|
726
|
+
// A WORD THIS FIELD READS AS AN OPERATOR COMES OUT HERE TOO, AND DOES NOT THROW. AND, OR, NOT,
|
|
727
|
+
// ADJ and NEAR are parsed inside the value and there is no escape syntax — and `NEAR` is
|
|
728
|
+
// ordinary specification language, so this is not a contrived input. The plan compiler already
|
|
729
|
+
// strips them and discloses it; this is the backstop for the paths that never go through a plan.
|
|
730
|
+
//
|
|
731
|
+
// It must STRIP rather than refuse: the list rides one OR-joined value, so throwing here would
|
|
732
|
+
// take every good term down with the bad one and leave the crowd a crowd. Refusing loudly is
|
|
733
|
+
// right when the alternative is a wrong answer; here the alternative is a narrower one, and the
|
|
734
|
+
// narrower one is what the caller asked for minus a word the vendor happens to reserve.
|
|
735
|
+
// THE ADJACENCY IS WIDENED BY WHAT WAS TAKEN OUT, which is the rule the mark field already
|
|
736
|
+
// follows: no filing says "controllers peripherals", so a strict adjacency of the survivors
|
|
737
|
+
// finds nothing where `controllers ADJ2 peripherals` finds the phrase that was meant. A word
|
|
738
|
+
// removed from the front or the back changes no distance between the words that remain.
|
|
739
|
+
//
|
|
740
|
+
// THE DISTANCES COME FROM THE PLAN WHERE THERE IS ONE. The compiler strips once and stores what
|
|
741
|
+
// will be asked, so a planned term arrives here already stripped and re-stripping it finds
|
|
742
|
+
// nothing to remove — the gaps would come back all 1 and the query would assert an adjacency
|
|
743
|
+
// that was never written. Stripping again locally is right only for the paths that never went
|
|
744
|
+
// through a plan, and it is a no-op on the ones that did.
|
|
745
|
+
const planned = Array.isArray(p?.goods_text_gaps) ? p.goods_text_gaps[gi] : null;
|
|
746
|
+
const local = stripGoodsReservedWords(t);
|
|
747
|
+
const { words: stripped, removed } = local;
|
|
748
|
+
const gaps = Array.isArray(planned) && planned.length ? planned : local.gaps;
|
|
749
|
+
if (!stripped.length) continue;
|
|
750
|
+
const parts = [];
|
|
751
|
+
for (const w of stripped) {
|
|
752
|
+
const safe = assertSearchableTerm(w, { allowWildcard: false });
|
|
753
|
+
if (safe) parts.push(safe);
|
|
754
|
+
}
|
|
755
|
+
if (!parts.length) continue;
|
|
756
|
+
if (parts.length > 1 && !CAPABILITIES.goodsTextMultiWord) {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`goods term ${JSON.stringify(String(t).slice(0, 40))} is more than one word, and this register `
|
|
759
|
+
+ `is not known to match a phrase as a phrase. Send the words you mean, one per entry.`);
|
|
760
|
+
}
|
|
761
|
+
// Single word: itself. Several: an adjacency chain whose every step carries the distance the
|
|
762
|
+
// words actually stood at, so a removal in the middle widens it and a removal at either end
|
|
763
|
+
// does not.
|
|
764
|
+
let value = parts[0];
|
|
765
|
+
for (let i = 1; i < parts.length; i++) {
|
|
766
|
+
value += ` ADJ${gaps[i - 1] > 1 ? gaps[i - 1] : ""} ${parts[i]}`;
|
|
767
|
+
}
|
|
768
|
+
if (!words.includes(value)) words.push(value);
|
|
769
|
+
}
|
|
770
|
+
if (words.length) searchFields.push({ operator: GOODS_OPERATOR, name: GOODS_FIELD, value: joinOrValue(words) });
|
|
771
|
+
}
|
|
772
|
+
|
|
654
773
|
const queryOptions = {};
|
|
655
774
|
if (p?.active_only != null) queryOptions.activeOnly = !!p.active_only;
|
|
656
775
|
if (p?.plurals != null) queryOptions.plurals = !!p.plurals;
|
|
@@ -109,6 +109,30 @@ export const CAPABILITIES = Object.freeze({
|
|
|
109
109
|
// with the term"). Declared as data so the planner/mint/executor hang off the declaration, never the
|
|
110
110
|
// vendor name.
|
|
111
111
|
ownerTermIntersection: true,
|
|
112
|
+
// ── GOODS-AND-SERVICES TEXT ──────────────────────────────────────────────────────────────────────
|
|
113
|
+
// `assembleQuery` builds a `product:` clause — this vendor's name for the goods description the
|
|
114
|
+
// others search too, documented in its own screening help as the written description of goods and
|
|
115
|
+
// services. The clause existed here long before anything passed it, which is the only reason this
|
|
116
|
+
// capability was ever false.
|
|
117
|
+
//
|
|
118
|
+
// A capability describes what a request through this adapter will actually do, never what the vendor
|
|
119
|
+
// is capable of — declaring `true` while the connector dropped the clause would compile narrowed
|
|
120
|
+
// slices into silently un-narrowed searches, the exact widened-search-wearing-a-narrow-name failure
|
|
121
|
+
// the deferral lane exists to prevent. So this flipped in the same commit that passed `product`
|
|
122
|
+
// through, and not before.
|
|
123
|
+
//
|
|
124
|
+
// Several words become several `product:` clauses; within one field this query language ORs them
|
|
125
|
+
// implicitly, which is the same "any of these words" the other two write with an explicit OR.
|
|
126
|
+
goodsTextSearch: true,
|
|
127
|
+
// A multi-word goods term: UNMEASURED on this vendor, so refused rather than guessed. A space that
|
|
128
|
+
// means AND on one register and OR on another changes the population either way and still answers
|
|
129
|
+
// 200, which is the failure that never announces itself.
|
|
130
|
+
// Unmeasured here, so a multi-word term is not sent at all. See clarivate/capabilities.js for the
|
|
131
|
+
// vocabulary; `null` is "we do not know what it would mean", which is not the same as "no".
|
|
132
|
+
goodsTextMultiWord: null,
|
|
133
|
+
// Several goods terms become several `product:` clauses, and within one field this query language
|
|
134
|
+
// ORs them implicitly — the same "any of these words" the explicit OR writes elsewhere.
|
|
135
|
+
goodsTextListOr: true,
|
|
112
136
|
// ── WHICH FORM OF A NON-LATIN MARK DOES THE INDEX HOLD? ──────────────────────────────────────────
|
|
113
137
|
// `true` = the CHARACTERS. A native-script term is a legitimate, productive query here and MUST be
|
|
114
138
|
// sent — the shared executor's script-form refusal (providers/_shared/script-form.mjs) is switched
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { makeLedger } from "../../_shared/ledger.mjs";
|
|
16
16
|
import { nonAnswerBodyError, parseJsonBody, unparsedBodyError } from "../../_shared/http-body.mjs";
|
|
17
17
|
import { normalizeTerritory } from "../../_shared/territory-codes.mjs";
|
|
18
|
+
import { goodsTermsList } from "../../_shared/term-shape.mjs"; // the shared reader for the goods words
|
|
18
19
|
import {
|
|
19
20
|
BATCH_SCREEN_CHUNK, chunk, classifyStatus, isAllClass, normalizeBrandRow, screenVerdict,
|
|
20
21
|
} from "../../_shared/screen.mjs";
|
|
@@ -133,6 +134,12 @@ export function assembleQuery(p) {
|
|
|
133
134
|
if (p.owner) parts.push(clause("", "owner", p.owner));
|
|
134
135
|
if (Array.isArray(p.owners)) for (const o of p.owners) parts.push(clause("", "owner", o)); // OR-stack of owner names (same-field implicit OR — mirrors `names`)
|
|
135
136
|
if (p.product) parts.push(clause("", "product", p.product));
|
|
137
|
+
// The goods-and-services narrowing, in the vocabulary every provider shares. `product:` is this
|
|
138
|
+
// vendor's name for the same field the others call a goods description, and it was already built
|
|
139
|
+
// here — nothing passed it until now, which is why the capability declared false.
|
|
140
|
+
// Several words become several clauses: within one field the clauses implicitly OR, which is the
|
|
141
|
+
// same "any of these words" the other connectors write with an explicit OR.
|
|
142
|
+
for (const g of goodsTermsList(p)) parts.push(clause("", "product", g));
|
|
136
143
|
if (p.representative) parts.push(clause("", "representative", p.representative));
|
|
137
144
|
|
|
138
145
|
// Filters (each value must be backtick-quoted)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-oauth-mcp-bridge",
|
|
3
|
-
"version": "0.3.2-beta.
|
|
3
|
+
"version": "0.3.2-beta.15",
|
|
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).",
|