clearotron 0.3.3 → 0.4.0-beta.1
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 +1 -14
- package/bin/brandowner.mjs +5 -5
- package/bin/connect.mjs +4 -4
- package/bin/onboard.mjs +5 -7
- package/bin/start.mjs +20 -5
- package/bin/update.mjs +6 -1
- package/build-info.json +2 -2
- package/docs/architecture/04-configuration-reference.md +2 -6
- package/driver/CHANGELOG.md +28 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/band-shape.mjs +8 -8
- package/driver/blind-frame-model.mjs +1 -1
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/commonlaw-carry.mjs +2 -2
- package/driver/company-bundle.mjs +11 -18
- package/driver/connotation-search.mjs +4 -4
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/declination-call.mjs +1 -1
- package/driver/declination-tool.mjs +1 -1
- package/driver/dev-portal.mjs +1 -1
- package/driver/door-call-verdict.mjs +27 -0
- package/driver/driver.config.mjs +17 -11
- package/driver/e2e/README.md +1 -1
- package/driver/engine/mcp/clarivate-server.mjs +2 -1
- package/driver/engine/mcp/corsearch-server.mjs +1 -0
- package/driver/engine/mcp/euipo-server.mjs +1 -0
- package/driver/engine/mcp/free-tier-server.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +1 -1
- package/driver/engine/mcp/perplexity-server.mjs +1 -1
- package/driver/engine/mcp/recording-server.mjs +1 -1
- package/driver/engine/mcp/signa-server.mjs +1 -0
- package/driver/engine/mcp/supplemental.mjs +1 -1
- package/driver/engine/mcp/uspto-local-server.mjs +1 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/feedback-issues.mjs +1 -1
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +3 -3
- package/driver/flag-snapshot.mjs +1 -1
- package/driver/floor-duty.mjs +2 -2
- package/driver/form-neighbourhood.mjs +47 -15
- package/driver/frame-diff-model.mjs +3 -3
- package/driver/gateway.mjs +5 -5
- package/driver/jx-lanes.mjs +1 -1
- package/driver/known-conflicts.mjs +18 -0
- package/driver/log.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +129 -95
- package/driver/pipeline.mjs +69 -32
- package/driver/placement-carry.mjs +2 -2
- package/driver/placement-form.mjs +1 -1
- package/driver/portal-mcp-client.mjs +1 -1
- package/driver/portal-request-origin.mjs +79 -0
- package/driver/portal-service.mjs +45 -17
- package/driver/predelivery-lint.mjs +10 -10
- package/driver/profile-page.html +9 -13
- package/driver/profile-service.mjs +25 -13
- package/driver/profiles.mjs +17 -4
- package/driver/progress.mjs +1 -1
- package/driver/provider-usage.mjs +24 -1
- package/driver/publish/index.mjs +17 -5
- package/driver/publish/knockout.mjs +3 -2
- package/driver/publish/render-knockout.mjs +1 -1
- package/driver/publish/render.mjs +14 -3
- package/driver/publish/search-depth.mjs +4 -2
- package/driver/recall-reconciliation.mjs +1 -1
- package/driver/record-carry.mjs +6 -6
- package/driver/recording-agreement.mjs +2 -2
- package/driver/reference-score.mjs +27 -27
- package/driver/register-count.mjs +56 -1
- package/driver/register-digest-record.mjs +1 -1
- package/driver/register-plan.mjs +5 -5
- package/driver/register-records.mjs +10 -1
- package/driver/registry-fidelity.mjs +4 -4
- package/driver/repair-composers.mjs +6 -6
- package/driver/run-economics.mjs +8 -19
- package/driver/screen-gate.mjs +1 -1
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/SKILL.md +2 -2
- package/driver/skills/clearance-common-law/perplexity-prompts.md +4 -4
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/unit.md +1 -1
- package/driver/skills/clearance-search/report-prose.md +5 -5
- package/driver/skills/clearance-search/synthesis-rules.md +4 -4
- package/driver/skills/clearance-variants/SKILL.md +2 -2
- package/driver/skills/clearance-variants/transliteration-scripts.md +1 -1
- package/driver/skills/frame-diff/SKILL.md +2 -2
- package/driver/skills/knockout-assess/SKILL.md +9 -9
- package/driver/skills/matter-frame/watchlist-reference.md +1 -1
- package/driver/skills/narrative-refutation/SKILL.md +2 -2
- package/driver/skills/placement-inquiry/SKILL.md +1 -1
- package/driver/stage-context.mjs +4 -4
- package/driver/stages.mjs +23 -15
- package/driver/suite-census.json +138 -42
- 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 +24 -0
- package/driver/tokens.mjs +26 -17
- package/driver/turnaround-bands.mjs +1 -1
- package/driver/unit-inventory.mjs +3 -3
- package/driver/variant-manifest-model.mjs +1 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +1 -1
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/audit.mjs +9 -2
- package/mcp-server/lib/http-handler.mjs +7 -3
- package/mcp-server/mint-token.mjs +8 -6
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +11 -1
- package/package.json +1 -1
- package/portal-ui/dist/assets/{index-GBbbyQxc.js → index-D_O_55vK.js} +59 -9
- package/portal-ui/dist/index.html +1 -1
- 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/ledger-path.mjs +1 -1
- package/providers/_shared/ledger.mjs +47 -5
- 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 +140 -13
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +24 -0
- package/providers/signa/src/core.js +66 -0
- package/scripts/deprecate-below.mjs +114 -2
- package/scripts/freeze-example-run.mjs +1 -1
- package/scripts/live-surface-check.mjs +11 -2
- package/scripts/release-entry-catch-up.mjs +211 -0
- package/scripts/release-note-required.mjs +102 -6
- 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/score.mjs +3 -3
- package/shared/brand.mjs +1 -1
- package/shared/client-door.mjs +15 -8
- package/shared/driver-dir.mjs +20 -9
- package/shared/names-in-force.mjs +2 -0
- package/shared/scope.mjs +25 -10
- package/shared/store-in-repo.mjs +38 -17
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
|
+
// answer-memory.mjs — a run remembers the register's answer to a question it has already asked.
|
|
4
|
+
//
|
|
5
|
+
// WHY. A register that bills per request charges again for every repeat, and a run repeats itself: a
|
|
6
|
+
// repair re-sends a whole axis, a re-attempt re-asks questions that already answered, a proposal is
|
|
7
|
+
// asked again later in the run. The memory sits at the provider's one HTTP chokepoint, so every caller
|
|
8
|
+
// is covered without each of them learning to share.
|
|
9
|
+
//
|
|
10
|
+
// WHERE IT LIVES. In the run's own `_driver/` folder, because the requests come from two kinds of
|
|
11
|
+
// process — the long-lived driver and the tool servers a stage spawns — and the repeats cross between
|
|
12
|
+
// them. Each already knows the run's record log: the driver passes it on `tctx.recordLog`, and a
|
|
13
|
+
// spawned server has it in CLEAROTRON_REGISTER_RECORD_LOG. The memory is the folder beside that file,
|
|
14
|
+
// so nothing new is handed to either.
|
|
15
|
+
//
|
|
16
|
+
// ONE ATTEMPT. The driver clears the folder when an attempt starts and removes it when the attempt
|
|
17
|
+
// ends, so a resume asks the register again. A folder whose attempt began more than a day ago is
|
|
18
|
+
// ignored, for an attempt that died without cleaning up. A process that finds no folder — a bare
|
|
19
|
+
// probe, a test, a register with no run — asks the register exactly as it always has.
|
|
20
|
+
//
|
|
21
|
+
// EXCEPT A RECORD, WHICH IS HELD FOR THE WHOLE RUN. Clarivate's record reuse (`heldRecords` in its core)
|
|
22
|
+
// reads the run's record log, not this folder, and that log keeps every attempt's records. So a run
|
|
23
|
+
// resumed days later screens a record from the copy an earlier attempt fetched, and its row says what the
|
|
24
|
+
// register said then. That is the ruling on this memory, that a run never fetches the same record twice;
|
|
25
|
+
// the questions above are asked again on a resume, the records are not.
|
|
26
|
+
//
|
|
27
|
+
// THREE MODES, fixed when the attempt starts from the register's own switch (ANSWER_MEMORY_PROVIDERS):
|
|
28
|
+
// off nothing is remembered and nothing is written.
|
|
29
|
+
// watch every request still goes to the register. Per request, the memory records whether it held
|
|
30
|
+
// an answer to the identical question, and whether the fresh answer has the same total, the
|
|
31
|
+
// same record ids and the same order as the one it held. This is the evidence the switch to
|
|
32
|
+
// `on` waits for.
|
|
33
|
+
// on a held answer is returned and the register is not asked.
|
|
34
|
+
//
|
|
35
|
+
// WHAT IS KEPT is the provider's decision, because only the provider knows which bodies are complete
|
|
36
|
+
// answers (see `rememberableAnswer` in the Signa core). An answer that points at a next page is held
|
|
37
|
+
// only for NEXT_PAGE_FRESH_MS: the cursor it carries is the register's, and nothing says how long the
|
|
38
|
+
// register honours it.
|
|
39
|
+
//
|
|
40
|
+
// NEVER THROWS. Every failure here reads as "nothing held" or "not stored", which is the behaviour
|
|
41
|
+
// the run had before the memory existed.
|
|
42
|
+
|
|
43
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { basename, dirname, join } from "node:path";
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
46
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
47
|
+
import { driverDir } from "../../shared/driver-dir.mjs";
|
|
48
|
+
import { RUN_RECORD_LOG_FILE } from "./ledger-path.mjs";
|
|
49
|
+
|
|
50
|
+
export const ANSWER_MEMORY_MODES = Object.freeze(["off", "watch", "on"]);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The registers whose core consults this memory, the environment switch that sets it for each (`env`, the
|
|
54
|
+
* table shape the configuration audit reads), and the mode a run takes when that switch is unset. Every other register is `off`, whatever any switch says.
|
|
55
|
+
*
|
|
56
|
+
* SIGNA starts `off`: it goes `on` only after runs in watch mode show no mismatch, by the owner's ruling.
|
|
57
|
+
* CLARIVATE starts `on`, with no watch period, also by his ruling: the repeated answers of its past runs
|
|
58
|
+
* were already compared and matched.
|
|
59
|
+
*/
|
|
60
|
+
export const ANSWER_MEMORY_PROVIDERS = Object.freeze({
|
|
61
|
+
signa: Object.freeze({ env: "CLEAROTRON_SIGNA_ANSWER_MEMORY", defaultMode: "off" }),
|
|
62
|
+
clarivate: Object.freeze({ env: "CLEAROTRON_CLARIVATE_ANSWER_MEMORY", defaultMode: "on" }),
|
|
63
|
+
});
|
|
64
|
+
export const ANSWER_MEMORY_SWITCH = ANSWER_MEMORY_PROVIDERS.signa.env;
|
|
65
|
+
export const ANSWER_MEMORY_DIR = "register-answers";
|
|
66
|
+
export const ANSWER_WATCH_LOG = "register-answer-watch.jsonl";
|
|
67
|
+
const ATTEMPT_FILE = "attempt.json";
|
|
68
|
+
export const ATTEMPT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
69
|
+
// One hour: a next-page link has been seen to work an hour after the register gave it, and nothing says it
|
|
70
|
+
// works for longer. A held answer older than this is simply asked again. A park ends the attempt anyway,
|
|
71
|
+
// and a resumed run starts with an empty memory, so no link survives a park.
|
|
72
|
+
export const NEXT_PAGE_FRESH_MS = 60 * 60 * 1000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The mode a register's switch asks for. Unset, or naming none of the three, is that register's default,
|
|
76
|
+
* and a value that named none of them is returned as `unknown` so the run can say so.
|
|
77
|
+
*/
|
|
78
|
+
export function answerMemoryMode(env = process.env, provider = "signa") {
|
|
79
|
+
const spec = ANSWER_MEMORY_PROVIDERS[String(provider ?? "")];
|
|
80
|
+
if (!spec) return { mode: "off", unknown: null };
|
|
81
|
+
const raw = String(env?.[spec.env] ?? "").trim().toLowerCase();
|
|
82
|
+
if (!raw) return { mode: spec.defaultMode, unknown: null };
|
|
83
|
+
return ANSWER_MEMORY_MODES.includes(raw) ? { mode: raw, unknown: null } : { mode: spec.defaultMode, unknown: raw };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Begin an attempt: whatever an earlier attempt left is removed, and a folder is made only for
|
|
88
|
+
* `watch` or `on`. Returns the folder, or null when the memory is off. Called by the driver alone.
|
|
89
|
+
*/
|
|
90
|
+
export function startAnswerMemory(runDir, mode, { now = Date.now } = {}) {
|
|
91
|
+
const dir = driverDir(runDir, ANSWER_MEMORY_DIR);
|
|
92
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* nothing to clear */ }
|
|
93
|
+
if (mode !== "watch" && mode !== "on") return null;
|
|
94
|
+
try {
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
writeFileSync(join(dir, ATTEMPT_FILE), JSON.stringify({ mode, started_ms: now() }) + "\n");
|
|
97
|
+
return dir;
|
|
98
|
+
} catch { return null; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The driver's half of an attempt: the mode this run uses, with the folder begun for it. `applies` is
|
|
103
|
+
* false for a register that does not use the memory, and then nothing is written at all, so that
|
|
104
|
+
* register's run folder is exactly what it was. A folder that cannot be made reads as `off`.
|
|
105
|
+
*/
|
|
106
|
+
export function beginAnswerMemory(runDir, provider, { env = process.env, now = Date.now } = {}) {
|
|
107
|
+
const spec = ANSWER_MEMORY_PROVIDERS[String(provider ?? "")];
|
|
108
|
+
if (!spec) return { mode: "off", unknown: null, applies: false, switch: null };
|
|
109
|
+
const { mode, unknown } = answerMemoryMode(env, provider);
|
|
110
|
+
const dir = startAnswerMemory(runDir, mode, { now });
|
|
111
|
+
return { mode: dir ? mode : "off", unknown, applies: true, switch: spec.env };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** End an attempt. The watch log stays; the held answers go. */
|
|
115
|
+
export function endAnswerMemory(runDir) {
|
|
116
|
+
try { rmSync(driverDir(runDir, ANSWER_MEMORY_DIR), { recursive: true, force: true }); } catch { /* already gone */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The run's memory for this request, or null. Found from the run's record log, which is the only
|
|
121
|
+
* address both kinds of process already hold. The box-global ledger's file has a different name, so a
|
|
122
|
+
* process pointed at it finds no run and no memory.
|
|
123
|
+
*/
|
|
124
|
+
export function openAnswerMemory(recordLog = null, { env = process.env, now = Date.now } = {}) {
|
|
125
|
+
try {
|
|
126
|
+
const log = typeof recordLog === "string" && recordLog.trim()
|
|
127
|
+
? recordLog.trim() : String(env?.CLEAROTRON_REGISTER_RECORD_LOG ?? "").trim();
|
|
128
|
+
if (!log || basename(log) !== RUN_RECORD_LOG_FILE) return null;
|
|
129
|
+
const dir = join(dirname(log), ANSWER_MEMORY_DIR);
|
|
130
|
+
const attempt = JSON.parse(readFileSync(join(dir, ATTEMPT_FILE), "utf8"));
|
|
131
|
+
if (attempt?.mode !== "watch" && attempt?.mode !== "on") return null;
|
|
132
|
+
if (!Number.isFinite(attempt.started_ms) || now() - attempt.started_ms > ATTEMPT_MAX_AGE_MS) return null;
|
|
133
|
+
return { mode: attempt.mode, dir, watchLog: join(dirname(log), ANSWER_WATCH_LOG) };
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The identity of a question: everything that decides the answer, and nothing that does not. */
|
|
138
|
+
export function answerKey(question) {
|
|
139
|
+
return createHash("sha256").update(JSON.stringify(question)).digest("hex");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The held answer to a question, or null. An answer that points at a next page is returned `stale`
|
|
144
|
+
* once NEXT_PAGE_FRESH_MS has passed: it is not served, and the watch log records that it was held, so
|
|
145
|
+
* the log shows what the rule costs.
|
|
146
|
+
*/
|
|
147
|
+
export function recallAnswer(mem, key, { now = Date.now } = {}) {
|
|
148
|
+
try {
|
|
149
|
+
const held = JSON.parse(gunzipSync(readFileSync(join(mem.dir, `${key}.json.gz`))).toString("utf8"));
|
|
150
|
+
const age = now() - held.stored_ms;
|
|
151
|
+
return { ...held, age_ms: age, stale: held?.summary?.next_page === true && age > NEXT_PAGE_FRESH_MS };
|
|
152
|
+
} catch { return null; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Keep an answer. Written whole and renamed into place, so a process reading at the same moment sees
|
|
157
|
+
* the old state or the new one and never half of either. Never makes the folder: an attempt that has
|
|
158
|
+
* ended has no memory to write into.
|
|
159
|
+
*/
|
|
160
|
+
export function rememberAnswer(mem, key, entry, { now = Date.now } = {}) {
|
|
161
|
+
const dest = join(mem.dir, `${key}.json.gz`);
|
|
162
|
+
const tmp = `${dest}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
163
|
+
try {
|
|
164
|
+
writeFileSync(tmp, gzipSync(JSON.stringify({ ...entry, stored_ms: now() })));
|
|
165
|
+
renameSync(tmp, dest);
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
try { rmSync(tmp, { force: true }); } catch { /* nothing written */ }
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Drop a held answer, so the next identical question goes to the register. Never throws. */
|
|
174
|
+
export function forgetAnswer(mem, key) {
|
|
175
|
+
try { rmSync(join(mem.dir, `${key}.json.gz`), { force: true }); return true; } catch { return false; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** One line per request in the watch log. Small, so concurrent appends from two processes stay whole. */
|
|
179
|
+
export function noteAnswer(mem, row) {
|
|
180
|
+
try { appendFileSync(mem.watchLog, JSON.stringify(row) + "\n"); } catch { /* the log is evidence, never a gate */ }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Three separate answers, because they mean different things. A different total or a different set
|
|
185
|
+
* of ids is a different answer, and the memory must not go live while either happens. A different
|
|
186
|
+
* order of the same ids is a register that breaks ties differently from one request to the next: the
|
|
187
|
+
* memory returns one of the orders the register itself gives.
|
|
188
|
+
*/
|
|
189
|
+
export function compareAnswers(held, fresh) {
|
|
190
|
+
const a = Array.isArray(held?.ids) ? held.ids : [];
|
|
191
|
+
const b = Array.isArray(fresh?.ids) ? fresh.ids : [];
|
|
192
|
+
const sa = [...a].sort(), sb = [...b].sort();
|
|
193
|
+
const same_ids = sa.length === sb.length && sa.every((v, i) => v === sb[i]);
|
|
194
|
+
return {
|
|
195
|
+
same_total: (held?.total ?? null) === (fresh?.total ?? null),
|
|
196
|
+
same_ids,
|
|
197
|
+
same_order: same_ids && a.every((v, i) => v === b[i]),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -305,7 +305,7 @@ export function retiredGlobalRecordLogNotice(env = process.env) {
|
|
|
305
305
|
if (announced.has(key)) return null;
|
|
306
306
|
announced.add(key);
|
|
307
307
|
const bytes = sizeOf(r.path);
|
|
308
|
-
return `[ledger] the box-global record log ${r.path}${bytes === null ? "" : ` (${bytes} bytes)`} is RETIRED
|
|
308
|
+
return `[ledger] the box-global record log ${r.path}${bytes === null ? "" : ` (${bytes} bytes)`} is RETIRED — `
|
|
309
309
|
+ `register response bodies now live in each run's _driver/${RUN_RECORD_LOG_FILE} and are archived and purged with the run. `
|
|
310
310
|
+ `Nothing writes to or reads this file any more; archive it once (mv it aside) at your convenience.`;
|
|
311
311
|
}
|
|
@@ -135,15 +135,21 @@ export function makeLedger(provider) {
|
|
|
135
135
|
//
|
|
136
136
|
// Run-scoped ledgers only. The box-wide fallback file holds many runs, and a record one run fetched is not
|
|
137
137
|
// a record another run holds.
|
|
138
|
-
const RUN_INDEXES = new Map(); // dest → { offset, byTarget: Map<key, { hash, refreshed, ts }> }
|
|
138
|
+
const RUN_INDEXES = new Map(); // dest → { offset, byTarget: Map<key, { hash, refreshed, ts, start, len }>, byGuid: Map<guid, key> }
|
|
139
139
|
const targetKey = (t) => String(t ?? "").toLowerCase(); // the key every reader of this file matches on
|
|
140
140
|
const bodyHash = (b) => createHash("sha1").update(JSON.stringify(b ?? null)).digest("hex");
|
|
141
|
+
// The record's own id: the last segment of `/mark/<office>/<id>`. The office segment is a hint that can
|
|
142
|
+
// differ between two answers for the same record, so a lookup by record keys on this and never on it.
|
|
143
|
+
const guidOf = (t) => { const k = targetKey(t); const i = k.lastIndexOf("/"); return i >= 0 ? k.slice(i + 1) : k; };
|
|
141
144
|
|
|
142
|
-
|
|
145
|
+
// `start` and `len` are the line's byte range, so one record can be read back without reading the file.
|
|
146
|
+
function indexRow(ix, line, start = null, len = null) {
|
|
143
147
|
if (!line.trim()) return;
|
|
144
148
|
try {
|
|
145
149
|
const r = JSON.parse(line);
|
|
146
|
-
|
|
150
|
+
const key = targetKey(r?.target);
|
|
151
|
+
ix.byTarget.set(key, { hash: bodyHash(r?.body), refreshed: r?.refreshed === true, ts: r?.ts ?? null, start, len });
|
|
152
|
+
if (key) ix.byGuid.set(guidOf(key), key);
|
|
147
153
|
} catch { /* a torn or foreign line indexes nothing, and is left where it is */ }
|
|
148
154
|
}
|
|
149
155
|
|
|
@@ -154,7 +160,7 @@ function indexOf(dest) {
|
|
|
154
160
|
let ix = RUN_INDEXES.get(dest);
|
|
155
161
|
// A replacement elsewhere renames a NEW file over this one: same path, different inode, and an offset
|
|
156
162
|
// into the old file means nothing in the new one. Re-read from the start.
|
|
157
|
-
if (!ix || size < ix.offset || ix.ino !== ino) { ix = { offset: 0, ino, byTarget: new Map() }; RUN_INDEXES.set(dest, ix); }
|
|
163
|
+
if (!ix || size < ix.offset || ix.ino !== ino) { ix = { offset: 0, ino, byTarget: new Map(), byGuid: new Map() }; RUN_INDEXES.set(dest, ix); }
|
|
158
164
|
if (size <= ix.offset) return ix;
|
|
159
165
|
const fd = openSync(dest, "r");
|
|
160
166
|
try {
|
|
@@ -167,9 +173,14 @@ function indexOf(dest) {
|
|
|
167
173
|
if (n <= 0) break;
|
|
168
174
|
pos += n;
|
|
169
175
|
const data = carry.length ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
|
|
176
|
+
const base = pos - data.length; // the file offset of data[0]
|
|
170
177
|
const last = data.lastIndexOf(0x0a);
|
|
171
178
|
if (last < 0) { carry = data; continue; }
|
|
172
|
-
for (
|
|
179
|
+
for (let at = 0; at <= last;) {
|
|
180
|
+
const nl = data.indexOf(0x0a, at);
|
|
181
|
+
indexRow(ix, data.subarray(at, nl).toString("utf8"), base + at, nl - at);
|
|
182
|
+
at = nl + 1;
|
|
183
|
+
}
|
|
173
184
|
carry = data.subarray(last + 1); // a line still being written waits for the next look
|
|
174
185
|
ix.offset = pos - carry.length;
|
|
175
186
|
}
|
|
@@ -177,6 +188,37 @@ function indexOf(dest) {
|
|
|
177
188
|
return ix;
|
|
178
189
|
}
|
|
179
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The record bodies this run already holds, for the given record ids: Map<id, body>, lowercased ids.
|
|
193
|
+
*
|
|
194
|
+
* Read off the run's own record log, which every process that fetches a record for the run writes to,
|
|
195
|
+
* so a record the driver fetched is held for a tool server and the other way round. A run-scoped log only:
|
|
196
|
+
* the box-wide fallback file holds many runs, and one run's record is not another's. Anything that cannot
|
|
197
|
+
* be read reads as not held, which costs a fetch and never an answer.
|
|
198
|
+
*/
|
|
199
|
+
export function heldRecordBodies(dest, ids) {
|
|
200
|
+
const out = new Map();
|
|
201
|
+
if (typeof dest !== "string" || basename(dest) !== RUN_RECORD_LOG_FILE) return out;
|
|
202
|
+
let ix;
|
|
203
|
+
try { ix = indexOf(dest); } catch { return out; }
|
|
204
|
+
const wanted = [...new Set((ids ?? []).map((g) => String(g ?? "").toLowerCase()).filter(Boolean))]
|
|
205
|
+
.map((g) => [g, ix.byTarget.get(ix.byGuid.get(g) ?? "")]).filter(([, e]) => e && Number.isInteger(e.start) && Number.isInteger(e.len));
|
|
206
|
+
if (!wanted.length) return out;
|
|
207
|
+
let fd;
|
|
208
|
+
try {
|
|
209
|
+
fd = openSync(dest, "r");
|
|
210
|
+
for (const [g, e] of wanted) {
|
|
211
|
+
try {
|
|
212
|
+
const buf = Buffer.alloc(e.len);
|
|
213
|
+
if (readSync(fd, buf, 0, e.len, e.start) !== e.len) continue;
|
|
214
|
+
const row = JSON.parse(buf.toString("utf8"));
|
|
215
|
+
if (row?.body && typeof row.body === "object") out.set(g, row.body);
|
|
216
|
+
} catch { /* a line that moved under us is simply not held */ }
|
|
217
|
+
}
|
|
218
|
+
} catch { /* unreadable: nothing held */ } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* closed */ } }
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
180
222
|
const sleepMs = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* no sleep available */ } };
|
|
181
223
|
|
|
182
224
|
/** Run `fn` holding the ledger's lock; false (and `fn` not run) when the lock could not be had in time. */
|
|
@@ -44,6 +44,25 @@ export const NON_LATIN_RE = /[^\p{Script=Latin}\p{Script=Common}\p{Script=Inheri
|
|
|
44
44
|
|
|
45
45
|
export function isNonLatinTerm(term) { return NON_LATIN_RE.test(String(term ?? "")); }
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Does the term MIX Latin letters with Greek or Cyrillic ones? `τιmbεr` does; `timber`, `τιμβερ` and
|
|
49
|
+
* `тимбер` do not, and neither does a term whose only other characters are digits or punctuation, which
|
|
50
|
+
* belong to no script of their own.
|
|
51
|
+
*
|
|
52
|
+
* The look-alike generator in driver/form-neighbourhood.mjs writes such terms: it swaps every letter
|
|
53
|
+
* that has a Greek or Cyrillic twin and leaves the rest Latin. A register may answer that spelling as
|
|
54
|
+
* if the non-Latin letters were not there, returning marks that share only the Latin remainder, or may
|
|
55
|
+
* not take it at all. `capabilities.mixedScriptQuery: false` declares that the register cannot search
|
|
56
|
+
* such a spelling as written, and the form band leaves these spellings out there and lists them as not
|
|
57
|
+
* searched.
|
|
58
|
+
*/
|
|
59
|
+
const LATIN_LETTER_RE = /\p{Script=Latin}/u;
|
|
60
|
+
const GREEK_OR_CYRILLIC_RE = /[\p{Script=Greek}\p{Script=Cyrillic}]/u;
|
|
61
|
+
export function mixesLatinWithGreekOrCyrillic(term) {
|
|
62
|
+
const s = String(term ?? "");
|
|
63
|
+
return LATIN_LETTER_RE.test(s) && GREEK_OR_CYRILLIC_RE.test(s);
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
/**
|
|
48
67
|
* The declaration-driven policy. Given a provider's capability contract and the MARK TERMS a query is
|
|
49
68
|
* about to carry, return the plain-English gap reason — or null when the slice may be dispatched.
|
|
@@ -121,15 +140,15 @@ export function romanizationSpellings(value) {
|
|
|
121
140
|
*
|
|
122
141
|
* Two folds fused, and the seam between them is the whole point:
|
|
123
142
|
* - LATIN text is accent-folded: lowercase, NFKD, then combining marks stripped — but ONLY the
|
|
124
|
-
* marks sitting on a Latin base letter — then punctuation/whitespace dropped. "
|
|
125
|
-
* "
|
|
143
|
+
* marks sitting on a Latin base letter — then punctuation/whitespace dropped. "Wavó-Slush" and
|
|
144
|
+
* "WAVO SLUSH" collide, exactly as the owner-formative dedup has always wanted.
|
|
126
145
|
* - NON-LATIN text keeps every combining mark. In most non-Latin scripts a combining mark is not
|
|
127
146
|
* an accent, it is part of WHICH LETTER this is: Japanese dakuten/handakuten (タ=ta vs ダ=da),
|
|
128
147
|
* Thai vowel signs, Devanagari matras, Arabic diacritics. A fold that strips them (the previous
|
|
129
148
|
* shape of this function: bare NFKD + strip all \p{M}) collapses MARK-DISTINGUISHED SIBLINGS
|
|
130
|
-
* into one key:
|
|
131
|
-
* (
|
|
132
|
-
* lookup handed one sibling the OTHER's romanisation and the dictated
|
|
149
|
+
* into one key: ワボスラッシュ
|
|
150
|
+
* (WABO SURASSHU) and ワホスラッシュ (WAHO SURASSHU) keyed identically, so the romanisation
|
|
151
|
+
* lookup handed one sibling the OTHER's romanisation and the dictated WAHO form was never
|
|
133
152
|
* searched anywhere — a silent wrong-query false clean, the exact class the carriage fix exists
|
|
134
153
|
* to kill. NFKD is kept for its width folding (half-width ガ and full-width ガ are the same
|
|
135
154
|
* letter); only the mark-stripping is script-scoped.
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// Two defect classes shipped as SILENT CLEANS, and both were shape-vs-predicate
|
|
7
7
|
// disagreements nobody checked:
|
|
8
8
|
//
|
|
9
|
-
// * WILDCARD-UNDER-LITERAL: the frozen plan carried {predicate:"exact", term:"
|
|
9
|
+
// * WILDCARD-UNDER-LITERAL: the frozen plan carried {predicate:"exact", term:"WAVO*"} ×4. Dispatch
|
|
10
10
|
// never inspects term characters on a literal predicate, so the provider searched the star as a
|
|
11
11
|
// character, found nothing, and the band recorded state:"enumerated", total_hits:0 — a
|
|
12
12
|
// schema-level confident clean over a slice that was never really searched.
|
|
13
|
-
// * LABEL-AS-TERM: a frame-diff directive's display label ("Reverse-order
|
|
14
|
-
// (TROPICAL
|
|
13
|
+
// * LABEL-AS-TERM: a frame-diff directive's display label ("Reverse-order WAVO composites
|
|
14
|
+
// (TROPICAL WAVO, ISLAND WAVO)") was dispatched verbatim as a mark term. Structured transport,
|
|
15
15
|
// prose value — same nil search, same false clean.
|
|
16
16
|
//
|
|
17
17
|
// This module is the shared detector all four seams call: the plan freeze-lint
|
|
@@ -88,7 +88,7 @@ export function termPredicateIssue(term, predicate) {
|
|
|
88
88
|
// state:"enumerated", total_hits:0 — a false clean, quieter than the failure.
|
|
89
89
|
//
|
|
90
90
|
// The three arms are the ones the issue names, and NOT the bracket: `predicate:"owner"` rows carry
|
|
91
|
-
// parenthesised company names ("
|
|
91
|
+
// parenthesised company names ("Korphi Technologies (BorgWarner Inc.)") and a bracket rule breaks
|
|
92
92
|
// owner search, which is the very lane this screen protects. Every arm fires at ANY word count.
|
|
93
93
|
//
|
|
94
94
|
// `**` / `__` markdown emphasis. No register indexes it; a mark cannot contain it.
|
|
@@ -178,7 +178,7 @@ export function termShapeIssue(term) {
|
|
|
178
178
|
* TWO EXEMPTIONS, AND THEY ARE NOT THE SAME EXEMPTION:
|
|
179
179
|
*
|
|
180
180
|
* `predicate: "owner"` is exempt from EVERYTHING, first, before any other test. Owner names are
|
|
181
|
-
* long, prose-shaped and parenthesised by nature ("
|
|
181
|
+
* long, prose-shaped and parenthesised by nature ("Korphi Technologies (BorgWarner Inc.)"), they
|
|
182
182
|
* ride their own field, and the cross-check lane mints them — so this is what makes it safe to
|
|
183
183
|
* screen that lane at all.
|
|
184
184
|
*
|
|
@@ -280,6 +280,17 @@ export const CAPABILITIES = Object.freeze({
|
|
|
280
280
|
// transliteration is itself a silent zero, where the same term under contains answers). A slice
|
|
281
281
|
// rescued that way is answerable and is never refused; only a native term with no romanisation defers.
|
|
282
282
|
nativeScriptIndex: false,
|
|
283
|
+
// A term mixing Latin letters with Greek or Cyrillic ones is not searched here. The index holds
|
|
284
|
+
// non-Latin filings by transliteration only (`nativeScriptIndex` above), so such a term used to be
|
|
285
|
+
// compiled and then deferred, and a deferred look-alike was counted as a search not completed. `false`
|
|
286
|
+
// leaves those spellings out of the form band and lists them there as not searched, as on Signa.
|
|
287
|
+
mixedScriptQuery: false,
|
|
288
|
+
// A knockout's listing already holds its count. The listing's `/search` asks the count lane's exact
|
|
289
|
+
// question, with the same body `/count` takes, and returns every matching id, so its length is the
|
|
290
|
+
// register's total. A knockout therefore lists first and takes the identical and close counts from the
|
|
291
|
+
// listing (driver/register-count.mjs listingAnswers). A term the listing did not answer, including one
|
|
292
|
+
// `/search` refuses for exceeding the result ceiling, is counted with `/count` as before.
|
|
293
|
+
listingAnswersCount: true,
|
|
283
294
|
// No phoneme expansion knob: PHONETIC_WORD_MARK_SPECIFICATION is the whole surface; the client cannot
|
|
284
295
|
// hand it a variant list. (/similarity/word/* — which would be the expansion surface — is genuinely
|
|
285
296
|
// not available on this provider; the endpoint answers 403. Do NOT wire it.)
|