lynkr 9.9.0 → 9.10.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.
Files changed (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. package/src/tools/workspace.js +0 -204
@@ -92,9 +92,23 @@ const ADVANCED_PATTERNS = {
92
92
  },
93
93
  };
94
94
 
95
- // Force cloud patterns - always route to cloud regardless of score
96
- const FORCE_CLOUD_PATTERNS = [
95
+ // Force REASONING patterns - deterministic escalation to top tier (Claude in
96
+ // config B). Checked before force_cloud. Hardcoded constants (no env var).
97
+ // Security/governance asks route to the trusted Claude provider; deep
98
+ // reasoning asks route to extended thinking.
99
+ const FORCE_REASONING_PATTERNS = [
97
100
  /\b(security\s+(audit|review|assessment)|penetration\s+test|vulnerability\s+scan)\b/i,
101
+ /\b(ultrathink|ultra[\s-]?think)\b/i,
102
+ /\b(think\s+(hard|deeply|carefully|step[\s-]by[\s-]step|through\s+this))/i,
103
+ /\b(prove|proof|formal\s+proof|verify|verification)\b/i,
104
+ /\b(from\s+first\s+principles)\b/i,
105
+ /\b(reason\s+(through|about|from)\s+(the|this))/i,
106
+ ];
107
+
108
+ // Force COMPLEX patterns - route to COMPLEX (GLM in config B) regardless of
109
+ // score. Architecture/refactor/code-review are substantial but not
110
+ // security-critical, so GLM is appropriate.
111
+ const FORCE_CLOUD_PATTERNS = [
98
112
  /\b(architect(ure)?\s+(review|design|diagram)|system\s+design)\b/i,
99
113
  /\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
100
114
  /\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
@@ -465,7 +479,13 @@ function scoreTools(payload) {
465
479
  function scoreTaskType(content) {
466
480
  const contentLower = content.toLowerCase();
467
481
 
468
- // Check force patterns first
482
+ // Check force patterns first (priority: REASONING > cloud > local)
483
+ for (const pattern of FORCE_REASONING_PATTERNS) {
484
+ if (pattern.test(content)) {
485
+ return { score: 100, reason: 'force_reasoning', pattern: pattern.source.slice(0, 40) };
486
+ }
487
+ }
488
+
469
489
  for (const pattern of FORCE_LOCAL_PATTERNS) {
470
490
  if (pattern.test(content)) {
471
491
  return { score: 0, reason: 'force_local', pattern: 'greeting_or_simple' };
@@ -474,7 +494,7 @@ function scoreTaskType(content) {
474
494
 
475
495
  for (const pattern of FORCE_CLOUD_PATTERNS) {
476
496
  if (pattern.test(content)) {
477
- return { score: 25, reason: 'force_cloud', pattern: pattern.source.slice(0, 30) };
497
+ return { score: 75, reason: 'force_cloud', pattern: pattern.source.slice(0, 30) };
478
498
  }
479
499
  }
480
500
 
@@ -926,6 +946,20 @@ function shouldForceCloud(payload) {
926
946
  return !!matched;
927
947
  }
928
948
 
949
+ /**
950
+ * Quick check if request should be forced to REASONING tier (Claude in
951
+ * config B). Matches: ultrathink, think hard/deeply, prove/formal proof,
952
+ * from first principles, security audit.
953
+ */
954
+ function shouldForceReasoning(payload) {
955
+ const content = extractContent(payload);
956
+ const matched = FORCE_REASONING_PATTERNS.find(pattern => pattern.test(content));
957
+ if (matched) {
958
+ logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_reasoning matched');
959
+ }
960
+ return !!matched;
961
+ }
962
+
929
963
  // ============================================================================
930
964
  // PHASE 4: Embeddings-Based Similarity (Optional Enhancement)
931
965
  // ============================================================================
@@ -1041,6 +1075,7 @@ module.exports = {
1041
1075
  // Quick checks
1042
1076
  shouldForceLocal,
1043
1077
  shouldForceCloud,
1078
+ shouldForceReasoning,
1044
1079
 
1045
1080
  // Individual scoring (for testing/debugging)
1046
1081
  scoreTokens,
@@ -1069,6 +1104,7 @@ module.exports = {
1069
1104
  // Constants (for testing)
1070
1105
  PATTERNS,
1071
1106
  ADVANCED_PATTERNS,
1107
+ FORCE_REASONING_PATTERNS,
1072
1108
  FORCE_CLOUD_PATTERNS,
1073
1109
  FORCE_LOCAL_PATTERNS,
1074
1110
  DIMENSION_WEIGHTS,
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Difficulty classifier — LLM-based 4-way classification of user prompts.
3
+ *
4
+ * WHY THIS EXISTS: The anchor-embedding scorer (intent-score.js) measures
5
+ * *topical* similarity to labeled difficulty exemplars. It confuses "list
6
+ * the exports from this file" (LOW difficulty, technical vocabulary) with
7
+ * hard problems. An LLM reading the actual sentence knows better.
8
+ *
9
+ * DESIGN:
10
+ * - Uses whatever model is configured for the SIMPLE tier (fetched at call
11
+ * time via getModelTierSelector). When the user later swaps in a
12
+ * fine-tuned classifier as SIMPLE, this picks it up automatically.
13
+ * - Structured JSON output; parse failure → null → caller falls back.
14
+ * - Hard 2500ms timeout; on timeout → null → caller falls back.
15
+ * - LRU cache keyed by sha256(text.trim().toLowerCase()); capacity 500.
16
+ * - Skip conditions surface via classifyDifficulty returning null with
17
+ * reason=skipped: text.length<15, force-pattern matched, risk=high,
18
+ * cache hit is transparent (returns cached).
19
+ * - Hardcoded kill-switch CLASSIFIER_ENABLED — no env var per user policy.
20
+ *
21
+ * Failure modes are all null: anchor+lexical scoring already handles the
22
+ * fallback. This module never blocks routing.
23
+ */
24
+
25
+ const crypto = require('crypto');
26
+ const logger = require('../logger');
27
+
28
+ const CLASSIFIER_ENABLED = true;
29
+ // Thinking models (minimax-m2.5) spend 1-4s in <thinking> before emitting
30
+ // JSON — 2.5s is not enough. Raise to 10s and rely on skip conditions +
31
+ // LRU cache to keep amortized latency low.
32
+ const TIMEOUT_MS = 10000;
33
+ const CACHE_CAPACITY = 500;
34
+ const MIN_TEXT_LENGTH = 15;
35
+
36
+ // Classifier model — decoupled from tier serving so SIMPLE tier can run a
37
+ // more capable model for real traffic while the classifier stays fast and
38
+ // cheap. Chosen 2026-07-19: qwen2.5:3b hit 87.3% hand-labeled accuracy at
39
+ // ~500ms warm latency, zero cost-critical over-routes. Replace with your
40
+ // fine-tuned classifier when you build one. Hardcoded per user directive
41
+ // (no env var).
42
+ const CLASSIFIER_PROVIDER = 'ollama';
43
+ const CLASSIFIER_MODEL = 'qwen2.5:3b';
44
+
45
+ const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
46
+
47
+ // One-shot classification prompt (v2). Kept in a const so drift is diffable.
48
+ // Difficulty framing (not intent) — matches config B routing goals.
49
+ //
50
+ // v2 (2026-07-21): added the follow-up rule, negative examples under
51
+ // REASONING, and casual-question SIMPLE examples after a live incident:
52
+ // qwen2.5:3b read "Who kills him ?" (a Doctor Doom plot question) as
53
+ // REASONING conf 1.0 — v1's REASONING examples were all formal-methods
54
+ // flavored and nothing said surface vocabulary isn't the signal. Baseline
55
+ // on data/difficulty-eval-followups.jsonl: 60% overall, 33% on SIMPLE,
56
+ // 3 SIMPLE→REASONING criticals.
57
+ const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the CURRENT user prompt into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
58
+
59
+ Tiers:
60
+ - SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups, and short conversational follow-up questions about people, stories, events, or everyday facts. Any tiny model handles.
61
+ examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?"
62
+ - MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
63
+ examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block", "verify the file exists before reading it"
64
+ - COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
65
+ examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
66
+ - REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
67
+ examples: "prove the correctness of this lock-free queue", "security audit the auth middleware", "formally verify this state machine never deadlocks", "derive the optimal eviction policy and prove its competitive ratio"
68
+ NOT reasoning: casual questions that merely contain words like "prove", "kill", "verify", "audit" — "who kills him?" is SIMPLE, "prove me wrong lol" is SIMPLE, "can you verify the score?" is SIMPLE, "verify the file exists" is MEDIUM.
69
+
70
+ Rules:
71
+ - Judge the TASK the model must perform, not the vocabulary. Words like kill/prove/verify/audit/security do not make a prompt REASONING unless it demands formal or expert-level analysis.
72
+ - A short follow-up question (pronouns like he/she/it/that referring to the earlier conversation) about a casual topic is SIMPLE. A follow-up that extends a technical task inherits the difficulty of that task.
73
+
74
+ Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
75
+ `;
76
+
77
+ // Short prompts are where context matters: "Who kills him ?" is
78
+ // unclassifiable in isolation but trivially SIMPLE next to its conversation.
79
+ // Long prompts self-describe, and contextualizing them would only shrink the
80
+ // LRU hit rate and grow latency.
81
+ const CONTEXT_MAX_TEXT_LENGTH = 40;
82
+ const CONTEXT_MAX_CHARS = 300;
83
+
84
+ function _buildPrompt(text, context) {
85
+ if (context) {
86
+ return `${CLASSIFY_PROMPT}
87
+ Conversation so far (context only — classify the CURRENT prompt, inheriting topic difficulty per the rules):
88
+ ${context}
89
+
90
+ CURRENT user prompt: """${text}"""`;
91
+ }
92
+ return `${CLASSIFY_PROMPT}
93
+ User prompt: """${text}"""`;
94
+ }
95
+
96
+ // --- LRU cache --------------------------------------------------------------
97
+
98
+ class LruCache {
99
+ constructor(capacity) {
100
+ this.capacity = capacity;
101
+ this.map = new Map();
102
+ }
103
+ get(key) {
104
+ if (!this.map.has(key)) return undefined;
105
+ const val = this.map.get(key);
106
+ this.map.delete(key);
107
+ this.map.set(key, val); // move to MRU
108
+ return val;
109
+ }
110
+ set(key, val) {
111
+ if (this.map.has(key)) this.map.delete(key);
112
+ else if (this.map.size >= this.capacity) {
113
+ const oldest = this.map.keys().next().value;
114
+ this.map.delete(oldest);
115
+ }
116
+ this.map.set(key, val);
117
+ }
118
+ stats() { return { size: this.map.size, capacity: this.capacity }; }
119
+ }
120
+
121
+ const _cache = new LruCache(CACHE_CAPACITY);
122
+
123
+ function _cacheKey(text) {
124
+ return crypto.createHash('sha256').update(text.trim().toLowerCase()).digest('hex');
125
+ }
126
+
127
+ // --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
128
+
129
+ async function _callOllama(text, context, opts) {
130
+ const config = require('../config');
131
+
132
+ // First cut supports ollama-family classifier only. Other providers can
133
+ // be wired later; today's classifier is qwen2.5:3b on ollama.
134
+ if (CLASSIFIER_PROVIDER !== 'ollama') {
135
+ logger.debug({ provider: CLASSIFIER_PROVIDER, model: CLASSIFIER_MODEL }, '[DifficultyClassifier] non-ollama classifier not yet supported');
136
+ return null;
137
+ }
138
+
139
+ const endpoint = config.ollama?.endpoint || 'http://localhost:11434';
140
+ const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
141
+ const body = {
142
+ model: CLASSIFIER_MODEL,
143
+ messages: [{ role: 'user', content: _buildPrompt(text, context) }],
144
+ stream: false,
145
+ format: 'json',
146
+ // num_predict generous because thinking models (minimax-m2.5) burn
147
+ // budget in message.thinking before producing message.content JSON.
148
+ options: { temperature: 0, num_predict: 512 },
149
+ };
150
+
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs || TIMEOUT_MS);
153
+ try {
154
+ const res = await fetch(url, {
155
+ method: 'POST',
156
+ headers: { 'content-type': 'application/json' },
157
+ body: JSON.stringify(body),
158
+ signal: controller.signal,
159
+ });
160
+ if (!res.ok) return null;
161
+ const json = await res.json();
162
+ // Thinking models put output in message.thinking; regular in message.content.
163
+ // /api/generate variants use `response`. Try all three, biggest first.
164
+ const candidates = [
165
+ json?.message?.content,
166
+ json?.message?.thinking,
167
+ json?.response,
168
+ ].filter(s => typeof s === 'string' && s.length > 0);
169
+ return candidates.join('\n') || null;
170
+ } catch (err) {
171
+ logger.debug({ err: err.message, textPreview: text.slice(0, 60) }, '[DifficultyClassifier] call failed');
172
+ return null;
173
+ } finally {
174
+ clearTimeout(timer);
175
+ }
176
+ }
177
+
178
+ // --- Parse & validate the model's JSON output -------------------------------
179
+
180
+ function _parseResult(raw) {
181
+ if (!raw) return null;
182
+ // Ollama with format:'json' usually gives clean JSON, but the model can
183
+ // still surround it with junk — extract the first { ... } span.
184
+ const match = raw.match(/\{[\s\S]*?\}/);
185
+ if (!match) return null;
186
+ let obj;
187
+ try { obj = JSON.parse(match[0]); } catch { return null; }
188
+ const tier = String(obj.tier || '').toUpperCase().trim();
189
+ if (!VALID_TIERS.includes(tier)) return null;
190
+ const rawConf = Number(obj.confidence);
191
+ const confidence = Number.isFinite(rawConf) ? Math.max(0, Math.min(1, rawConf)) : 0.5;
192
+ return { tier, confidence };
193
+ }
194
+
195
+ // --- Public API -------------------------------------------------------------
196
+
197
+ /**
198
+ * Classify difficulty of user text.
199
+ *
200
+ * @param {string} text — cleaned user prompt
201
+ * @param {object} [opts]
202
+ * @param {number} [opts.timeoutMs] — override default 2500ms
203
+ * @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
204
+ * @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
205
+ * @param {string} [opts.context] — condensed prior conversation ("user asked:
206
+ * ... → assistant replied about: ..."); used only for short prompts, where
207
+ * a bare follow-up is unclassifiable in isolation
208
+ * @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
209
+ * null when: disabled, skipped, model failure, parse failure, timeout.
210
+ */
211
+ async function classifyDifficulty(text, opts = {}) {
212
+ if (!CLASSIFIER_ENABLED) return null;
213
+ if (typeof text !== 'string') return null;
214
+ const trimmed = text.trim();
215
+ if (trimmed.length < MIN_TEXT_LENGTH) return null;
216
+ if (opts.forceMatched) return null;
217
+ if (opts.riskLevel === 'high') return null;
218
+
219
+ const context =
220
+ typeof opts.context === 'string' && opts.context.trim() && trimmed.length <= CONTEXT_MAX_TEXT_LENGTH
221
+ ? opts.context.trim().slice(0, CONTEXT_MAX_CHARS)
222
+ : null;
223
+
224
+ // Context participates in the cache key: the same follow-up text means
225
+ // different things in different conversations.
226
+ const key = _cacheKey(context ? `${trimmed}${context}` : trimmed);
227
+ const cached = _cache.get(key);
228
+ if (cached) return { ...cached, source: 'cache' };
229
+
230
+ const raw = await _callOllama(trimmed, context, opts);
231
+ const parsed = _parseResult(raw);
232
+ if (!parsed) return null;
233
+ _cache.set(key, parsed);
234
+ return { ...parsed, source: 'model' };
235
+ }
236
+
237
+ // --- Test helpers -----------------------------------------------------------
238
+
239
+ function _clearCacheForTests() { _cache.map.clear(); }
240
+ function _getCacheStats() { return _cache.stats(); }
241
+
242
+ // Exposed metadata for the bootstrap module — keeps model choice in one file
243
+ // so classifier-setup.js pulls exactly what the classifier will call.
244
+ const CLASSIFIER_MODEL_INFO = Object.freeze({
245
+ provider: CLASSIFIER_PROVIDER,
246
+ model: CLASSIFIER_MODEL,
247
+ endpoint: process.env.OLLAMA_ENDPOINT || 'http://localhost:11434',
248
+ });
249
+
250
+ module.exports = {
251
+ classifyDifficulty,
252
+ CLASSIFIER_ENABLED,
253
+ CLASSIFIER_MODEL_INFO,
254
+ VALID_TIERS,
255
+ // internals for tests only
256
+ _parseResult,
257
+ _cacheKey,
258
+ _buildPrompt,
259
+ _clearCacheForTests,
260
+ _getCacheStats,
261
+ };
@@ -14,6 +14,7 @@ const {
14
14
  analyzeComplexity,
15
15
  shouldForceLocal,
16
16
  shouldForceCloud,
17
+ shouldForceReasoning,
17
18
  routingMetrics,
18
19
  analyzeWithEmbeddings,
19
20
  } = require('./complexity-analyzer');
@@ -583,6 +584,8 @@ function checkSessionPin(payload, options = {}) {
583
584
  // the new_conversation guard for everyone after it.
584
585
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
585
586
  promptTokensEst: pin.promptTokensEst,
587
+ // We're inside payloadHasToolHistory(payload) — session has committed.
588
+ hasToolHistory: true,
586
589
  });
587
590
  }
588
591
  return { serve: true, pin, reason: 'tool_history', sessionId };
@@ -598,11 +601,30 @@ function checkSessionPin(payload, options = {}) {
598
601
  sessionAffinity.setPin(sessionId, pin, {
599
602
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
600
603
  promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
604
+ // setPin OR-merges with the existing pin's flag, so a plain-text turn
605
+ // between tool exchanges keeps the sticky-true property.
606
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
601
607
  });
602
608
  }
603
609
  return { serve: true, pin, reason: 'guards_passed', sessionId };
604
610
  }
605
611
 
612
+ /** Whole-payload scan for tool_use/tool_result blocks. Unlike
613
+ * sessionAffinity.payloadHasToolHistory (last message only), this checks
614
+ * every message so we correctly flag sessions where the last frame is a
615
+ * plain text turn but earlier turns carried tools. */
616
+ function _payloadHasAnyToolBlocks(payload) {
617
+ const msgs = payload?.messages;
618
+ if (!Array.isArray(msgs)) return false;
619
+ for (const m of msgs) {
620
+ if (!Array.isArray(m?.content)) continue;
621
+ for (const b of m.content) {
622
+ if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
623
+ }
624
+ }
625
+ return false;
626
+ }
627
+
606
628
  /**
607
629
  * Write-through helper: persist a fresh routing decision as the session's
608
630
  * new pin. No-op when sessionId is missing or the decision has no provider.
@@ -641,7 +663,11 @@ function writeSessionPin(sessionId, decision, payload) {
641
663
  return;
642
664
  }
643
665
  const promptTokensEst = _tryCountTokens(payload, decision.model);
644
- sessionAffinity.setPin(sessionId, decision, { messageCount, promptTokensEst });
666
+ sessionAffinity.setPin(sessionId, decision, {
667
+ messageCount,
668
+ promptTokensEst,
669
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
670
+ });
645
671
  }
646
672
 
647
673
  async function _determineProviderSmartInner(payload, options = {}) {
@@ -706,11 +732,14 @@ async function _determineProviderSmartInner(payload, options = {}) {
706
732
  if (risk?.level === 'high' && isFallbackEnabled()) {
707
733
  try {
708
734
  const selector = getModelTierSelector();
709
- const modelSelection = selector.selectModel('COMPLEX', null);
735
+ // Config B (local → GLM → Claude): high-risk requests route to the
736
+ // trusted provider (Claude) via REASONING, not the mid-tier GLM.
737
+ // Security/auth/middleware changes belong on the governance path.
738
+ const modelSelection = selector.selectModel('REASONING', null);
710
739
  const decision = {
711
740
  provider: modelSelection.provider,
712
741
  model: modelSelection.model,
713
- tier: 'COMPLEX',
742
+ tier: 'REASONING',
714
743
  method: 'risk',
715
744
  reason: 'high_risk_forced_tier',
716
745
  score: 100,
@@ -719,7 +748,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
719
748
  escalations: [{
720
749
  source: 'risk',
721
750
  fromTier: null,
722
- toTier: 'COMPLEX',
751
+ toTier: 'REASONING',
723
752
  fromModel: null,
724
753
  toModel: modelSelection.model,
725
754
  }],
@@ -731,7 +760,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
731
760
  };
732
761
  routingMetrics.record(decision);
733
762
  logger.debug({
734
- tier: 'COMPLEX',
763
+ tier: 'REASONING',
735
764
  provider: decision.provider,
736
765
  instructionHits: risk.instructionHits,
737
766
  pathHits: risk.pathHits,
@@ -785,6 +814,33 @@ async function _determineProviderSmartInner(payload, options = {}) {
785
814
  return decision;
786
815
  }
787
816
 
817
+ // Force REASONING (Claude in config B) — checked before force_cloud.
818
+ // Matches ultrathink/prove/security-audit/first-principles. Deterministic
819
+ // routing to the top tier regardless of embedding score.
820
+ if (shouldForceReasoning(payload) && isFallbackEnabled() && config.modelTiers?.enabled) {
821
+ try {
822
+ const selector = getModelTierSelector();
823
+ const modelSelection = selector.selectModel('REASONING', null);
824
+ const decision = {
825
+ provider: modelSelection.provider,
826
+ model: modelSelection.model,
827
+ tier: 'REASONING',
828
+ method: 'force',
829
+ reason: 'force_reasoning_pattern',
830
+ score: 100,
831
+ risk,
832
+ propensity: 1.0,
833
+ candidates: [{ provider: modelSelection.provider, model: modelSelection.model }],
834
+ _queryEmbedding: queryEmbedding,
835
+ _queryText: queryText,
836
+ };
837
+ routingMetrics.record(decision);
838
+ return decision;
839
+ } catch (err) {
840
+ degradation.record('tier_select', err);
841
+ }
842
+ }
843
+
788
844
  if (shouldForceCloud(payload) && isFallbackEnabled()) {
789
845
  // When tier routing is enabled, force-cloud means the COMPLEX tier's
790
846
  // configured model — NOT getBestCloudProvider()'s credential-priority
@@ -1057,10 +1113,17 @@ async function _determineProviderSmartInner(payload, options = {}) {
1057
1113
 
1058
1114
  // Phase 1.2 — cost-optimizer override.
1059
1115
  // Only kick in when:
1060
- // - feature flag enabled (default true, disable with LYNKR_COST_OPTIMIZE=false)
1116
+ // - feature flag EXPLICITLY enabled with LYNKR_COST_OPTIMIZE=true (default OFF)
1061
1117
  // - risk level is not high (high-risk keeps the explicitly-configured model)
1062
1118
  // - the optimizer finds a meaningfully cheaper qualifying model
1063
- const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE !== 'false'
1119
+ //
1120
+ // Default changed to OFF (2026-07-20): the optimizer silently overrides
1121
+ // explicit TIER_* config with the cheapest model in the tier pool, which
1122
+ // surprised operators who expected their tier settings to be authoritative
1123
+ // (e.g. TIER_REASONING=azure-anthropic:claude-opus-4.7 was being swapped
1124
+ // for openrouter:deepseek/deepseek-reasoner — which was also a typo that
1125
+ // 400'd upstream). Opt-in only.
1126
+ const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE === 'true'
1064
1127
  && config.routing?.costOptimize !== false;
1065
1128
  if (costOptimizeEnabled && risk?.level !== 'high') {
1066
1129
  try {