lynkr 9.14.12 → 9.14.14

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.
@@ -46,6 +46,13 @@ const TIER_DEFINITIONS = {
46
46
  },
47
47
  };
48
48
 
49
+ // Midpoint of each tier band: the score a decision carries when only the
50
+ // tier is known (reconcile caps, Jev overrides, badges). Single source of
51
+ // truth — intent-score and jev-router import this rather than keeping
52
+ // private copies that can drift (midpoints must stay inside the bands
53
+ // above, including calibrated overrides).
54
+ const TIER_MIDPOINT = { SIMPLE: 10, MEDIUM: 35, COMPLEX: 63, REASONING: 88 };
55
+
49
56
  class ModelTierSelector {
50
57
  constructor() {
51
58
  this.tierConfig = null;
@@ -147,6 +154,11 @@ class ModelTierSelector {
147
154
  getTier(complexityScore) {
148
155
  const score = Math.max(0, Math.min(100, complexityScore || 0));
149
156
  const ranges = this.ranges || this._defaultRanges();
157
+ // First match wins in TIER_DEFINITIONS order (SIMPLE, MEDIUM, COMPLEX,
158
+ // REASONING). Calibrated ranges can overlap (observed: MEDIUM [20,50] vs
159
+ // COMPLEX [30,75]) — in the overlap the EARLIER tier wins, so the
160
+ // effective COMPLEX floor is 51, not 30. Keep this in mind when reading
161
+ // data/calibrated-thresholds.json.
150
162
  for (const tier of Object.keys(TIER_DEFINITIONS)) {
151
163
  const [lo, hi] = ranges[tier];
152
164
  if (score >= lo && score <= hi) return tier;
@@ -521,4 +533,5 @@ module.exports = {
521
533
  getModelTierSelector,
522
534
  reloadCalibratedThresholds,
523
535
  TIER_DEFINITIONS,
536
+ TIER_MIDPOINT,
524
537
  };
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tier-driven model selection for the OAuth subscription passthrough.
3
+ *
4
+ * Context: when a picked tier resolves to `azure-anthropic` for a first-party
5
+ * subscription client, src/api/router.js forwards the request byte-for-byte
6
+ * to api.anthropic.com — serving whatever model the CLIENT chose. Tier picks
7
+ * (COMPLEX/sonnet, REASONING/opus, force keywords) were computed and then
8
+ * silently discarded, so escalation could never fire.
9
+ *
10
+ * This module decides the model the passthrough should ACTUALLY serve. The
11
+ * subscription is not model-locked (the same OAuth token serves Haiku, Sonnet
12
+ * and Opus), so rewriting `body.model` to a tier-configured Claude model is
13
+ * accepted upstream. Only the `model` field is ever rewritten — messages,
14
+ * tools, system prompt, thinking blocks and cache_control breakpoints pass
15
+ * through verbatim, which is what keeps Anthropic's prefix cache usable
16
+ * across the switch (same bytes, new model = one cold-prefix turn, then warm).
17
+ *
18
+ * Rules (first match wins):
19
+ * 1. Kill-switch: LYNKR_PASSTHROUGH_MODEL_ROUTING=false → verbatim always.
20
+ * 2. Side-channel tiers (suggestion/autocomplete/bare/drift frames routed to
21
+ * static SIMPLE) → verbatim. Never spend Opus on harness wrapper text.
22
+ * 3. Unknown client model or unrankable tier model → verbatim (fail-closed).
23
+ * 4. Tier model outranks client model → upgrade to the tier model, with one
24
+ * exception (rule 4a): stepping DOWN from a higher pin (pin Opus, fresh
25
+ * verdict Sonnet) consults the downgrade gate for the pin→tier move. A
26
+ * warm Opus prefix survives a Sonnet verdict when break-even blocks;
27
+ * genuine upgrades (no higher pin, or pin at/below the tier) fire
28
+ * ungated — correctness beats one cold turn, same policy as the normal
29
+ * flow's hard triggers (cache-switch-cost.js scope: escalations are
30
+ * never gated). Without 4a every Opus→Sonnet transition destroyed the
31
+ * warm prefix with no math.
32
+ * 5. Pinned (previously served) model outranks client model → downgrade
33
+ * gate: hold the pin (pin_hold) ONLY while real warmth is at stake —
34
+ * cacheState present, fresh (not TTL-cold), recorded for this same
35
+ * model, and warmPrefixTokens >= HOLD_MIN_PREFIX_TOKENS (2000). Otherwise
36
+ * allow the downgrade (nothing warm worth protecting). This is the
37
+ * normal flow's break-even philosophy ported to a flat subscription:
38
+ * dollars can't justify holding (marginal cost is $0), but a large warm
39
+ * prefix is paid latency on every switch, so a large fresh one protects
40
+ * the pin while a cold/small/absent one doesn't.
41
+ * 6. Otherwise → verbatim. A client that explicitly chose Opus keeps Opus
42
+ * even on a SIMPLE-scored turn.
43
+ *
44
+ * Pure functions, no I/O. Never throws.
45
+ */
46
+
47
+ const FAMILY_RANK = [
48
+ { re: /opus/i, rank: 3 },
49
+ { re: /sonnet/i, rank: 2 },
50
+ { re: /haiku/i, rank: 1 },
51
+ ];
52
+
53
+ /**
54
+ * Rank a Claude model id by family. Works on short aliases
55
+ * (claude-sonnet-4-5), dated ids (claude-haiku-4-5-20251001) and
56
+ * provider-prefixed forms. Returns null for anything unrecognized so
57
+ * callers fail closed to verbatim passthrough.
58
+ */
59
+ function familyRank(modelId) {
60
+ if (!modelId || typeof modelId !== 'string') return null;
61
+ for (const f of FAMILY_RANK) {
62
+ if (f.re.test(modelId)) return f.rank;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function isRoutingEnabled() {
68
+ // Default ON. Set LYNKR_PASSTHROUGH_MODEL_ROUTING=false to restore pure
69
+ // byte-for-byte passthrough (pre-routing behavior).
70
+ return process.env.LYNKR_PASSTHROUGH_MODEL_ROUTING !== 'false';
71
+ }
72
+
73
+ function isSideTier(tier) {
74
+ if (!tier || typeof tier !== 'object') return false;
75
+ return tier.pinned === false && String(tier.method || '').startsWith('side_request');
76
+ }
77
+
78
+ // Warm-prefix floor for the downgrade hold (rule 5). Deliberately a
79
+ // constant, not an env knob: one fewer dial, same protection. At/above this
80
+ // many warm tokens the pin holds across lower-scored turns; below it (or
81
+ // cold/absent/stale) the tier falls straight back.
82
+ const HOLD_MIN_PREFIX_TOKENS = 2000;
83
+
84
+ function holdMinPrefixTokens() {
85
+ return HOLD_MIN_PREFIX_TOKENS;
86
+ }
87
+
88
+ /**
89
+ * Resolve the effective tier model for dispatch (pure).
90
+ *
91
+ * A tier object can arrive with a weaker model than its label promises:
92
+ * model-swapping overrides (kNN/bandit) keep the tier label while serving
93
+ * cheaper, the no_user_messages fallback historically carried null, and
94
+ * stale pins replay both shapes indefinitely. On the orchestrator path
95
+ * those dynamics have dollar logic behind them; on the flat-fee
96
+ * subscription passthrough a demotion saves $0 and only risks quality —
97
+ * so here the label wins ties: effective model = max(tierModel,
98
+ * configured(tier)) by family rank. Promotions (stronger-than-configured,
99
+ * e.g. a held Opus on a COMPLEX tier) always survive.
100
+ *
101
+ * @param {object} args
102
+ * @param {string|null} args.tierName - tier label ('COMPLEX', ...)
103
+ * @param {string|null} args.tierModel - model carried on the tier object
104
+ * @param {function|null} args.selectModelFn - (tier) => {model} | null
105
+ * @returns {{model:string|null, resolved:boolean}} resolved=true when the
106
+ * configured model replaced the carried one.
107
+ */
108
+ function resolveTierModel({ tierName = null, tierModel = null, selectModelFn = null } = {}) {
109
+ try {
110
+ let configured = null;
111
+ try {
112
+ configured = (typeof selectModelFn === 'function' && tierName)
113
+ ? selectModelFn(tierName)?.model || null
114
+ : null;
115
+ } catch { configured = null; }
116
+ const rT = familyRank(tierModel);
117
+ const rC = familyRank(configured);
118
+ if (rC !== null && (rT === null || rC > rT)) {
119
+ return { model: configured, resolved: true };
120
+ }
121
+ return { model: tierModel ?? null, resolved: false };
122
+ } catch {
123
+ return { model: tierModel ?? null, resolved: false };
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Downgrade gate (rule 5): is there enough live warmth to justify holding
129
+ * the pinned model instead of following the fresh lower tier down?
130
+ * Mirrors cache-switch-cost.js staleness/TTL handling: stale-model state,
131
+ * TTL-cold state, absent state and small prefixes all allow the downgrade
132
+ * without further math.
133
+ *
134
+ * Above the trivial floor, the decision is dollar break-even via the SAME
135
+ * evaluator the normal flow uses: holding Opus to "save" a re-read is only
136
+ * rational when the re-read costs more than the per-turn premium over the
137
+ * session's expected remaining turns. With Opus ~5x Haiku on output
138
+ * (registry: $25/M vs $5/M) the break-even typically clears in ~1-2 turns,
139
+ * so holds are rare by design — surviving only when the session is nearly
140
+ * over (nothing to amortize a rebuild over), quota pressure has shortened
141
+ * the horizon to ~nothing, or pricing is unknown (fail toward the hold).
142
+ *
143
+ * @param {string|null} pinModel - currently serving (pinned) model
144
+ * @param {object|null} cacheState - sessionAffinity.getCacheState(sessionId)
145
+ * @param {object} [opts]
146
+ * @param {string|null} [opts.downgradeModel] - model a downgrade would serve
147
+ * @param {number|null} [opts.remainingTurns] - expected turns left (null → evaluator default)
148
+ * @param {number|null} [opts.sessionBurnPressure] - 0..1 quota pressure,
149
+ + * threaded into evaluateSwitch (null/0 = untouched horizon)
150
+ * @param {function|null} [opts.evaluateSwitch] - injectable evaluator (tests)
151
+ */
152
+ function shouldHoldForCache(pinModel, cacheState, opts = {}) {
153
+ if (!cacheState || typeof cacheState !== 'object') return null;
154
+ if (cacheState.cold) return { hold: false, reason: 'downgrade_cache_cold' };
155
+ if (cacheState.model && pinModel && cacheState.model !== pinModel) {
156
+ return { hold: false, reason: 'downgrade_cache_stale' };
157
+ }
158
+ const warm = Number(cacheState.warmPrefixTokens) || 0;
159
+ if (warm < holdMinPrefixTokens()) {
160
+ return { hold: false, reason: 'downgrade_prefix_small', warmPrefixTokens: warm };
161
+ }
162
+ try {
163
+ const evaluate = opts.evaluateSwitch
164
+ || require('./cache-switch-cost').evaluateSwitch;
165
+ const ev = evaluate({
166
+ cacheState,
167
+ current: { provider: cacheState.provider || 'azure-anthropic', model: pinModel },
168
+ target: { provider: cacheState.provider || 'azure-anthropic', model: opts.downgradeModel || null },
169
+ expectedRemainingTurns: opts.remainingTurns ?? null,
170
+ ...(opts.sessionBurnPressure != null ? { sessionBurnPressure: opts.sessionBurnPressure } : {}),
171
+ });
172
+ if (ev && ev.switchAllowed) {
173
+ return {
174
+ hold: false,
175
+ reason: 'downgrade_break_even_cleared',
176
+ warmPrefixTokens: warm,
177
+ breakEvenTurns: ev.breakEvenTurns ?? null,
178
+ expectedRemainingTurns: ev.expectedRemainingTurns ?? null,
179
+ };
180
+ }
181
+ return {
182
+ hold: true,
183
+ reason: 'hold_break_even_blocked',
184
+ warmPrefixTokens: warm,
185
+ breakEvenTurns: ev && ev.breakEvenTurns !== undefined ? ev.breakEvenTurns : null,
186
+ expectedRemainingTurns: ev && ev.expectedRemainingTurns !== undefined ? ev.expectedRemainingTurns : null,
187
+ };
188
+ } catch {
189
+ return { hold: true, reason: 'hold_evaluator_failed', warmPrefixTokens: warm };
190
+ }
191
+ }
192
+
193
+ /**
194
+ * @param {object} args
195
+ * @param {string|null} args.tierModel - model from the picked tier's config
196
+ * @param {string|null} args.clientModel - req.body.model (what the client asked for)
197
+ * @param {string|null} [args.pinModel] - model recorded on the session pin (previously served)
198
+ * @param {string|null} [args.tierMethod] - tier.method (side-channel detection)
199
+ * @param {boolean|null} [args.tierPinned] - tier.pinned flag
200
+ * @param {object|null} [args.cacheState] - sessionAffinity.getCacheState(sessionId):
201
+ * {warmPrefixTokens, provider, model, lastRequestAt, ttlMs, cold} or null.
202
+ * Caller (router fork) loads it; kept out of here so this stays pure.
203
+ * @param {number|null} [args.remainingTurns] - expected turns left, for the
204
+ * break-even gate (null → evaluator default). Caller loads via telemetry.
205
+ * @param {number|null} [args.sessionBurnPressure] - 0..1 quota pressure,
206
+ * threaded into both gate calls (null/0 = untouched horizon).
207
+ * @param {function|null} [args.evaluateSwitch] - injectable evaluator (tests).
208
+ * @returns {{model:string|null, action:'verbatim'|'upgrade'|'pin_hold', reason:string, warmPrefixTokens:number|null}}
209
+ */
210
+ function decidePassthroughModel({ tierModel = null, clientModel = null, pinModel = null, tierMethod = null, tierPinned = null, cacheState = null, remainingTurns = null, sessionBurnPressure = null, evaluateSwitch = null } = {}) {
211
+ try {
212
+ if (!isRoutingEnabled()) {
213
+ return { model: clientModel, action: 'verbatim', reason: 'routing_disabled', warmPrefixTokens: null };
214
+ }
215
+ if (isSideTier({ method: tierMethod, pinned: tierPinned })) {
216
+ return { model: clientModel, action: 'verbatim', reason: 'side_request', warmPrefixTokens: null };
217
+ }
218
+ const clientRank = familyRank(clientModel);
219
+ if (clientRank === null) {
220
+ return { model: clientModel, action: 'verbatim', reason: 'unknown_client_model', warmPrefixTokens: null };
221
+ }
222
+ const tierRank = familyRank(tierModel);
223
+ const pinRank = familyRank(pinModel);
224
+ if (tierRank !== null && tierRank > clientRank) {
225
+ // Rule 4a — the upgrade branch can also be a step DOWN from the pin
226
+ // (pin Opus, fresh verdict Sonnet, client Haiku). Consult the gate for
227
+ // the pin→tier move instead of destroying warmth unexamined.
228
+ if (pinRank !== null && pinRank > tierRank) {
229
+ const gate = shouldHoldForCache(pinModel, cacheState, {
230
+ downgradeModel: tierModel,
231
+ remainingTurns,
232
+ sessionBurnPressure,
233
+ ...(evaluateSwitch ? { evaluateSwitch } : {}),
234
+ });
235
+ if (gate && gate.hold) {
236
+ return { model: pinModel, action: 'pin_hold', reason: gate.reason, warmPrefixTokens: gate.warmPrefixTokens ?? null };
237
+ }
238
+ }
239
+ return { model: tierModel, action: 'upgrade', reason: 'tier_outranks_client', warmPrefixTokens: null };
240
+ }
241
+ if (pinRank !== null && pinRank > clientRank) {
242
+ const gate = shouldHoldForCache(pinModel, cacheState, {
243
+ downgradeModel: clientModel,
244
+ remainingTurns,
245
+ sessionBurnPressure,
246
+ ...(evaluateSwitch ? { evaluateSwitch } : {}),
247
+ });
248
+ if (gate && gate.hold) {
249
+ return { model: pinModel, action: 'pin_hold', reason: gate.reason, warmPrefixTokens: gate.warmPrefixTokens ?? null };
250
+ }
251
+ return {
252
+ model: clientModel,
253
+ action: 'verbatim',
254
+ reason: gate ? gate.reason : 'downgrade_no_cache_state',
255
+ warmPrefixTokens: (gate && gate.warmPrefixTokens) ?? null,
256
+ };
257
+ }
258
+ return { model: clientModel, action: 'verbatim', reason: 'no_upgrade', warmPrefixTokens: null };
259
+ } catch {
260
+ return { model: clientModel ?? null, action: 'verbatim', reason: 'error_fail_closed', warmPrefixTokens: null };
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Badge text for a passthrough turn (pure — unit-tested, used by router.js).
266
+ * Three marked variants plus the plain no-op:
267
+ * upgrade → +route (model rewritten up)
268
+ * pin_hold → +hold (model rewritten to the held pin, with gate reason)
269
+ * verbatim + downgrade_* reason → −stepdown (gate-approved descent)
270
+ * otherwise → plain passthrough (true no-op: no_upgrade, side_request…)
271
+ */
272
+ function buildPassthroughBadge({ action = null, reason = null, routeModel = null, clientModel = null, tierName = null, servedModel = null } = {}) {
273
+ if (action === 'upgrade' && routeModel) {
274
+ return `*[Lynkr] subscription-passthrough+route → ${routeModel} (client: ${clientModel || '—'}) · ${tierName || '—'}*`;
275
+ }
276
+ if (action === 'pin_hold' && routeModel) {
277
+ return `*[Lynkr] subscription-passthrough+hold → ${routeModel} (client: ${clientModel || '—'}) · ${tierName || '—'} (${reason || 'cache'})*`;
278
+ }
279
+ if (action === 'verbatim' && typeof reason === 'string' && reason.startsWith('downgrade_')) {
280
+ return `*[Lynkr] subscription-passthrough−stepdown → ${servedModel || '—'} (azure-anthropic) · ${tierName || '—'} (${reason})*`;
281
+ }
282
+ return `*[Lynkr] subscription-passthrough → ${servedModel || '—'} (azure-anthropic)*`;
283
+ }
284
+
285
+ module.exports = { familyRank, isRoutingEnabled, isSideTier, decidePassthroughModel,
286
+ resolveTierModel, shouldHoldForCache, holdMinPrefixTokens, buildPassthroughBadge };
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Subscription quota ledger (Phase 4 — quota-aware descents).
3
+ *
4
+ * Flat-fee subscriptions have no dollar price per token, but they do have a
5
+ * quota bucket: sustained Opus serving drains weekly runway several times
6
+ * faster than Haiku. This module tracks per-session burn in QUOTA UNITS so
7
+ * the downgrade gate can shorten its horizon under pressure (see
8
+ * cache-switch-cost.js `sessionBurnPressure`): high burn → fewer effective
9
+ * remaining turns → holds collapse → descents clear sooner.
10
+ *
11
+ * Units: 1 quota unit = 1000 Haiku-class tokens (input+output blended).
12
+ * Model weight = (inputPerM + outputPerM) / (haiku input+output), resolved
13
+ * via cache-economics with a fail-to-middle default (2.0) for unpriced ids —
14
+ * unknown models should exert middling pressure, never zero, never Opus.
15
+ * Today's registry: haiku 1.0, sonnet-5 2.0, opus-4-8 5.0.
16
+ *
17
+ * Pressure = min(1, windowBurn / BURN_WINDOW_QUOTA) over the trailing window
18
+ * (default 10 turns). BURN_WINDOW_QUOTA is a heuristic constant: ~ten sustained
19
+ * full-context Opus turns. It is deliberately coarse — pressure only shortens
20
+ * the hold horizon, it never forces a descent past the gate's own math, and
21
+ * upgrades/tool-loops/compaction-floors bypass the gate entirely.
22
+ *
23
+ * Pure except for the in-memory session map (no I/O, no DB). Never throws.
24
+ */
25
+
26
+ const BURN_WINDOW_TURNS = 10;
27
+ // ~10 sustained 10k-token Opus turns (10 × 10 × 5.0). Sustained peaks pin
28
+ // pressure at 1; occasional upgrades barely register. Heuristic — see above.
29
+ const BURN_WINDOW_QUOTA = 500;
30
+ const MAX_SESSIONS = 500;
31
+ const UNKNOWN_MODEL_WEIGHT = 2.0;
32
+ const HAIKU_BASELINE_PER_M = 6; // claude-haiku-4-5-20251001: 1 input + 5 output
33
+
34
+ const _windows = new Map(); // sessionId -> number[] (recent turn burns)
35
+
36
+ function _evictIfNeeded() {
37
+ while (_windows.size > MAX_SESSIONS) {
38
+ const oldest = _windows.keys().next();
39
+ if (oldest.done) break;
40
+ _windows.delete(oldest.value);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Quota weight for a model id: output+input price relative to Haiku baseline.
46
+ * @param {string|null} model
47
+ * @param {function|null} [resolveFn] - (provider, model) => {inputPerM, outputPerM} (tests)
48
+ */
49
+ function modelWeight(model, resolveFn = null) {
50
+ try {
51
+ const resolve = resolveFn || require('./cache-economics').resolveCacheEconomics;
52
+ const econ = resolve('azure-anthropic', model);
53
+ if (!econ || typeof econ.inputPerM !== 'number' || typeof econ.outputPerM !== 'number') {
54
+ return UNKNOWN_MODEL_WEIGHT;
55
+ }
56
+ if (econ.unknownPricing) return UNKNOWN_MODEL_WEIGHT;
57
+ return (econ.inputPerM + econ.outputPerM) / HAIKU_BASELINE_PER_M;
58
+ } catch {
59
+ return UNKNOWN_MODEL_WEIGHT;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Record one served turn.
65
+ * @param {string|null} sessionId
66
+ * @param {object} usage - {model, inputTokens, outputTokens}
67
+ * @param {function|null} [resolveFn] - test injection for modelWeight
68
+ */
69
+ function record(sessionId, usage = {}, resolveFn = null) {
70
+ if (!sessionId) return;
71
+ const input = Number(usage.inputTokens) || 0;
72
+ const output = Number(usage.outputTokens) || 0;
73
+ if (input <= 0 && output <= 0) return;
74
+ const burn = ((input + output) / 1000) * modelWeight(usage.model, resolveFn);
75
+ let window = _windows.get(sessionId);
76
+ if (!window) {
77
+ window = [];
78
+ _windows.set(sessionId, window);
79
+ }
80
+ window.push(burn);
81
+ while (window.length > BURN_WINDOW_TURNS) window.shift();
82
+ _evictIfNeeded();
83
+ }
84
+
85
+ /**
86
+ * Current burn pressure for a session: 0 (idle/cheap) → 1 (sustained peaks).
87
+ */
88
+ function pressure(sessionId) {
89
+ const window = _windows.get(sessionId);
90
+ if (!window || window.length === 0) return 0;
91
+ const burn = window.reduce((a, b) => a + b, 0);
92
+ return Math.max(0, Math.min(1, burn / BURN_WINDOW_QUOTA));
93
+ }
94
+
95
+ /** Test/ops helpers. */
96
+ function _reset() { _windows.clear(); }
97
+ function _windowBurn(sessionId) {
98
+ const window = _windows.get(sessionId);
99
+ return window ? window.reduce((a, b) => a + b, 0) : 0;
100
+ }
101
+
102
+ module.exports = {
103
+ record,
104
+ pressure,
105
+ modelWeight,
106
+ BURN_WINDOW_TURNS,
107
+ BURN_WINDOW_QUOTA,
108
+ _reset,
109
+ _windowBurn,
110
+ };
@@ -125,9 +125,22 @@ function findHits(keywords, haystack) {
125
125
  * not what the harness injected; genuinely risky asks live in the typed
126
126
  * text and still fire. (The intent scorers have stripped these blocks
127
127
  * since WS3 — this brings the risk scan in line.)
128
+ *
129
+ * Bare-line MCP boilerplate (HARNESS_BOILERPLATE_LINE) gets the same
130
+ * treatment: auth notices also arrive as PLAIN text outside any tags.
128
131
  * @param {string} text
129
132
  * @returns {string}
130
133
  */
134
+ // Bare-line harness boilerplate that escapes the tag stripping above: MCP
135
+ // auth notices also arrive as PLAIN text (tool results, echoed banners),
136
+ // where the same credential-flavored words detonate the keyword scan —
137
+ // live 2026-09-18: "Can you pull sprint stories…" + an "Authentication
138
+ // successful. Connected to claude.ai BT1_MCP" notice routed REASONING on
139
+ // instructionHits ["authentication"] the user never typed. Full-LINE match
140
+ // only: these are fixed harness templates, never user prose — typed risky
141
+ // asks ("disable the authentication check") span the line differently and
142
+ // keep firing. Tool_use inputs are untouched (real activity still counts).
143
+ const HARNESS_BOILERPLATE_LINE = /^\s*(authentication successful[.!]?(\s+connected to .+)?|connected to \S+ MCP\.?|(\d+\s+)?MCP servers? need authentication\b.*|run \/mcp\b.*)\s*$/i;
131
144
  function stripSystemReminders(text) {
132
145
  if (typeof text !== 'string' || !text) return '';
133
146
  return text
@@ -139,7 +152,10 @@ function stripSystemReminders(text) {
139
152
  // "Hi", force-escalated to COMPLEX on it) and <user_instructions>
140
153
  // carries AGENTS.md contents the user never typed this turn.
141
154
  .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
142
- .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ');
155
+ .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ')
156
+ .split('\n')
157
+ .filter((line) => !HARNESS_BOILERPLATE_LINE.test(line))
158
+ .join('\n');
143
159
  }
144
160
 
145
161
  /**
@@ -181,6 +181,14 @@ function init() {
181
181
  // NULL on rows where the bandit didn't run (deterministic decisions)
182
182
  // or recorded before this column existed.
183
183
  ["context", "TEXT"],
184
+ // Jev routing judge — shadow verdict recorded alongside every scored
185
+ // decision so heuristic/Jev eras stay separable. NULL when Jev didn't
186
+ // run (pinned serves, side frames, no key) or predates this column.
187
+ ["jev_verdict", "TEXT"],
188
+ ["jev_confidence", "REAL"],
189
+ ["jev_probabilities", "TEXT"],
190
+ ["jev_model", "TEXT"],
191
+ ["criteria_hash", "TEXT"],
184
192
  ];
185
193
  for (const [col, type] of additiveCols) {
186
194
  if (!existingCols.has(col)) {
@@ -243,7 +251,8 @@ function record(data) {
243
251
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
244
252
  cost_efficiency, request_text, response_text,
245
253
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
246
- cache_decision, cache_read_tokens, cache_creation_tokens, context
254
+ cache_decision, cache_read_tokens, cache_creation_tokens, context,
255
+ jev_verdict, jev_confidence, jev_probabilities, jev_model, criteria_hash
247
256
  ) VALUES (
248
257
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
249
258
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -252,7 +261,8 @@ function record(data) {
252
261
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
253
262
  @cost_efficiency, @request_text, @response_text,
254
263
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
255
- @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
264
+ @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context,
265
+ @jev_verdict, @jev_confidence, @jev_probabilities, @jev_model, @criteria_hash
256
266
  )`
257
267
  );
258
268
  if (!insert) return;
@@ -308,6 +318,13 @@ function record(data) {
308
318
  context: data.context == null
309
319
  ? null
310
320
  : (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
321
+ jev_verdict: data.jev_verdict ?? null,
322
+ jev_confidence: data.jev_confidence ?? null,
323
+ jev_probabilities: data.jev_probabilities === null || data.jev_probabilities === undefined
324
+ ? null
325
+ : (typeof data.jev_probabilities === "string" ? data.jev_probabilities : JSON.stringify(data.jev_probabilities)),
326
+ jev_model: data.jev_model ?? null,
327
+ criteria_hash: data.criteria_hash ?? null,
311
328
  });
312
329
  } catch (err) {
313
330
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -1136,8 +1153,36 @@ function getAnalytics(opts = {}) {
1136
1153
  }
1137
1154
  }
1138
1155
 
1156
+ /**
1157
+ * Map a Jev verdict (decision.jev or analysis.jev shape) onto the
1158
+ * jev_* telemetry columns. Null-safe: anything missing yields all-null
1159
+ * fields so call sites spread this unconditionally.
1160
+ */
1161
+ function jevFields(src) {
1162
+ const j = (src && typeof src === 'object')
1163
+ ? (src.jev && typeof src.jev === 'object' ? src.jev
1164
+ : (src.analysis && typeof src.analysis === 'object' && src.analysis.jev ? src.analysis.jev : null))
1165
+ : null;
1166
+ if (!j) {
1167
+ return {
1168
+ jev_verdict: null, jev_confidence: null, jev_probabilities: null,
1169
+ jev_model: null, criteria_hash: null,
1170
+ };
1171
+ }
1172
+ return {
1173
+ jev_verdict: j.tier ?? null,
1174
+ jev_confidence: j.confidence ?? null,
1175
+ jev_probabilities: j.probabilities === null || j.probabilities === undefined
1176
+ ? null
1177
+ : (typeof j.probabilities === 'string' ? j.probabilities : JSON.stringify(j.probabilities)),
1178
+ jev_model: j.model ?? null,
1179
+ criteria_hash: j.criteriaHash ?? j.criteria_hash ?? null,
1180
+ };
1181
+ }
1182
+
1139
1183
  module.exports = {
1140
1184
  record,
1185
+ jevFields,
1141
1186
  query,
1142
1187
  getStats: getStatsCached,
1143
1188
  getProviderStats: getProviderStatsCached,