@aiwayds/dsh-mcp-adapter 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Outbound secret gate (design 2026-09-25 §3.3) — ported verbatim from
3
+ * dsh-topics-memory `src/jev/gate.ts`, a self-contained port of the
4
+ * dsh-jev-mcp `server.mjs` mechanism. The COMPLETE serialized request body
5
+ * (model, state, questions — byte-identical to what goes on the wire) is
6
+ * scanned for credential patterns BEFORE anything leaves the process; a hit
7
+ * aborts the call with a structured descriptor that never echoes the secret.
8
+ *
9
+ * SECURITY INVARIANT: hit descriptors carry static metadata only (pattern
10
+ * name, offset, ≤10 printable chars before the hit) — never the matched
11
+ * text, and never text at or after the hit point.
12
+ *
13
+ * @module jev/gate
14
+ */
15
+ import { readFileSync, statSync } from 'node:fs';
16
+ function safeWarn(log, message) {
17
+ try {
18
+ log?.warn?.(message);
19
+ }
20
+ catch {
21
+ // contained — diagnostics never take the feature down
22
+ }
23
+ }
24
+ // Prefix-style patterns require a 20+ char continuation so that merely
25
+ // DISCUSSING a prefix (e.g. "set OPENROUTER_API_KEY to an sk-or-v1- key")
26
+ // never trips the gate — only an actual long token does.
27
+ export const BUILTIN_PATTERNS = [
28
+ { name: 'jwt', re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
29
+ { name: 'openrouter-key', re: /sk-or-v1-[A-Za-z0-9_-]{20,}/ },
30
+ { name: 'anthropic-key', re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
31
+ { name: 'openai-project-key', re: /sk-proj-[A-Za-z0-9_-]{20,}/ },
32
+ { name: 'openai-sk-key', re: /sk-[A-Za-z0-9]{20,}/ },
33
+ { name: 'github-token', re: /ghp_[A-Za-z0-9]{20,}/ },
34
+ { name: 'github-pat', re: /github_pat_[A-Za-z0-9_]{20,}/ },
35
+ { name: 'gitlab-token', re: /glpat-[A-Za-z0-9_-]{20,}/ },
36
+ { name: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{20,}/ },
37
+ { name: 'aws-access-key-id', re: /AKIA[0-9A-Z]{16}/ },
38
+ { name: 'aws-temp-key-id', re: /ASIA[0-9A-Z]{16}/ },
39
+ { name: 'google-api-key', re: /AIza[A-Za-z0-9_-]{20,}/ },
40
+ { name: 'private-key-block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
41
+ ];
42
+ const MAX_SECRET_LINE = 512; // longer lines WARN; matching ALWAYS uses the full line
43
+ const MIN_EXTERNAL_SECRET = 8; // shorter entries would cause runaway false hits
44
+ /** Literal secrets from the external list file (JEV_SECRET_FILE semantics:
45
+ * one entry per line, ≥8 chars, `#` comments skipped, matched at FULL
46
+ * length — a >512-char line warns but is never truncated, or everything
47
+ * past char 512 would silently escape the gate). */
48
+ export function loadExternalSecrets(file, log) {
49
+ if (file === '')
50
+ return [];
51
+ let raw;
52
+ try {
53
+ raw = readFileSync(file, 'utf8');
54
+ }
55
+ catch (err) {
56
+ safeWarn(log, `mcp-adapter jev: cannot read jevSecretFile (${file}): ${err instanceof Error ? err.message : String(err)}; continuing without it.`);
57
+ return [];
58
+ }
59
+ const secrets = [];
60
+ raw.split(/\r?\n/).forEach((line, i) => {
61
+ const s = line.trim();
62
+ if (s === '' || s.startsWith('#'))
63
+ return;
64
+ if (s.length < MIN_EXTERNAL_SECRET) {
65
+ safeWarn(log, `mcp-adapter jev: jevSecretFile line ${i + 1} shorter than ${MIN_EXTERNAL_SECRET} chars, skipped (false-positive guard).`);
66
+ return;
67
+ }
68
+ if (s.length > MAX_SECRET_LINE) {
69
+ safeWarn(log, `mcp-adapter jev: jevSecretFile line ${i + 1} is ${s.length} chars (> ${MAX_SECRET_LINE}); matching uses the FULL line (no truncation).`);
70
+ }
71
+ secrets.push(s);
72
+ });
73
+ return secrets;
74
+ }
75
+ // Cached external list, keyed by path, so the file is read once instead of on
76
+ // every outbound payload. The `jevSecretFile` config is VOLATILE — an operator
77
+ // can repoint it AND edit the file in place — and nothing in the plugin calls
78
+ // reloadSecrets on a settings write, so a cache hit is validated against the
79
+ // file's mtime+size (one stat, far cheaper than a re-read) before it is
80
+ // served. Without that, adding a secret to the file by hand would not be gated
81
+ // until the path changed or the process restarted.
82
+ let cache = { file: '', secrets: [], stamp: '' };
83
+ /** mtime+size of the list file, or '' when it cannot be stat'ed. */
84
+ function fileStamp(file) {
85
+ if (file === '')
86
+ return '';
87
+ try {
88
+ const { mtimeMs, size } = statSync(file);
89
+ return `${mtimeMs}:${size}`;
90
+ }
91
+ catch {
92
+ return '';
93
+ }
94
+ }
95
+ export function currentSecrets(file, log) {
96
+ // A new path, or the same path whose file changed under us, re-reads.
97
+ if (cache.file !== file || cache.stamp !== fileStamp(file))
98
+ reloadSecrets(file, log);
99
+ return cache.secrets;
100
+ }
101
+ export function reloadSecrets(file, log) {
102
+ cache = { file, secrets: loadExternalSecrets(file, log), stamp: fileStamp(file) };
103
+ return cache.secrets;
104
+ }
105
+ /**
106
+ * Redacted context: at most 10 printable chars BEFORE the hit plus an
107
+ * explicit [REDACTED:<pattern>] marker. Content at or after the hit point is
108
+ * NEVER shown — when a prefix regex under-matches, the secret tail sits
109
+ * right there. Belt and suspenders: if the matched text or any known
110
+ * external secret still appears in the context, the whole context collapses
111
+ * to '[REDACTED]'.
112
+ */
113
+ export function describeHit(pattern, offset, matched, payload, externalSecrets = []) {
114
+ const before = payload.slice(Math.max(0, offset - 10), offset).replace(/[^\x20-\x7e]/g, '.'); // keep it printable
115
+ const context = `${before}[REDACTED:${pattern}]`;
116
+ if (context.includes(matched) || externalSecrets.some((s) => s !== '' && context.includes(s))) {
117
+ return { pattern, offset, context: '[REDACTED]' };
118
+ }
119
+ return { pattern, offset, context };
120
+ }
121
+ /**
122
+ * Scan the serialized outbound payload for secrets.
123
+ * @param payload JSON.stringify({model, state, questions}) — the exact wire body
124
+ * @param externalSecrets literal secrets from the external list (cached via currentSecrets)
125
+ * @param externalSource label for hit descriptors (the list file path)
126
+ */
127
+ export function scanForSecrets(payload, externalSecrets = [], externalSource = '') {
128
+ for (const { name, re } of BUILTIN_PATTERNS) {
129
+ const m = re.exec(payload);
130
+ if (m)
131
+ return describeHit(name, m.index, m[0], payload, externalSecrets);
132
+ }
133
+ for (const secret of externalSecrets) {
134
+ const idx = payload.indexOf(secret);
135
+ if (idx !== -1) {
136
+ return describeHit(externalSource !== '' ? `external(${externalSource})` : 'external', idx, secret, payload, externalSecrets);
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ //# sourceMappingURL=gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gate.js","sourceRoot":"","sources":["../../src/jev/gate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * decisions.jsonl — System One usage/optimization telemetry (design
3
+ * 2026-09-25 §6), always on while jevEnabled is on. Ported from
4
+ * dsh-topics-memory `src/jev/log.ts` with ONE structural change: the
5
+ * destination directory is no longer resolved from an env/paths module
6
+ * inside the log, it is INJECTED by the caller
7
+ * ({@link createDecisionLog}). Everything else is carried over unchanged:
8
+ * 512KB auto-compaction keeping the most recent quarter, line JSON, and
9
+ * write failures swallowed silently — fail-open extends to the stats itself.
10
+ *
11
+ * The caller owns the directory; the plugin's own gate directory
12
+ * (`<dsh home>/storages/mcp-adapter`, see resolveGateDir in ../index.ts) is
13
+ * the intended one, so decisions.jsonl lands beside gate.json. Nothing here
14
+ * reads the dsh home, which keeps the module testable against a temp dir.
15
+ *
16
+ * REDACTION INVARIANT (§6.1): metadata and probabilities only — reference
17
+ * hash/question type/score/latency/token count. NEVER the state text or
18
+ * conclusion bodies. Writers copy an explicit field whitelist, so extra
19
+ * fields on caller objects cannot leak into the file.
20
+ *
21
+ * @module jev/log
22
+ */
23
+ import type { JevBand } from './thresholds.ts';
24
+ /** This plugin's three jev seams. A row must be attributable to exactly one
25
+ * of them — the seam identity is what makes the usage/calibration analysis
26
+ * separable per seam. */
27
+ export type JevLane = 'usage-suggest' | 'list-rank' | 'didyoumean';
28
+ export type JevOutcome = 'ok' | 'timeout' | 'network' | 'http_4xx' | 'http_5xx' | 'bad_json' | 'secret_gate_blocked' | 'missing_key'
29
+ /** Local pre-flight refusal (e.g. questions violate the decisions/openrouter
30
+ * wire contract) — nothing was sent. Reserved outcome beyond §6.2's v1 list. */
31
+ | 'invalid_request';
32
+ export interface JevUsage {
33
+ input_tokens: number;
34
+ output_tokens: number;
35
+ }
36
+ /** Call layer (§6.2): one row per HTTP call, failures included — the
37
+ * fallback rate is the health metric, so failed calls must be counted. */
38
+ export interface JevCallRecord {
39
+ at: string;
40
+ lane: JevLane;
41
+ backend: string;
42
+ model: string;
43
+ questionCount: number;
44
+ stateChars: number;
45
+ latencyMs: number;
46
+ usage: JevUsage | null;
47
+ outcome: JevOutcome;
48
+ fallback: boolean;
49
+ }
50
+ /** Verdict layer (§6.2): one row per question, batch requests expanded. */
51
+ export interface JevVerdictRecord {
52
+ at: string;
53
+ lane: JevLane;
54
+ questionId: string;
55
+ qtype: 'noul' | 'choice' | 'score';
56
+ /** Candidate reference without content: `tool:xxx` | `server:name`. */
57
+ ref: string;
58
+ /** §6.2 digest — stable key for the repeat-decision-rate analysis. */
59
+ digest: string;
60
+ probability: number;
61
+ band: JevBand;
62
+ agree: 'hit' | 'nearFloor' | 'gate-blocked' | 'wouldBlock' | 'n/a';
63
+ /** Which backend produced this verdict: 'primary' (default — the configured
64
+ * backend) or 'laya' (the local pace-maker). Design §3.4. */
65
+ backend?: string;
66
+ /** True when this row's source DROVE the decision because the primary
67
+ * backend was unavailable (degraded mode). Comparison rows from the
68
+ * pace-maker are degraded=false; laya rows are excluded from ECE either
69
+ * way (uncalibrated probabilities). */
70
+ degraded?: boolean;
71
+ }
72
+ /** The log file for one directory. The directory is created lazily on the
73
+ * first append — `createDecisionLog` itself touches nothing on disk. */
74
+ export declare function decisionsFile(logDir: string): string;
75
+ /** digest(state, questionId, instructions): `h1:` + first 16 hex of
76
+ * sha256(state + NUL + questionId + NUL + instructions) — the key the
77
+ * repeat-decision-rate analysis joins on. */
78
+ export declare function digestFor(state: string, questionId: string, instructions: string): string;
79
+ /** One injected log handle. jevAsk takes this as a required argument so the
80
+ * "row on EVERY path" invariant cannot be skipped by a caller. */
81
+ export interface DecisionLog {
82
+ /** Absolute path this log appends to. */
83
+ file(): string;
84
+ /** Call-layer row (field whitelist enforced). */
85
+ logCall(rec: JevCallRecord): Promise<void>;
86
+ /** Verdict-layer rows (batch expanded by the caller). */
87
+ logVerdicts(recs: readonly JevVerdictRecord[]): Promise<void>;
88
+ }
89
+ /**
90
+ * Build a decisions.jsonl log over one directory. Cheap and side-effect
91
+ * free: build it once (apply time), keep the handle, pass it to jevAsk.
92
+ */
93
+ export declare function createDecisionLog(logDir: string): DecisionLog;
package/lib/jev/log.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * decisions.jsonl — System One usage/optimization telemetry (design
3
+ * 2026-09-25 §6), always on while jevEnabled is on. Ported from
4
+ * dsh-topics-memory `src/jev/log.ts` with ONE structural change: the
5
+ * destination directory is no longer resolved from an env/paths module
6
+ * inside the log, it is INJECTED by the caller
7
+ * ({@link createDecisionLog}). Everything else is carried over unchanged:
8
+ * 512KB auto-compaction keeping the most recent quarter, line JSON, and
9
+ * write failures swallowed silently — fail-open extends to the stats itself.
10
+ *
11
+ * The caller owns the directory; the plugin's own gate directory
12
+ * (`<dsh home>/storages/mcp-adapter`, see resolveGateDir in ../index.ts) is
13
+ * the intended one, so decisions.jsonl lands beside gate.json. Nothing here
14
+ * reads the dsh home, which keeps the module testable against a temp dir.
15
+ *
16
+ * REDACTION INVARIANT (§6.1): metadata and probabilities only — reference
17
+ * hash/question type/score/latency/token count. NEVER the state text or
18
+ * conclusion bodies. Writers copy an explicit field whitelist, so extra
19
+ * fields on caller objects cannot leak into the file.
20
+ *
21
+ * @module jev/log
22
+ */
23
+ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
24
+ import { createHash, randomUUID } from 'node:crypto';
25
+ import { join } from 'node:path';
26
+ const COMPACT_LIMIT = 512 * 1024; // ~512KB cap, same as the ilog sidecars
27
+ /** The log file for one directory. The directory is created lazily on the
28
+ * first append — `createDecisionLog` itself touches nothing on disk. */
29
+ export function decisionsFile(logDir) {
30
+ return join(logDir, 'decisions.jsonl');
31
+ }
32
+ /** Digest field separator — a NUL byte, spelled this way so the source file
33
+ * itself stays plain text (a raw NUL would make the file binary). */
34
+ const NUL_SEP = String.fromCharCode(0);
35
+ /** digest(state, questionId, instructions): `h1:` + first 16 hex of
36
+ * sha256(state + NUL + questionId + NUL + instructions) — the key the
37
+ * repeat-decision-rate analysis joins on. */
38
+ export function digestFor(state, questionId, instructions) {
39
+ const h = createHash('sha256');
40
+ h.update(state);
41
+ h.update(NUL_SEP);
42
+ h.update(questionId);
43
+ h.update(NUL_SEP);
44
+ h.update(instructions);
45
+ return `h1:${h.digest('hex').slice(0, 16)}`;
46
+ }
47
+ /**
48
+ * Build a decisions.jsonl log over one directory. Cheap and side-effect
49
+ * free: build it once (apply time), keep the handle, pass it to jevAsk.
50
+ */
51
+ export function createDecisionLog(logDir) {
52
+ const file = decisionsFile(logDir);
53
+ // Serialized like the store's write queue: single-record appends are
54
+ // line-atomic anyway, but the compaction rewrite must not interleave with
55
+ // an append. Per-log, so two logs never block each other.
56
+ let queue = Promise.resolve();
57
+ function enqueue(op) {
58
+ const run = queue.then(op, op);
59
+ queue = run.catch(() => undefined);
60
+ return run;
61
+ }
62
+ /** All write failures die HERE — never upstream. */
63
+ async function appendJsonl(lines) {
64
+ await enqueue(async () => {
65
+ try {
66
+ await mkdir(logDir, { recursive: true });
67
+ await appendFile(file, lines.map((l) => `${l}\n`).join(''), 'utf8');
68
+ await compactIfNeeded(file);
69
+ }
70
+ catch {
71
+ // fail-open: a broken stats sink must never break the feature (§6.1)
72
+ }
73
+ });
74
+ }
75
+ // ~512KB cap; keep the most recent quarter when exceeded (headroom for
76
+ // the next window).
77
+ async function compactIfNeeded(target) {
78
+ let raw;
79
+ try {
80
+ raw = await readFile(target, 'utf8');
81
+ }
82
+ catch {
83
+ return;
84
+ }
85
+ if (raw.length <= COMPACT_LIMIT)
86
+ return;
87
+ const lines = raw.split('\n').filter((l) => l.trim() !== '');
88
+ const keep = lines.slice(-Math.max(1, Math.floor(lines.length / 4)));
89
+ const tmp = `${target}.tmp-${randomUUID()}`;
90
+ try {
91
+ await writeFile(tmp, keep.map((l) => `${l}\n`).join(''), 'utf8');
92
+ await rename(tmp, target);
93
+ }
94
+ catch {
95
+ // swallow — the next append retries the compaction
96
+ }
97
+ }
98
+ /** Call-layer row. Field whitelist enforced here: anything extra on the
99
+ * caller's object is dropped, so no state/conclusion text can sneak in. */
100
+ async function logCall(rec) {
101
+ await appendJsonl([
102
+ JSON.stringify({
103
+ at: rec.at,
104
+ lane: rec.lane,
105
+ backend: rec.backend,
106
+ model: rec.model,
107
+ questionCount: rec.questionCount,
108
+ stateChars: rec.stateChars,
109
+ latencyMs: rec.latencyMs,
110
+ usage: rec.usage === null
111
+ ? null
112
+ : { input_tokens: rec.usage.input_tokens, output_tokens: rec.usage.output_tokens },
113
+ outcome: rec.outcome,
114
+ fallback: rec.fallback,
115
+ }),
116
+ ]);
117
+ }
118
+ /** Verdict-layer rows (batch expanded by the caller). */
119
+ async function logVerdicts(recs) {
120
+ if (recs.length === 0)
121
+ return;
122
+ await appendJsonl(recs.map((rec) => JSON.stringify({
123
+ at: rec.at,
124
+ lane: rec.lane,
125
+ questionId: rec.questionId,
126
+ qtype: rec.qtype,
127
+ ref: rec.ref,
128
+ digest: rec.digest,
129
+ probability: rec.probability,
130
+ band: rec.band,
131
+ agree: rec.agree,
132
+ backend: rec.backend ?? 'primary',
133
+ degraded: rec.degraded === true,
134
+ })));
135
+ }
136
+ return { file: () => file, logCall, logVerdicts };
137
+ }
138
+ //# sourceMappingURL=log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/jev/log.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The three System One seams (jev business wiring) — and ONLY these three
3
+ * places in the plugin may call out to a decision backend.
4
+ *
5
+ * WHERE THEY ARE ALLOWED TO RUN (red line ①, hot paths stay remote-free):
6
+ * - {@link askKeepSuggestions} — the background keep-suggestion batch, fired
7
+ * by the usage counter and awaited by nobody;
8
+ * - {@link rankListQuery} — `mcp_list {query}` only, i.e. when the MODEL
9
+ * explicitly asked for a ranked view;
10
+ * - {@link rankDidYouMean} — `mcp_call`'s unknown-tool branch, i.e. an error
11
+ * the model is going to read anyway.
12
+ * The assemble waterfall, `mcp_list` without a query, and a SUCCESSFUL
13
+ * dispatch never appear in this file's call graph.
14
+ *
15
+ * FAIL-OPEN (red line ②): every export returns a "no opinion" value on ANY
16
+ * failure — disabled, timeout, 5xx, bad JSON, a thrown hook, an unparseable
17
+ * answer. None of them throws. The caller's lexical order then stands, which
18
+ * is the pre-seam behavior; a jev outage is indistinguishable from jev being
19
+ * off.
20
+ *
21
+ * CALIBRATION: the batch in {@link askKeepSuggestions} is the one seam that
22
+ * reads a band (thresholds.ts, kind 'suggest') — and only as a DISPLAY label
23
+ * on a suggestion row, and only for a PRIMARY probability. The two ranking
24
+ * seams log their probabilities with band 'record': a rank is relative
25
+ * ordering, and adopting/vetoing thresholds ported from another plugin's
26
+ * protocol would be a category error. An laya probability is not calibrated at
27
+ * all (dual.ts: relative use only), so a pace-maker answer NEVER takes a band
28
+ * and NEVER becomes a suggestion row — see {@link askKeepSuggestions}.
29
+ *
30
+ * @module jev/seams
31
+ */
32
+ import type { JevDualConfig } from './dual.ts';
33
+ import type { DecisionLog } from './log.ts';
34
+ import type { RankCandidate } from '../rank.ts';
35
+ import type { SuggestCandidate, SuggestionRecord } from '../usage.ts';
36
+ /**
37
+ * Everything a seam needs from the host, read PER CALL so a settings-page
38
+ * write takes effect on the next request (0.1.7 volatile config contract).
39
+ * Production builds one of these in apply() over the live config refs.
40
+ */
41
+ export interface JevRuntime {
42
+ /** The master switch; false means "no call, and no failure". */
43
+ enabled(): boolean;
44
+ /** Resolved backend/model/timeout/secret/pace-maker settings. */
45
+ config(): JevDualConfig;
46
+ /** decisions.jsonl sink (createDecisionLog over the gate directory). */
47
+ log(): DecisionLog;
48
+ }
49
+ /** A tool as the ranking seams see it: a name plus a blurb for the criteria. */
50
+ export type { RankCandidate } from '../rank.ts';
51
+ /** A probability is usable only when it is a finite 0..1 number. */
52
+ export declare function noulProbability(answer: unknown): number | undefined;
53
+ /**
54
+ * Turn one jev ranking answer into an order. A full probability map sorts by
55
+ * probability descending (ties keep the lexical order); a bare `choice` field
56
+ * only moves the winner to the front; anything unparseable reports "no
57
+ * opinion" so the caller keeps the order it already had.
58
+ *
59
+ * @param candidates - the pool the question was asked about, in lexical order.
60
+ * @param answer - the raw answer value for {@link RANK_QUESTION_ID}.
61
+ * @returns the reordered names, or undefined for "no opinion".
62
+ */
63
+ export declare function orderFromChoice(candidates: readonly RankCandidate[], answer: unknown): readonly string[] | undefined;
64
+ /**
65
+ * Seam B: rerank `mcp_list {query}` candidates by relevance.
66
+ *
67
+ * The incoming order is the LEXICAL one (see ../rank.ts) and is also the
68
+ * fail-open result: any failure, disabled switch, or unusable answer returns
69
+ * `undefined`, which the caller reads as "keep what you had".
70
+ *
71
+ * A DEGRADED answer (the local laya pace-maker took over because the primary
72
+ * backend failed) is usable here: relative ordering is exactly what a rank
73
+ * is, and jev/dual.ts's contract allows degraded results for relative use
74
+ * only — which is all this seam does.
75
+ *
76
+ * @param query - the model's query text.
77
+ * @param ordered - lexical order (best first), already capped by the caller.
78
+ * @param env - the live jev runtime.
79
+ * @returns the jev order, or undefined for "no opinion".
80
+ */
81
+ export declare function rankListQuery(query: string, ordered: readonly RankCandidate[], env: JevRuntime): Promise<readonly string[] | undefined>;
82
+ /**
83
+ * Seam C: rerank the near-miss candidates of an unregistered tool name.
84
+ *
85
+ * Same fail-open contract as {@link rankListQuery}. Only runs when the lexical
86
+ * prefilter found MORE THAN ONE candidate — a single lexical hit needs no
87
+ * ranking, so the cold branch stays free when it has nothing to offer.
88
+ *
89
+ * @param tool - the name dispatch could not resolve.
90
+ * @param ordered - lexical near-miss order (best first).
91
+ * @param env - the live jev runtime.
92
+ * @returns the jev order, or undefined for "no opinion".
93
+ */
94
+ export declare function rankDidYouMean(tool: string, ordered: readonly RankCandidate[], env: JevRuntime): Promise<readonly string[] | undefined>;
95
+ /**
96
+ * Seam A: ask, in ONE batched noul request, whether each folded-and-used tool
97
+ * earns a permanent place in every prompt.
98
+ *
99
+ * Fire-and-forget by contract: the caller never awaits this on a hot path.
100
+ * `null` means "this round produced nothing" (disabled, call failed, no
101
+ * candidates, unusable answers, DEGRADED primary) — the caller then keeps the
102
+ * PREVIOUS batch in suggestions.json, and the usage record is unaffected
103
+ * either way.
104
+ *
105
+ * ABSOLUTE SCORES ONLY FROM THE PRIMARY: a suggestion row is a calibrated
106
+ * probability plus a band plus an actionable "keep it resident" line that a
107
+ * human reads in `/mcp suggest`. When the primary failed and the local laya
108
+ * pace-maker took over, that answer is usable for RELATIVE ranking only
109
+ * (dual.ts) and laya's negatives sit in the same band as its positives — so a
110
+ * degraded round returns `null` instead of rendering uncalibrated numbers. The
111
+ * answer is still logged (band 'record', degraded=true): the laya-vs-jev bench
112
+ * and the fallback-rate health metric must not lose a round.
113
+ *
114
+ * @param candidates - folded tools with recorded calls, most-used first.
115
+ * @param env - the live jev runtime.
116
+ * @returns one row per answered candidate, or null.
117
+ */
118
+ export declare function askKeepSuggestions(candidates: readonly SuggestCandidate[], env: JevRuntime): Promise<SuggestionRecord[] | null>;