lynkr 9.14.17 → 9.14.18

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.
@@ -34,6 +34,16 @@ const logger = require('../logger');
34
34
  * tool, add it here (or override via data/client-profiles.json).
35
35
  */
36
36
  const PROFILES = {
37
+ 'terminus': {
38
+ name: 'terminus',
39
+ toolless: true,
40
+ baselineTools: new Set(),
41
+ detect: {
42
+ headerPatterns: [],
43
+ promptPatterns: [/You are an AI assistant tasked with solving command-line tasks/i],
44
+ minToolFingerprintMatch: 1,
45
+ },
46
+ },
37
47
  'claude-code': {
38
48
  name: 'claude-code',
39
49
  baselineTools: new Set([
@@ -257,6 +267,18 @@ function detectClient({ headers = {}, payload = {} } = {}) {
257
267
  }
258
268
  }
259
269
 
270
+ const firstUser = Array.isArray(payload.messages) ? payload.messages.find((m) => m?.role === 'user') : null;
271
+ if (firstUser) {
272
+ const txt = typeof firstUser.content === 'string' ? firstUser.content
273
+ : Array.isArray(firstUser.content) ? firstUser.content.filter((b) => b?.type === 'text').map((b) => b.text || '').join(' ') : '';
274
+ if (txt) {
275
+ for (const profile of Object.values(PROFILES)) {
276
+ for (const pattern of (profile.detect.promptPatterns || [])) {
277
+ if (pattern.test(txt)) return profile;
278
+ }
279
+ }
280
+ }
281
+ }
260
282
  const tools = Array.isArray(payload.tools) ? payload.tools : [];
261
283
  if (tools.length === 0) return null;
262
284
 
@@ -421,6 +421,11 @@ function extractContent(payload) {
421
421
  if (!payload?.messages || !Array.isArray(payload.messages)) {
422
422
  return '';
423
423
  }
424
+ {
425
+ const { harnessAskFromPayload } = require('./harness-envelope');
426
+ const ask = harnessAskFromPayload(payload);
427
+ if (ask) return ask.text;
428
+ }
424
429
 
425
430
  // Get last user message. Harness envelopes (Cursor's <user_info>/<rules>/
426
431
  // attached context inside the user message) are stripped so force patterns,
@@ -983,6 +988,7 @@ function shouldForceLocal(payload) {
983
988
  * Quick check if request should be forced to cloud
984
989
  */
985
990
  function shouldForceCloud(payload) {
991
+ if (process.env.FORCE_TIER_PATTERNS === "false") return false;
986
992
  const content = extractContent(payload);
987
993
  const matched = FORCE_CLOUD_PATTERNS.find(pattern => pattern.test(content));
988
994
  if (matched) {
@@ -997,6 +1003,7 @@ function shouldForceCloud(payload) {
997
1003
  * from first principles, security audit.
998
1004
  */
999
1005
  function shouldForceReasoning(payload) {
1006
+ if (process.env.FORCE_TIER_PATTERNS === "false") return false;
1000
1007
  const content = extractContent(payload);
1001
1008
  const matched = FORCE_REASONING_PATTERNS.find(pattern => pattern.test(content));
1002
1009
  if (matched) {
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Decision engine: evaluate signals, walk decisions by priority, first match
3
+ * wins. Returns a result with a full trace so `lynkr route --preview` and
4
+ * telemetry can explain every choice.
5
+ *
6
+ * In "observe" mode the engine's tier is recorded but the legacy chain's tier
7
+ * is served. In "enforce" mode routing/index.js adopts the engine's tier when
8
+ * it differs. The default config has a single `legacy` decision whose tier is
9
+ * `from:legacy`, so a fresh install agrees with the legacy chain by
10
+ * construction and operators add decisions above it.
11
+ */
12
+ 'use strict';
13
+
14
+ const rc = require('./routing-config');
15
+ const signalsMod = require('./signals');
16
+
17
+ function _evalRule(rule, signals) {
18
+ const op = String(rule.operator || 'AND').toUpperCase();
19
+ const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
20
+ const results = conds.map((c) => (c && typeof c.operator === 'string') ? _evalRule(c, signals) : signalsMod.testCondition(c, signals));
21
+ if (op === 'NOT') return !results[0];
22
+ if (op === 'OR') return results.some(Boolean);
23
+ return results.every(Boolean); // AND; empty = true
24
+ }
25
+
26
+ function _resolveTier(spec, ctx, signals) {
27
+ if (!spec) return null;
28
+ if (spec === 'from:legacy') return ctx.legacy?.tier || null;
29
+ if (spec === 'from:anchor') return signals.anchor?.band || ctx.legacy?.tier || null;
30
+ if (spec === 'from:judge') return signals.judge?.tier || ctx.legacy?.tier || null;
31
+ return rc.TIERS.includes(spec) ? spec : null;
32
+ }
33
+
34
+ /**
35
+ * @param {object} ctx — see signals.js
36
+ * @returns {{ decision, tier, effort, hosts, plugins, mode, signals, trace }}
37
+ */
38
+ async function evaluate(ctx, config) {
39
+ const cfg = config || rc.load();
40
+ const signals = await signalsMod.evaluateAll(ctx, cfg);
41
+ return decideFromSignals(signals, ctx, cfg);
42
+ }
43
+
44
+ /**
45
+ * Pure decision step over already-evaluated signals. Used by evaluate() and
46
+ * by the corpus regression test (fixtures store signal snapshots so the test
47
+ * is deterministic without embeddings or the judge).
48
+ */
49
+ function decideFromSignals(signals, ctx, config) {
50
+ const cfg = config || rc.load();
51
+ const considered = [];
52
+ let matched = null;
53
+ for (const d of cfg.decisions) {
54
+ let ok = false;
55
+ try { ok = _evalRule(d.rules || { operator: 'AND', conditions: [] }, signals); } catch { ok = false; }
56
+ considered.push({ name: d.name, priority: d.priority, matched: ok });
57
+ if (ok) { matched = d; break; }
58
+ }
59
+ const tier = matched ? _resolveTier(matched.tier, ctx, signals) : (ctx.legacy?.tier || null);
60
+ // Cascade-on-ambiguity trigger: when the judge's top-two tier probabilities
61
+ // are within `trigger_margin`, the request is ambiguous. Observe mode only
62
+ // records the trigger (telemetry: cascade_trigger); enforce is wired later.
63
+ let cascade = null;
64
+ const cc = matched?.cascade && typeof matched.cascade === 'object' ? matched.cascade : (cfg.cascade || null);
65
+ if (cc) {
66
+ const probs = signals.judge?.probabilities || null;
67
+ if (probs) {
68
+ const sorted = Object.values(probs).map(Number).filter(Number.isFinite).sort((a, b) => b - a);
69
+ const margin = sorted.length >= 2 ? sorted[0] - sorted[1] : 1;
70
+ const thr = Number.isFinite(cc.trigger_margin) ? cc.trigger_margin : 0.15;
71
+ cascade = { triggered: margin < thr, margin: Math.round(margin * 1000) / 1000, mode: cc.mode || 'observe', escalate_to: cc.escalate_to || null };
72
+ }
73
+ }
74
+ return {
75
+ decision: matched ? matched.name : null,
76
+ tier,
77
+ effort: matched?.effort ?? null,
78
+ hosts: Array.isArray(matched?.hosts) ? matched.hosts : null,
79
+ plugins: matched?.plugins && typeof matched.plugins === 'object' ? matched.plugins : null,
80
+ mode: cfg.mode === 'enforce' ? 'enforce' : 'observe',
81
+ cascade,
82
+ agreesWithLegacy: !ctx.legacy?.tier || tier === ctx.legacy.tier,
83
+ signals: Object.fromEntries(Object.entries(signals).map(([k, v]) => [k, { matched: v.matched, value: v.value, confidence: v.confidence, band: v.band, tier: v.tier }])),
84
+ trace: { considered, legacy: ctx.legacy || null },
85
+ };
86
+ }
87
+
88
+ /** Compact header-safe summary. */
89
+ function headerSummary(result) {
90
+ if (!result) return {};
91
+ const sig = Object.entries(result.signals || {}).filter(([, v]) => v.matched).map(([k, v]) => `${k}=${v.band || v.tier || (typeof v.value === 'object' ? 'y' : v.value)}`).join(',');
92
+ return {
93
+ 'X-Lynkr-Decision': result.decision || 'none',
94
+ 'X-Lynkr-Decision-Tier': result.tier || '',
95
+ 'X-Lynkr-Decision-Mode': result.mode,
96
+ 'X-Lynkr-Signals': sig.slice(0, 500),
97
+ };
98
+ }
99
+
100
+ module.exports = { evaluate, decideFromSignals, headerSummary, mode: rc.mode };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Grounding check for completion claims (HaluGate-style, local NLI).
3
+ *
4
+ * When a reply claims the task is done, check whether the evidence the model
5
+ * had — the most recent tool output / terminal snapshot — supports that claim.
6
+ * A small NLI cross-encoder (default Xenova/nli-deberta-v3-small, int8, CPU,
7
+ * ~25 ms per pair) labels each claim entailment / neutral / contradiction.
8
+ *
9
+ * verdict: 'supported' | 'unverified' | 'contradicted' | 'skipped'
10
+ *
11
+ * Policy is per decision (routing.json → decisions[].plugins.grounding):
12
+ * { mode: "observe" | "flag" | "veto", contradiction_threshold: 0.6 }
13
+ * observe: telemetry only; flag: + X-Lynkr-Grounding header; veto: caller
14
+ * re-asks once with the contradiction quoted (not enabled anywhere yet).
15
+ *
16
+ * The transformers runtime is loaded lazily from LYNKR_NLI_LIB_PATH or the
17
+ * normal module path; if absent the module reports 'skipped' and never
18
+ * throws. Model files cache under LYNKR_MODELS_DIR (default ~/lynkr-models/hf).
19
+ */
20
+ 'use strict';
21
+
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const logger = require('../logger');
25
+
26
+ const MODEL_ID = process.env.LYNKR_NLI_MODEL || 'Xenova/nli-deberta-v3-small';
27
+ const MAX_EVIDENCE_CHARS = 4000;
28
+ const MAX_CLAIM_CHARS = 400;
29
+ let _loading = null; // Promise<{tok, model, labels}> | null
30
+ let _unavailable = false;
31
+
32
+ function _lib() {
33
+ const p = process.env.LYNKR_NLI_LIB_PATH;
34
+ try { return p ? require(path.join(p, '@huggingface', 'transformers')) : require('@huggingface/transformers'); } catch { return null; }
35
+ }
36
+
37
+ async function _load() {
38
+ if (_unavailable) return null;
39
+ if (_loading) return _loading;
40
+ _loading = (async () => {
41
+ const tf = _lib();
42
+ if (!tf) { _unavailable = true; logger.warn('[Grounding] @huggingface/transformers not available — grounding disabled'); return null; }
43
+ tf.env.cacheDir = process.env.LYNKR_MODELS_DIR || path.join(os.homedir(), 'lynkr-models', 'hf');
44
+ tf.env.allowLocalModels = true;
45
+ const t0 = Date.now();
46
+ const tok = await tf.AutoTokenizer.from_pretrained(MODEL_ID);
47
+ const model = await tf.AutoModelForSequenceClassification.from_pretrained(MODEL_ID, { dtype: process.env.LYNKR_NLI_DTYPE || 'q8' });
48
+ const labels = Object.fromEntries(Object.entries(model.config.id2label || {}).map(([i, l]) => [Number(i), String(l).toLowerCase()]));
49
+ logger.info({ model: MODEL_ID, ms: Date.now() - t0 }, '[Grounding] NLI model loaded');
50
+ return { tf, tok, model, labels };
51
+ })().catch((err) => { _unavailable = true; logger.warn({ err: err.message }, '[Grounding] model load failed — grounding disabled'); return null; });
52
+ return _loading;
53
+ }
54
+
55
+ /** Pull completion-bearing claims out of a reply (structured agents and prose). */
56
+ function extractClaims(replyText) {
57
+ if (typeof replyText !== 'string' || !replyText.trim()) return { claimsDone: false, claims: [] };
58
+ const s = replyText.indexOf('{'), e = replyText.lastIndexOf('}');
59
+ if (s !== -1 && e > s) {
60
+ try {
61
+ const obj = JSON.parse(replyText.slice(s, e + 1));
62
+ if (obj && typeof obj === 'object' && 'is_task_complete' in obj) {
63
+ const claims = [];
64
+ for (const k of ['state_analysis', 'explanation', 'summary', 'result']) if (typeof obj[k] === 'string' && obj[k].trim()) claims.push(obj[k].trim().slice(0, MAX_CLAIM_CHARS));
65
+ return { claimsDone: obj.is_task_complete === true, claims };
66
+ }
67
+ } catch { /* fall through to prose */ }
68
+ }
69
+ const done = /\b(task (is )?(now )?complete|completed successfully|all (tests )?pass(ed)?|successfully (created|written|installed|fixed)|is now (working|fixed|done))\b/i.test(replyText);
70
+ const sentences = replyText.split(/(?<=[.!?])\s+/).filter((x) => /\b(complete|done|pass|success|written|created|fixed|installed|verified)\b/i.test(x)).slice(0, 4).map((x) => x.trim().slice(0, MAX_CLAIM_CHARS));
71
+ return { claimsDone: done, claims: sentences };
72
+ }
73
+
74
+ /** Evidence = the last user-turn text (tool results / terminal output), tail-truncated. */
75
+ function extractEvidence(messages) {
76
+ if (!Array.isArray(messages)) return '';
77
+ for (let i = messages.length - 1; i >= 0; i--) {
78
+ const m = messages[i]; if (m?.role !== 'user') continue;
79
+ let t = '';
80
+ if (typeof m.content === 'string') t = m.content;
81
+ else if (Array.isArray(m.content)) t = m.content.map((b) => typeof b?.text === 'string' ? b.text : (b?.type === 'tool_result' ? (typeof b.content === 'string' ? b.content : JSON.stringify(b.content || '')) : '')).join('\n');
82
+ if (t.trim()) return t.length > MAX_EVIDENCE_CHARS ? t.slice(-MAX_EVIDENCE_CHARS) : t;
83
+ }
84
+ return '';
85
+ }
86
+
87
+ /**
88
+ * @returns {Promise<{verdict, claimsDone, pairs:[{claim, entailment, neutral, contradiction}], maxContradiction, minEntailment, ms}>}
89
+ */
90
+ async function check({ replyText, messages, evidence = null, onlyWhenDone = true, threshold = 0.6 }) {
91
+ const t0 = Date.now();
92
+ const { claimsDone, claims } = extractClaims(replyText);
93
+ if (onlyWhenDone && !claimsDone) return { verdict: 'skipped', reason: 'no_completion_claim', claimsDone, pairs: [], ms: Date.now() - t0 };
94
+ const ev = evidence != null ? String(evidence) : extractEvidence(messages);
95
+ if (!ev.trim()) return { verdict: 'unverified', reason: 'no_evidence', claimsDone, pairs: [], ms: Date.now() - t0 };
96
+ if (!claims.length) return { verdict: 'unverified', reason: 'no_claims', claimsDone, pairs: [], ms: Date.now() - t0 };
97
+ const rt = await _load();
98
+ if (!rt) return { verdict: 'skipped', reason: 'nli_unavailable', claimsDone, pairs: [], ms: Date.now() - t0 };
99
+ const pairs = [];
100
+ for (const claim of claims) {
101
+ const inputs = rt.tok(ev, { text_pair: claim, truncation: true, max_length: 512 });
102
+ const out = await rt.model(inputs);
103
+ const probs = rt.tf.softmax(Array.from(out.logits.data));
104
+ const row = { claim };
105
+ for (const [i, l] of Object.entries(rt.labels)) row[l] = Math.round(probs[i] * 1000) / 1000;
106
+ pairs.push(row);
107
+ }
108
+ const maxContradiction = Math.max(...pairs.map((p) => p.contradiction || 0));
109
+ const minEntailment = Math.min(...pairs.map((p) => p.entailment || 0));
110
+ const anyEntail = pairs.some((p) => (p.entailment || 0) >= 0.5);
111
+ const verdict = maxContradiction >= threshold ? 'contradicted' : (anyEntail ? 'supported' : 'unverified');
112
+ return { verdict, claimsDone, pairs, maxContradiction, minEntailment, ms: Date.now() - t0 };
113
+ }
114
+
115
+ function available() { return !_unavailable && !!_lib(); }
116
+ function warm() { return _load(); }
117
+
118
+ module.exports = { check, extractClaims, extractEvidence, available, warm, MODEL_ID };
@@ -48,6 +48,73 @@ const UNCLOSED_RES = ENVELOPE_TAGS.map(
48
48
  );
49
49
  const USER_QUERY_RE = /<user_query(?:\s[^>]*)?>([\s\S]*?)<\/user_query>/gi;
50
50
 
51
+ const HARNESS_PREAMBLE_RES = [
52
+ /You are an AI assistant tasked with solving command-line tasks/i,
53
+ /"title":\s*"CommandBatchResponse"/,
54
+ ];
55
+ const HARNESS_INSTRUCTION_RE = /(?:^|\n)Instruction:\s*\n([\s\S]*?)\n\s*\n(?:Your response must be|Your response|Respond)/i;
56
+
57
+ function _patterns() {
58
+ try {
59
+ const pats = require('./routing-config').harnessPatterns();
60
+ if (pats.length) return pats;
61
+ } catch { /* fall back to built-ins */ }
62
+ return [{ name: 'harness', preamble: HARNESS_PREAMBLE_RES[0], instruction: HARNESS_INSTRUCTION_RE }];
63
+ }
64
+
65
+ function matchHarness(text) {
66
+ if (typeof text !== 'string') return null;
67
+ for (const p of _patterns()) if (p.preamble.test(text)) return p;
68
+ return HARNESS_PREAMBLE_RES.some((re) => re.test(text)) ? { name: 'harness', preamble: null, instruction: HARNESS_INSTRUCTION_RE } : null;
69
+ }
70
+
71
+ function isHarnessPrompt(text) {
72
+ return matchHarness(text) !== null;
73
+ }
74
+
75
+ /**
76
+ * @param {string} text - one user message's text
77
+ * @returns {string|null} the task instruction when `text` is a recognised
78
+ * instruction-schema harness prompt; null otherwise (not a harness prompt,
79
+ * or the instruction block could not be isolated).
80
+ */
81
+ function extractHarnessInstruction(text) {
82
+ const p = matchHarness(text);
83
+ if (!p) return null;
84
+ const re = p.instruction || HARNESS_INSTRUCTION_RE;
85
+ const m = re.exec(text);
86
+ const instr = m ? m[1].trim() : '';
87
+ return instr.length > 0 ? instr : null;
88
+ }
89
+
90
+ function _msgText(msg) {
91
+ if (!msg) return '';
92
+ if (typeof msg.content === 'string') return msg.content;
93
+ if (Array.isArray(msg.content)) {
94
+ return msg.content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join(' ');
95
+ }
96
+ return '';
97
+ }
98
+
99
+ /**
100
+ * Harness-aware ask for a whole payload: when the FIRST user message is a
101
+ * recognised instruction-schema harness prompt, the task instruction is the
102
+ * ask for every turn of the session (later user turns are terminal output).
103
+ * @param {object} payload - { messages }
104
+ * @returns {{ text: string, index: number }|null}
105
+ */
106
+ function harnessAskFromPayload(payload) {
107
+ const msgs = payload?.messages;
108
+ if (!Array.isArray(msgs)) return null;
109
+ for (let i = 0; i < msgs.length; i++) {
110
+ if (msgs[i]?.role !== 'user') continue;
111
+ const txt = _msgText(msgs[i]);
112
+ const instr = extractHarnessInstruction(txt);
113
+ return instr ? { text: instr, index: i, name: (matchHarness(txt) || {}).name || 'harness' } : null;
114
+ }
115
+ return null;
116
+ }
117
+
51
118
  /**
52
119
  * @param {string} text - one user message's text content
53
120
  * @returns {string} the user's ask with harness envelope blocks removed;
@@ -55,6 +122,9 @@ const USER_QUERY_RE = /<user_query(?:\s[^>]*)?>([\s\S]*?)<\/user_query>/gi;
55
122
  */
56
123
  function stripHarnessEnvelope(text) {
57
124
  if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : '';
125
+ // Instruction-schema harness prompt → the ask IS the instruction block.
126
+ const harnessInstr = extractHarnessInstruction(text);
127
+ if (harnessInstr) return harnessInstr;
58
128
  try {
59
129
  if (!text.includes('<')) return text;
60
130
  const queries = [...text.matchAll(USER_QUERY_RE)].map((m) => m[1].trim()).filter(Boolean);
@@ -68,4 +138,4 @@ function stripHarnessEnvelope(text) {
68
138
  }
69
139
  }
70
140
 
71
- module.exports = { stripHarnessEnvelope, ENVELOPE_TAGS };
141
+ module.exports = { stripHarnessEnvelope, ENVELOPE_TAGS, isHarnessPrompt, matchHarness, extractHarnessInstruction, harnessAskFromPayload };
@@ -680,6 +680,33 @@ async function checkPinScoreDrift(pin, payload) {
680
680
  * @returns {{serve:boolean, pin?:object, reason:string, sessionId:string|null}}
681
681
  */
682
682
  function checkSessionPin(payload, options = {}) {
683
+ const result = _checkSessionPinInner(payload, options);
684
+ // Switch gate (switch-gate.js): hysteresis over the session's attributable
685
+ // turn outcomes. Observe mode annotates; enforce mode drops the pin and
686
+ // sets a floor so the fresh route that follows lands on the target tier.
687
+ try {
688
+ if (result && result.serve && result.pin && result.sessionId) {
689
+ const gate = require('./switch-gate');
690
+ const ring = require('./outcomes').ring(result.sessionId);
691
+ const turns = Array.isArray(payload?.messages) ? payload.messages.filter((m) => m?.role === 'assistant').length : null;
692
+ const g = gate.evaluate({ sessionId: result.sessionId, currentTier: result.pin.tier, ring, turn: turns, baseTier: result.pin.baseTier || result.pin.tier });
693
+ result.gate = g;
694
+ if (g.action !== 'stay') {
695
+ logger.info({ sessionId: result.sessionId, action: g.action, target: g.target, reason: g.reason, enforced: g.enforced, pinnedTier: result.pin.tier }, g.enforced ? '[SwitchGate] switch enforced — pin dropped' : '[SwitchGate] would switch (observe)');
696
+ if (g.enforced) {
697
+ gate.commit(result.sessionId, g, turns);
698
+ sessionAffinity.removePin(result.sessionId);
699
+ return { serve: false, pin: result.pin, sessionId: result.sessionId, reason: `switch_gate_${g.action}`, gate: g };
700
+ }
701
+ }
702
+ }
703
+ } catch (err) {
704
+ logger.debug({ err: err.message }, '[SwitchGate] evaluation failed (ignored)');
705
+ }
706
+ return result;
707
+ }
708
+
709
+ function _checkSessionPinInner(payload, options = {}) {
683
710
  const sessionId = payload?._sessionId || null;
684
711
  const stickyEnabled = process.env.LYNKR_STICKY_SESSIONS !== 'false';
685
712
  if (!stickyEnabled || !sessionId || options.forceProvider) {
@@ -937,13 +964,18 @@ async function _determineProviderSmartInner(payload, options = {}) {
937
964
  // Ollama (~200ms). Both are best-effort and fall through as null.
938
965
  let queryText = null;
939
966
  let queryEmbedding = null;
940
- if (config.routing?.knnEnabled !== false) {
967
+ if (config.routing?.knnEnabled !== false && process.env.LYNKR_KNN_ENABLED !== 'false') {
941
968
  try {
942
969
  const msgs = payload?.messages;
943
- const lastMsg = Array.isArray(msgs) ? msgs[msgs.length - 1]?.content : null;
944
- queryText = typeof lastMsg === 'string' ? lastMsg
945
- : Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
946
- : null;
970
+ const _harnessAsk = require('./harness-envelope').harnessAskFromPayload(payload);
971
+ if (_harnessAsk) {
972
+ queryText = _harnessAsk.text;
973
+ } else {
974
+ const lastMsg = Array.isArray(msgs) ? msgs[msgs.length - 1]?.content : null;
975
+ queryText = typeof lastMsg === 'string' ? lastMsg
976
+ : Array.isArray(lastMsg) ? lastMsg.filter(b => b?.type === 'text').map(b => b.text || '').join(' ')
977
+ : null;
978
+ }
947
979
  if (queryText) {
948
980
  queryEmbedding = await getKnnRouter().embed(queryText);
949
981
  }
@@ -967,7 +999,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
967
999
  // High-risk requests jump straight to COMPLEX and skip the rest of
968
1000
  // the analysis. This is independent of complexity score — a one-line
969
1001
  // edit to auth/middleware.ts should never go to a local model.
970
- if (risk?.level === 'high' && isFallbackEnabled()) {
1002
+ if (process.env.RISK_TIER_ESCALATION !== 'false' && risk?.level === 'high' && isFallbackEnabled()) {
971
1003
  try {
972
1004
  const selector = getModelTierSelector();
973
1005
  // Config B (local → GLM → Claude): high-risk requests route to the
@@ -1399,7 +1431,9 @@ async function _determineProviderSmartInner(payload, options = {}) {
1399
1431
  && analysis?.mode === 'weighted' && analysis?.breakdown) {
1400
1432
  const { buildRequirementVector } = require('./capabilities');
1401
1433
  const sf = require('./shortfall');
1402
- const req = buildRequirementVector({ dimensions: analysis.breakdown, agenticResult });
1434
+ const _structuralReq = buildRequirementVector({ dimensions: analysis.breakdown, agenticResult });
1435
+ const _lifted = sf.liftRequirement(_structuralReq, { anchorScore: analysis.anchorScore, jev: analysis.jev });
1436
+ const req = _lifted.req;
1403
1437
  // Candidates constrained to the user's TIER_* (same eligibility rule
1404
1438
  // as the bandit in decide.js) with tier labels attached for capability
1405
1439
  // resolution. Dedupe identical provider:model keeping the highest tier.
@@ -1452,6 +1486,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
1452
1486
  const agreed = serveResult.selected.provider === provider && serveResult.selected.model === selectedModel;
1453
1487
  shortfallInfo = {
1454
1488
  req,
1489
+ structuralReq: _structuralReq,
1490
+ lift: _lifted.lift.applied,
1455
1491
  tau: result.tau,
1456
1492
  selected: serveResult.selected,
1457
1493
  wanted: result.selected,
@@ -1460,6 +1496,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
1460
1496
  };
1461
1497
  logger.debug({
1462
1498
  req,
1499
+ structuralReq: _structuralReq,
1500
+ lift: _lifted.lift.applied,
1463
1501
  tau: result.tau,
1464
1502
  legacy: `${tier}:${provider}:${selectedModel}`,
1465
1503
  shortfall: `${result.selected.tier}:${result.selected.provider}:${result.selected.model}`,
@@ -1742,6 +1780,24 @@ async function _determineProviderSmartInner(payload, options = {}) {
1742
1780
  // Confidence thresholds (env-configurable; defaults 0.7 high / 0.4 low):
1743
1781
  const KNN_HIGH = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_HIGH) || 0.7;
1744
1782
  const KNN_LOW = Number.parseFloat(process.env.LYNKR_KNN_CONFIDENCE_LOW) || 0.4;
1783
+ if (knnResult && process.env.LYNKR_KNN_ENABLED === 'false') {
1784
+ knnResult = null;
1785
+ }
1786
+ if (knnResult && knnResult.model) {
1787
+ try {
1788
+ const _sel = getModelTierSelector();
1789
+ const _allowed = new Set();
1790
+ for (const _t of TIER_ORDER) for (const _m of (_sel.getModelsForTier(_t) || [])) _allowed.add(`${_m.provider}:${_m.model}`);
1791
+ const _pick = `${knnResult.provider}:${knnResult.model}`;
1792
+ if (_allowed.size > 0 && !_allowed.has(_pick)) {
1793
+ logger.info({ pick: _pick, confidence: knnResult.confidence?.toFixed?.(3) }, '[Routing] kNN pick not in configured tiers — ignored');
1794
+ knnResult = { ...knnResult, model: null, provider: null };
1795
+ }
1796
+ } catch (err) {
1797
+ logger.debug({ err: err?.message }, '[Routing] kNN tier validation failed — pick ignored');
1798
+ knnResult = { ...knnResult, model: null, provider: null };
1799
+ }
1800
+ }
1745
1801
  if (knnResult && knnResult.confidence > KNN_HIGH && knnResult.model && knnResult.model !== selectedModel) {
1746
1802
  // High confidence — trust kNN's model recommendation directly.
1747
1803
  logger.debug({
@@ -1898,6 +1954,46 @@ async function _determineProviderSmartInner(payload, options = {}) {
1898
1954
  }
1899
1955
  }
1900
1956
 
1957
+ // Switch-gate floor: an enforced escalation set a per-session floor; the
1958
+ // fresh route must not land below it.
1959
+ try {
1960
+ const _sid = payload?._sessionId || options?._sessionId || null;
1961
+ const _floor = _sid ? require('./switch-gate').floor(_sid) : null;
1962
+ if (_floor && (TIER_DEFINITIONS[_floor]?.priority ?? -1) > (TIER_DEFINITIONS[tier]?.priority ?? -1)) {
1963
+ const _fsel = selector.selectModel(_floor, null);
1964
+ if (_fsel && _fsel.provider && _fsel.model) {
1965
+ escalations.push({ source: 'switch_gate_floor', fromTier: tier, toTier: _floor, fromModel: selectedModel, toModel: _fsel.model });
1966
+ tier = _floor; provider = _fsel.provider; selectedModel = _fsel.model; analysis.tier = tier; method = method + '+gate_floor';
1967
+ }
1968
+ }
1969
+ } catch (err) {
1970
+ degradation.record('switch_gate', err);
1971
+ }
1972
+ // Declarative decision engine (config/routing.json). Observe mode records
1973
+ // what it would do; enforce mode adopts its tier when it differs.
1974
+ let engineInfo = null;
1975
+ try {
1976
+ const engine = require('./decisions');
1977
+ engineInfo = await engine.evaluate({
1978
+ payload, analysis, risk, agenticResult,
1979
+ legacy: { tier, provider, model: selectedModel },
1980
+ sessionId: payload?._sessionId || options?._sessionId || null,
1981
+ prevTurns: payload?._sessionId ? require('./outcomes').ring(payload._sessionId) : [],
1982
+ });
1983
+ if (engineInfo && engineInfo.mode === 'enforce' && engineInfo.tier && engineInfo.tier !== tier) {
1984
+ const _esel = selector.selectModel(engineInfo.tier, null);
1985
+ if (_esel && _esel.provider && _esel.model) {
1986
+ escalations.push({ source: `decision:${engineInfo.decision}`, fromTier: tier, toTier: engineInfo.tier, fromModel: selectedModel, toModel: _esel.model });
1987
+ logger.info({ decision: engineInfo.decision, from: `${tier}:${selectedModel}`, to: `${engineInfo.tier}:${_esel.model}` }, '[Routing] Decision engine override');
1988
+ tier = engineInfo.tier; provider = _esel.provider; selectedModel = _esel.model;
1989
+ analysis.tier = tier; method = method + '+decision';
1990
+ }
1991
+ } else if (engineInfo && !engineInfo.agreesWithLegacy) {
1992
+ logger.info({ decision: engineInfo.decision, engineTier: engineInfo.tier, legacyTier: tier }, '[Routing] Decision engine disagrees (observe mode)');
1993
+ }
1994
+ } catch (err) {
1995
+ degradation.record('decisions', err);
1996
+ }
1901
1997
  const decision = buildDecision({
1902
1998
  provider,
1903
1999
  model: selectedModel,
@@ -1924,6 +2020,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1924
2020
  demoted_from: demotedFrom,
1925
2021
  });
1926
2022
 
2023
+ if (engineInfo) decision.engine = engineInfo;
1927
2024
  // WS4.2 — propensity/candidates for off-policy evaluation from telemetry.
1928
2025
  // Collapse rule lives in decide.js (stampPropensity): if a deterministic
1929
2026
  // downstream override (deadline / tenant) swapped the served model out of
@@ -105,6 +105,11 @@ function intentScoreMode() {
105
105
  function _latestUserAsk(payload) {
106
106
  const msgs = payload?.messages;
107
107
  if (!Array.isArray(msgs)) return { text: null, index: -1 };
108
+ {
109
+ const { harnessAskFromPayload } = require('./harness-envelope');
110
+ const ask = harnessAskFromPayload(payload);
111
+ if (ask) return ask;
112
+ }
108
113
  const { cleanUserText } = require('./jev-router');
109
114
  for (let i = msgs.length - 1; i >= 0; i--) {
110
115
  const msg = msgs[i];