lynkr 9.7.3 → 9.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +63 -25
  2. package/bin/cli.js +16 -1
  3. package/bin/lynkr-init.js +44 -1
  4. package/bin/lynkr-usage.js +78 -0
  5. package/bin/wrap.js +60 -35
  6. package/config/difficulty-anchors.json +22 -0
  7. package/package.json +23 -2
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/build-eval-set.js +256 -0
  10. package/scripts/calibrate-thresholds.js +38 -157
  11. package/scripts/compact-dictionary.js +204 -0
  12. package/scripts/mine-difficulty-anchors.js +288 -0
  13. package/scripts/test-deduplication.js +448 -0
  14. package/scripts/validate-difficulty-classifier.js +123 -0
  15. package/scripts/validate-intent-anchors.js +186 -0
  16. package/scripts/ws7-anchor-replay.js +108 -0
  17. package/skills/lynkr/SKILL.md +195 -0
  18. package/src/api/middleware/loop-guard.js +87 -0
  19. package/src/api/middleware/request-logging.js +5 -64
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +120 -101
  22. package/src/api/providers-handler.js +27 -2
  23. package/src/api/router.js +467 -125
  24. package/src/budget/index.js +2 -19
  25. package/src/cache/semantic.js +9 -0
  26. package/src/clients/databricks.js +455 -142
  27. package/src/clients/gpt-utils.js +11 -105
  28. package/src/clients/openai-format.js +10 -3
  29. package/src/clients/openrouter-utils.js +49 -24
  30. package/src/clients/prompt-cache-injection.js +1 -0
  31. package/src/clients/provider-capabilities.js +1 -1
  32. package/src/clients/responses-format.js +34 -3
  33. package/src/clients/routing.js +15 -0
  34. package/src/config/index.js +36 -2
  35. package/src/context/gcf.js +275 -0
  36. package/src/context/tool-result-compressor.js +932 -47
  37. package/src/dashboard/api.js +1 -0
  38. package/src/logger/index.js +14 -1
  39. package/src/memory/search.js +12 -40
  40. package/src/memory/tools.js +3 -24
  41. package/src/orchestrator/bypass.js +4 -2
  42. package/src/orchestrator/index.js +120 -85
  43. package/src/routing/affinity-store.js +194 -0
  44. package/src/routing/agentic-detector.js +36 -6
  45. package/src/routing/bandit.js +25 -6
  46. package/src/routing/calibration.js +212 -0
  47. package/src/routing/classifier-setup.js +207 -0
  48. package/src/routing/client-profiles.js +292 -0
  49. package/src/routing/complexity-analyzer.js +88 -15
  50. package/src/routing/deescalator.js +148 -0
  51. package/src/routing/degradation.js +109 -0
  52. package/src/routing/difficulty-classifier.js +219 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +931 -90
  55. package/src/routing/intent-score.js +441 -0
  56. package/src/routing/interaction.js +3 -0
  57. package/src/routing/knn-router.js +70 -21
  58. package/src/routing/model-registry.js +28 -7
  59. package/src/routing/model-tiers.js +25 -2
  60. package/src/routing/reward-pipeline.js +68 -2
  61. package/src/routing/risk-analyzer.js +30 -1
  62. package/src/routing/risk-classifier.js +6 -2
  63. package/src/routing/session-affinity.js +162 -34
  64. package/src/routing/telemetry.js +286 -13
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +86 -21
  67. package/src/sessions/cleanup.js +17 -0
  68. package/src/tools/index.js +1 -15
  69. package/src/tools/smart-selection.js +10 -0
  70. package/src/tools/web-client.js +3 -3
  71. package/.eslintrc.cjs +0 -12
  72. package/benchmark-configs/litellm_config.yaml +0 -86
  73. package/benchmark-configs/lynkr.env +0 -48
  74. package/benchmark-configs/portkey-config.json +0 -60
  75. package/benchmark-configs/portkey-docker.sh +0 -23
  76. package/benchmark-tier-routing.js +0 -449
  77. package/funding.json +0 -110
  78. package/src/api/middleware/validation.js +0 -261
  79. package/src/routing/drift-monitor.js +0 -113
  80. package/src/workers/helpers.js +0 -185
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Classifier bootstrap — verifies ollama is installed and the classifier
3
+ * model is pulled + warm. Called from:
4
+ * - `lynkr init` (interactive, prompts user for install, blocks on completion)
5
+ * - server boot (non-blocking, logs warnings, never errors out)
6
+ *
7
+ * Ollama install is intentionally NOT auto-executed. Silent `curl | sh`
8
+ * during npm install is a supply-chain footgun; we detect and instruct
9
+ * instead.
10
+ *
11
+ * "Later" work per user directive:
12
+ * - Fine-tune qwen2.5:3b on labeled classification data (needs LoRA infra)
13
+ * - Canary verification against known-good prompts on every startup
14
+ */
15
+
16
+ const { spawn } = require('child_process');
17
+ const os = require('os');
18
+
19
+ // Reads the classifier model constant from difficulty-classifier.js so this
20
+ // module and the classifier stay in lock-step.
21
+ const { CLASSIFIER_MODEL_INFO } = require('./difficulty-classifier');
22
+
23
+ /**
24
+ * Check whether the `ollama` CLI is on PATH.
25
+ * @returns {Promise<{installed: boolean, version?: string}>}
26
+ */
27
+ function detectOllama() {
28
+ return new Promise((resolve) => {
29
+ const proc = spawn('ollama', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
30
+ let out = '';
31
+ proc.stdout?.on('data', (d) => { out += d.toString(); });
32
+ proc.on('error', () => resolve({ installed: false }));
33
+ proc.on('close', (code) => {
34
+ if (code === 0) {
35
+ resolve({ installed: true, version: out.trim() });
36
+ } else {
37
+ resolve({ installed: false });
38
+ }
39
+ });
40
+ });
41
+ }
42
+
43
+ /**
44
+ * Check whether a specific ollama model is present locally.
45
+ * @param {string} model
46
+ * @returns {Promise<boolean>}
47
+ */
48
+ function isModelPulled(model) {
49
+ return new Promise((resolve) => {
50
+ const proc = spawn('ollama', ['list'], { stdio: ['ignore', 'pipe', 'ignore'] });
51
+ let out = '';
52
+ proc.stdout?.on('data', (d) => { out += d.toString(); });
53
+ proc.on('error', () => resolve(false));
54
+ proc.on('close', () => {
55
+ // ollama list prints "NAME ID SIZE MODIFIED" — grep by exact model name
56
+ const rows = out.split('\n').slice(1);
57
+ resolve(rows.some((line) => line.startsWith(model + ' ') || line.split(/\s+/)[0] === model));
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Pull a model, streaming progress to stdout.
64
+ * @param {string} model
65
+ * @returns {Promise<void>}
66
+ */
67
+ function pullModel(model) {
68
+ return new Promise((resolve, reject) => {
69
+ const proc = spawn('ollama', ['pull', model], { stdio: 'inherit' });
70
+ proc.on('error', reject);
71
+ proc.on('close', (code) => {
72
+ if (code === 0) resolve();
73
+ else reject(new Error(`ollama pull exited with code ${code}`));
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Warm the model — one dummy inference so first real call isn't cold.
80
+ * Best-effort; failure doesn't block.
81
+ * @param {string} model
82
+ */
83
+ async function warmModel(model, endpoint = 'http://localhost:11434') {
84
+ try {
85
+ const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
86
+ const controller = new AbortController();
87
+ const timer = setTimeout(() => controller.abort(), 60000);
88
+ const res = await fetch(url, {
89
+ method: 'POST',
90
+ headers: { 'content-type': 'application/json' },
91
+ body: JSON.stringify({
92
+ model,
93
+ messages: [{ role: 'user', content: 'ok' }],
94
+ stream: false,
95
+ options: { num_predict: 3 },
96
+ }),
97
+ signal: controller.signal,
98
+ });
99
+ clearTimeout(timer);
100
+ return res.ok;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Instructions to print when ollama is missing. */
107
+ function installInstructions() {
108
+ const plat = os.platform();
109
+ const lines = ['Ollama is required for the difficulty classifier and SIMPLE-tier serving.'];
110
+ if (plat === 'darwin') {
111
+ lines.push('Install on macOS:');
112
+ lines.push(' brew install ollama');
113
+ lines.push(' # or download: https://ollama.com/download');
114
+ } else if (plat === 'linux') {
115
+ lines.push('Install on Linux:');
116
+ lines.push(' curl -fsSL https://ollama.com/install.sh | sh');
117
+ } else if (plat === 'win32') {
118
+ lines.push('Install on Windows:');
119
+ lines.push(' Download the installer from https://ollama.com/download/windows');
120
+ } else {
121
+ lines.push('Install from https://ollama.com/download');
122
+ }
123
+ lines.push('After installing, re-run `lynkr init` (or restart the server).');
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * Full bootstrap flow with prompts.
129
+ * @param {object} opts
130
+ * @param {'interactive'|'boot'} opts.mode — interactive blocks on failure, boot warns and continues
131
+ * @param {function} [opts.log] — logger function (defaults to console)
132
+ * @param {function} [opts.warn] — warn function
133
+ * @param {function} [opts.prompt] — async prompt(text)→string for interactive install/pull confirmations
134
+ * @returns {Promise<{ready: boolean, ollama: boolean, model: boolean, reason?: string}>}
135
+ */
136
+ async function ensureClassifierReady(opts = {}) {
137
+ const mode = opts.mode || 'boot';
138
+ const log = opts.log || ((msg) => console.log(msg));
139
+ const warn = opts.warn || ((msg) => console.warn(msg));
140
+ const { provider, model, endpoint } = CLASSIFIER_MODEL_INFO;
141
+
142
+ if (provider !== 'ollama') {
143
+ // Non-ollama providers not supported today — bail cleanly.
144
+ warn(`Classifier provider "${provider}" is not yet auto-provisioned. Ensure ${model} is reachable manually.`);
145
+ return { ready: false, ollama: false, model: false, reason: 'non_ollama_provider' };
146
+ }
147
+
148
+ // 1. Ollama detection
149
+ const ollama = await detectOllama();
150
+ if (!ollama.installed) {
151
+ const msg = installInstructions();
152
+ if (mode === 'interactive') {
153
+ log('');
154
+ log('⚠ Ollama not found on PATH.');
155
+ log('');
156
+ log(msg);
157
+ log('');
158
+ } else {
159
+ warn(`[classifier-setup] Ollama not installed — classifier disabled. ${installInstructions().split('\n')[0]}`);
160
+ }
161
+ return { ready: false, ollama: false, model: false, reason: 'ollama_missing' };
162
+ }
163
+ if (mode === 'interactive') log(`✓ Ollama detected (${ollama.version || 'unknown version'}).`);
164
+
165
+ // 2. Model pull
166
+ const hasModel = await isModelPulled(model);
167
+ if (!hasModel) {
168
+ if (mode === 'interactive') {
169
+ log('');
170
+ log(`Classifier model "${model}" not present locally.`);
171
+ const yes = opts.prompt ? await opts.prompt(`Pull ${model} now? [Y/n] `) : 'y';
172
+ if (yes.trim().toLowerCase().startsWith('n')) {
173
+ warn(`Skipped pull — classifier will be disabled until you run: ollama pull ${model}`);
174
+ return { ready: false, ollama: true, model: false, reason: 'pull_declined' };
175
+ }
176
+ log(`Pulling ${model} (this can take a few minutes on first run)...`);
177
+ try {
178
+ await pullModel(model);
179
+ log(`✓ Model ${model} pulled.`);
180
+ } catch (err) {
181
+ warn(`✗ Pull failed: ${err.message}`);
182
+ return { ready: false, ollama: true, model: false, reason: 'pull_failed' };
183
+ }
184
+ } else {
185
+ // Boot mode — don't auto-pull. Log a clear instruction and continue.
186
+ warn(`[classifier-setup] Classifier model ${model} not pulled. Run: ollama pull ${model}. Classifier will fall back to anchor-only scoring until then.`);
187
+ return { ready: false, ollama: true, model: false, reason: 'model_missing' };
188
+ }
189
+ }
190
+
191
+ // 3. Warm-up
192
+ const warmed = await warmModel(model, endpoint);
193
+ if (mode === 'interactive') {
194
+ log(warmed ? `✓ Model warmed (first classification will be fast).` : `⚠ Warm-up call failed — the first classification may be slow.`);
195
+ }
196
+
197
+ return { ready: true, ollama: true, model: true, warmed };
198
+ }
199
+
200
+ module.exports = {
201
+ detectOllama,
202
+ isModelPulled,
203
+ pullModel,
204
+ warmModel,
205
+ installInstructions,
206
+ ensureClassifierReady,
207
+ };
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Client Profiles (WS3.1).
3
+ *
4
+ * Client harnesses like Claude Code, Cursor, and Codex attach a large
5
+ * baseline tool loadout to EVERY request (Claude Code sends ~11 tools
6
+ * unconditionally). The old agentic detector inflated `tool_count` and
7
+ * `agentic_tool` signals from those baselines, mis-classifying a trivial
8
+ * "hi" as an agentic COMPLEX-tier workload.
9
+ *
10
+ * The `slice(0, 3)` hack in `src/api/router.js` worked around that in one
11
+ * specific spot for one specific client — but it also discarded real MCP
12
+ * tools the user had configured, and did nothing for Cursor / Codex.
13
+ *
14
+ * This module fixes the root cause. Given a request's `user-agent` header
15
+ * and its tools list, `detectClient` returns the matching profile (or null),
16
+ * and `effectiveTools` subtracts the baseline so the agentic detector scores
17
+ * only tools the user actually added beyond the harness default.
18
+ *
19
+ * @module routing/client-profiles
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const logger = require('../logger');
25
+
26
+ /**
27
+ * Seed profiles. Baseline tool lists must exactly match the tool NAMES the
28
+ * client sends (case-sensitive) — the agentic detector reads
29
+ * `t.name || t.function?.name`, so these are the tools that ship in the
30
+ * harness's default `tools` array.
31
+ *
32
+ * Claude Code's default loadout was verified against the live proxy request
33
+ * shape at time of writing (2026-07). If a future release adds/renames a
34
+ * tool, add it here (or override via data/client-profiles.json).
35
+ */
36
+ const PROFILES = {
37
+ 'claude-code': {
38
+ name: 'claude-code',
39
+ baselineTools: new Set([
40
+ 'Task', 'Bash', 'Glob', 'Grep', 'Read', 'Edit', 'Write',
41
+ 'NotebookEdit', 'WebFetch', 'WebSearch', 'TodoWrite',
42
+ 'BashOutput', 'KillShell', 'SlashCommand',
43
+ ]),
44
+ detect: {
45
+ // Claude Code CLI actually sends `user-agent: claude-cli/x.y.z` (not
46
+ // claude-code/x.y.z as the tests assumed). Match every Anthropic-side
47
+ // subscription client — same set as auth-mode.js's
48
+ // SUBSCRIPTION_UA_PREFIXES, kept in sync manually since the two files
49
+ // want slightly different match rules.
50
+ headerPatterns: [
51
+ /claude[-_](cli|code|vscode)/i,
52
+ /anthropic-cli/i,
53
+ ],
54
+ minToolFingerprintMatch: 0.8,
55
+ },
56
+ },
57
+ 'cursor': {
58
+ name: 'cursor',
59
+ baselineTools: new Set([
60
+ // Cursor's default agent loadout
61
+ 'read_file', 'write_file', 'edit_file', 'delete_file',
62
+ 'run_terminal_command', 'grep_search', 'file_search', 'codebase_search',
63
+ 'web_search', 'list_dir',
64
+ ]),
65
+ detect: {
66
+ headerPatterns: [/cursor/i],
67
+ minToolFingerprintMatch: 0.8,
68
+ },
69
+ },
70
+ 'lynkr-injected': {
71
+ name: 'lynkr-injected',
72
+ baselineTools: new Set([
73
+ // Lynkr's own IDE_SAFE_TOOLS loadout (src/clients/standard-tools.js),
74
+ // injected by the OpenAI-compat routers when a client sends no tools.
75
+ // These are Lynkr plumbing, not user intent: without this profile the
76
+ // agentic detector counts its own injected tools and floors every
77
+ // tool-less client (e.g. goose's summarizer side-requests) at
78
+ // ITERATIVE/COMPLEX. Matches claude-code's names plus the four
79
+ // (MultiEdit, LS, NotebookRead, WebAgent) that keep it from
80
+ // fingerprinting as claude-code.
81
+ 'Write', 'Read', 'Edit', 'Bash', 'Glob', 'Grep', 'MultiEdit', 'LS',
82
+ 'NotebookRead', 'TodoWrite', 'Task', 'WebSearch', 'WebFetch',
83
+ 'WebAgent', 'NotebookEdit',
84
+ ]),
85
+ detect: {
86
+ headerPatterns: [],
87
+ minToolFingerprintMatch: 0.8,
88
+ },
89
+ },
90
+ 'goose': {
91
+ name: 'goose',
92
+ baselineTools: new Set([
93
+ // Goose CLI 1.41 default loadout — captured live 2026-07-13 against
94
+ // the OpenAI-compatible endpoint. Goose sends NO user-agent header,
95
+ // so detection relies entirely on this tool fingerprint. The apps__*,
96
+ // extensionmanager__* and todo__* names come from goose's default
97
+ // extensions; the rest are core tools that attach to every request.
98
+ 'analyze', 'delegate', 'edit', 'load', 'load_skill', 'read_image',
99
+ 'shell', 'tree', 'write',
100
+ 'apps__create_app', 'apps__delete_app', 'apps__iterate_app',
101
+ 'apps__list_apps',
102
+ 'extensionmanager__list_resources', 'extensionmanager__manage_extensions',
103
+ 'extensionmanager__read_resource',
104
+ 'extensionmanager__search_available_extensions',
105
+ 'todo__todo_write',
106
+ ]),
107
+ detect: {
108
+ headerPatterns: [/goose/i],
109
+ // 0.5 rather than the usual 0.8: users can disable goose's default
110
+ // extensions (apps, extensionmanager, todo), which removes up to 9 of
111
+ // the 18 baseline names while the 9 core tools still identify goose.
112
+ // A false-positive match on another harness with this many identical
113
+ // tool names would subtract only those shared names — harmless.
114
+ minToolFingerprintMatch: 0.5,
115
+ },
116
+ },
117
+ 'openai-codex': {
118
+ name: 'openai-codex',
119
+ baselineTools: new Set([
120
+ // Legacy Codex CLI (and Lynkr's injected fallback names)
121
+ 'shell', 'apply_patch', 'read_file', 'write_file',
122
+ // Codex v0.142+ (Rust CLI) default loadout — captured live 2026-07-11.
123
+ // All of these attach to EVERY request, including "Hi": counting them
124
+ // as agentic intent floored every Codex turn at MEDIUM.
125
+ 'exec_command', 'write_stdin', 'update_plan', 'request_user_input',
126
+ 'view_image', 'get_goal', 'create_goal', 'update_goal',
127
+ 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource',
128
+ 'list_available_plugins_to_install', 'request_plugin_install',
129
+ ]),
130
+ detect: {
131
+ headerPatterns: [/codex/i, /openai-cli/i],
132
+ minToolFingerprintMatch: 0.8,
133
+ },
134
+ },
135
+ };
136
+
137
+ // Union of every baseline tool across all known profiles — used for the
138
+ // "no-profile-but-looks-like-a-harness" heuristic guard.
139
+ let _knownBaselineToolsCache = null;
140
+ function _knownBaselineTools() {
141
+ if (_knownBaselineToolsCache) return _knownBaselineToolsCache;
142
+ const s = new Set();
143
+ for (const p of Object.values(PROFILES)) {
144
+ for (const t of p.baselineTools) s.add(t);
145
+ }
146
+ _knownBaselineToolsCache = s;
147
+ return s;
148
+ }
149
+
150
+ // User overrides / additions from data/client-profiles.json — loaded once
151
+ // at first use. Shape: { profileName: { baselineTools: [...], detect: {...} } }
152
+ let _userProfilesLoaded = false;
153
+ function _loadUserProfiles() {
154
+ if (_userProfilesLoaded) return;
155
+ _userProfilesLoaded = true;
156
+ const overridePath = path.join(process.cwd(), 'data', 'client-profiles.json');
157
+ if (!fs.existsSync(overridePath)) return;
158
+ try {
159
+ const raw = JSON.parse(fs.readFileSync(overridePath, 'utf8'));
160
+ for (const [name, def] of Object.entries(raw || {})) {
161
+ if (!def || typeof def !== 'object') continue;
162
+ const baseline = Array.isArray(def.baselineTools)
163
+ ? new Set(def.baselineTools)
164
+ : PROFILES[name]?.baselineTools ?? new Set();
165
+ const patterns = Array.isArray(def.headerPatterns)
166
+ ? def.headerPatterns.map((p) => new RegExp(p, 'i'))
167
+ : PROFILES[name]?.detect?.headerPatterns ?? [];
168
+ PROFILES[name] = {
169
+ name,
170
+ baselineTools: baseline,
171
+ detect: {
172
+ headerPatterns: patterns,
173
+ minToolFingerprintMatch: Number.isFinite(def.minToolFingerprintMatch)
174
+ ? def.minToolFingerprintMatch
175
+ : 0.8,
176
+ },
177
+ };
178
+ }
179
+ _knownBaselineToolsCache = null; // recompute after overrides
180
+ logger.debug({ count: Object.keys(raw).length }, '[ClientProfiles] Loaded user overrides');
181
+ } catch (err) {
182
+ logger.debug({ err: err.message }, '[ClientProfiles] Failed to load user overrides');
183
+ }
184
+ }
185
+
186
+ function _toolName(t) {
187
+ return (t && (t.name || t.function?.name)) || null;
188
+ }
189
+
190
+ /**
191
+ * Detect the client harness that issued this request.
192
+ *
193
+ * Match order:
194
+ * 1. user-agent header regex — strongest signal, cheap.
195
+ * 2. Tool-set fingerprint — ≥ minToolFingerprintMatch (default 80%) of the
196
+ * profile's baseline tool names must be present in payload.tools.
197
+ *
198
+ * @param {Object} args
199
+ * @param {Object} [args.headers]
200
+ * @param {Object} [args.payload]
201
+ * @returns {Object|null} The matched profile or null.
202
+ */
203
+ function detectClient({ headers = {}, payload = {} } = {}) {
204
+ _loadUserProfiles();
205
+
206
+ const ua = String(headers['user-agent'] || headers['User-Agent'] || '');
207
+ if (ua) {
208
+ for (const profile of Object.values(PROFILES)) {
209
+ for (const pattern of profile.detect.headerPatterns) {
210
+ if (pattern.test(ua)) return profile;
211
+ }
212
+ }
213
+ }
214
+
215
+ const tools = Array.isArray(payload.tools) ? payload.tools : [];
216
+ if (tools.length === 0) return null;
217
+
218
+ const presentNames = new Set(tools.map(_toolName).filter(Boolean));
219
+ let best = null;
220
+ let bestRatio = 0;
221
+ for (const profile of Object.values(PROFILES)) {
222
+ const baseline = profile.baselineTools;
223
+ if (baseline.size === 0) continue;
224
+ let hits = 0;
225
+ for (const name of baseline) {
226
+ if (presentNames.has(name)) hits++;
227
+ }
228
+ const ratio = hits / baseline.size;
229
+ if (ratio >= profile.detect.minToolFingerprintMatch && ratio > bestRatio) {
230
+ best = profile;
231
+ bestRatio = ratio;
232
+ }
233
+ }
234
+ return best;
235
+ }
236
+
237
+ /**
238
+ * Return the tools in `payload.tools` that are NOT part of `profile`'s
239
+ * baseline loadout — i.e., the tools the user actually configured beyond
240
+ * the harness default. If profile is null, returns the raw list.
241
+ *
242
+ * The array preserves the original tool objects (not just names) so the
243
+ * agentic detector can still inspect `t.name || t.function?.name`.
244
+ *
245
+ * @param {Object} payload
246
+ * @param {Object|null} profile
247
+ * @returns {Array}
248
+ */
249
+ function effectiveTools(payload, profile) {
250
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
251
+ if (!profile) return tools.slice();
252
+ const baseline = profile.baselineTools;
253
+ return tools.filter((t) => {
254
+ const name = _toolName(t);
255
+ return name && !baseline.has(name);
256
+ });
257
+ }
258
+
259
+ /**
260
+ * True when every tool name in the payload is present in some known profile's
261
+ * baseline. Used by the agentic detector's "unknown harness" guard so a
262
+ * Claude-Code-alike client that omits its user-agent doesn't inflate the
263
+ * agentic score.
264
+ *
265
+ * @param {Object} payload
266
+ * @returns {boolean}
267
+ */
268
+ function allToolsAreBaseline(payload) {
269
+ _loadUserProfiles();
270
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
271
+ if (tools.length === 0) return false;
272
+ const known = _knownBaselineTools();
273
+ for (const t of tools) {
274
+ const name = _toolName(t);
275
+ if (!name || !known.has(name)) return false;
276
+ }
277
+ return true;
278
+ }
279
+
280
+ /** Test helper — clear cached override state (unit tests reload from disk). */
281
+ function _resetForTests() {
282
+ _userProfilesLoaded = false;
283
+ _knownBaselineToolsCache = null;
284
+ }
285
+
286
+ module.exports = {
287
+ PROFILES,
288
+ detectClient,
289
+ effectiveTools,
290
+ allToolsAreBaseline,
291
+ _resetForTests,
292
+ };
@@ -15,6 +15,26 @@
15
15
  const logger = require('../logger');
16
16
  const config = require('../config');
17
17
  const codeGraph = require('../tools/code-graph');
18
+ const clientProfiles = require('./client-profiles');
19
+
20
+ /**
21
+ * Return the tools the user actually added beyond the client harness's
22
+ * baseline loadout. Same intent as the agentic detector's guard: Claude
23
+ * Code / Cursor / Codex all attach a fat baseline tools list to every
24
+ * request, and counting those inflates the complexity score for trivial
25
+ * turns. If no profile is attached, fall back to the raw list, but zero
26
+ * it out when every tool is a known-harness-baseline name AND there are
27
+ * enough of them to look like a harness attaching its default set
28
+ * (mirrors the "unknown_harness_guard" in agentic-detector.js).
29
+ */
30
+ function _effectiveTools(payload) {
31
+ const raw = Array.isArray(payload?.tools) ? payload.tools : [];
32
+ if (raw.length === 0) return raw;
33
+ const profile = payload?._clientProfile || null;
34
+ if (profile) return clientProfiles.effectiveTools(payload, profile);
35
+ if (raw.length >= 10 && clientProfiles.allToolsAreBaseline(payload)) return [];
36
+ return raw;
37
+ }
18
38
 
19
39
  // ============================================================================
20
40
  // PHASE 1: Basic Scoring Patterns
@@ -72,9 +92,23 @@ const ADVANCED_PATTERNS = {
72
92
  },
73
93
  };
74
94
 
75
- // Force cloud patterns - always route to cloud regardless of score
76
- 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 = [
77
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 = [
78
112
  /\b(architect(ure)?\s+(review|design|diagram)|system\s+design)\b/i,
79
113
  /\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
80
114
  /\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
@@ -422,10 +456,14 @@ function scoreTokens(payload) {
422
456
  }
423
457
 
424
458
  /**
425
- * Score based on tool count (0-20 points)
459
+ * Score based on tool count (0-20 points). Uses the effective tool list —
460
+ * i.e. tools the user actually added beyond the client harness's baseline
461
+ * loadout — so a trivial "hi" with 11 Claude Code tools attached doesn't
462
+ * pick up 16 points of "heavy tools" complexity for something the user
463
+ * didn't ask for.
426
464
  */
427
465
  function scoreTools(payload) {
428
- const toolCount = Array.isArray(payload?.tools) ? payload.tools.length : 0;
466
+ const toolCount = _effectiveTools(payload).length;
429
467
 
430
468
  if (toolCount === 0) return 0; // No tools
431
469
  if (toolCount <= 3) return 4; // Few tools - local can handle
@@ -441,7 +479,13 @@ function scoreTools(payload) {
441
479
  function scoreTaskType(content) {
442
480
  const contentLower = content.toLowerCase();
443
481
 
444
- // 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
+
445
489
  for (const pattern of FORCE_LOCAL_PATTERNS) {
446
490
  if (pattern.test(content)) {
447
491
  return { score: 0, reason: 'force_local', pattern: 'greeting_or_simple' };
@@ -450,7 +494,7 @@ function scoreTaskType(content) {
450
494
 
451
495
  for (const pattern of FORCE_CLOUD_PATTERNS) {
452
496
  if (pattern.test(content)) {
453
- 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) };
454
498
  }
455
499
  }
456
500
 
@@ -627,21 +671,22 @@ function calculateWeightedScore(payload, content) {
627
671
  }
628
672
  dimensions.domainSpecificity = Math.min(domainScore, 100);
629
673
 
630
- // 5. Tool count
631
- const toolCount = payload?.tools?.length ?? 0;
674
+ // 5. Tool count — effective tools only (harness baseline subtracted)
675
+ const effTools = _effectiveTools(payload);
676
+ const toolCount = effTools.length;
632
677
  dimensions.toolCount = toolCount === 0 ? 0 :
633
678
  toolCount <= 3 ? 20 :
634
679
  toolCount <= 6 ? 40 :
635
680
  toolCount <= 10 ? 60 :
636
681
  toolCount <= 15 ? 80 : 100;
637
682
 
638
- // 6. Tool complexity (weighted by tool types)
639
- if (payload?.tools?.length > 0) {
640
- const totalWeight = payload.tools.reduce((sum, t) => {
683
+ // 6. Tool complexity (weighted by tool types) — same effective list
684
+ if (effTools.length > 0) {
685
+ const totalWeight = effTools.reduce((sum, t) => {
641
686
  const name = t.name || t.function?.name || '';
642
687
  return sum + (TOOL_COMPLEXITY_WEIGHTS[name] || TOOL_COMPLEXITY_WEIGHTS.default);
643
688
  }, 0);
644
- const avgWeight = totalWeight / payload.tools.length;
689
+ const avgWeight = totalWeight / effTools.length;
645
690
  dimensions.toolComplexity = avgWeight * 100;
646
691
  } else {
647
692
  dimensions.toolComplexity = 0;
@@ -831,7 +876,7 @@ async function analyzeComplexity(payload, options = {}) {
831
876
  recommendation,
832
877
  breakdown: {
833
878
  tokens: { score: tokenScore, estimated: estimateTokens(payload) },
834
- tools: { score: toolScore, count: payload?.tools?.length ?? 0 },
879
+ tools: { score: toolScore, count: _effectiveTools(payload).length },
835
880
  taskType: taskTypeResult,
836
881
  codeComplexity: codeComplexityResult,
837
882
  reasoning: reasoningResult,
@@ -878,7 +923,15 @@ async function analyzeComplexity(payload, options = {}) {
878
923
  */
879
924
  function shouldForceLocal(payload) {
880
925
  const content = extractContent(payload);
881
- return FORCE_LOCAL_PATTERNS.some(pattern => pattern.test(content));
926
+ const matched = FORCE_LOCAL_PATTERNS.find(pattern => pattern.test(content));
927
+ if (matched) {
928
+ // Deliberately loud: a force_local misfire routed a subagent's opening
929
+ // frames to the weakest tier (live 2026-07-09, pattern unidentified at
930
+ // the time because the wrap gateway had no file logging). This line is
931
+ // how the next misfire names itself.
932
+ logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_local matched');
933
+ }
934
+ return !!matched;
882
935
  }
883
936
 
884
937
  /**
@@ -886,7 +939,25 @@ function shouldForceLocal(payload) {
886
939
  */
887
940
  function shouldForceCloud(payload) {
888
941
  const content = extractContent(payload);
889
- return FORCE_CLOUD_PATTERNS.some(pattern => pattern.test(content));
942
+ const matched = FORCE_CLOUD_PATTERNS.find(pattern => pattern.test(content));
943
+ if (matched) {
944
+ logger.debug({ pattern: matched.source.slice(0, 50), text: content.slice(0, 100) }, '[Force] force_cloud matched');
945
+ }
946
+ return !!matched;
947
+ }
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;
890
961
  }
891
962
 
892
963
  // ============================================================================
@@ -1004,6 +1075,7 @@ module.exports = {
1004
1075
  // Quick checks
1005
1076
  shouldForceLocal,
1006
1077
  shouldForceCloud,
1078
+ shouldForceReasoning,
1007
1079
 
1008
1080
  // Individual scoring (for testing/debugging)
1009
1081
  scoreTokens,
@@ -1032,6 +1104,7 @@ module.exports = {
1032
1104
  // Constants (for testing)
1033
1105
  PATTERNS,
1034
1106
  ADVANCED_PATTERNS,
1107
+ FORCE_REASONING_PATTERNS,
1035
1108
  FORCE_CLOUD_PATTERNS,
1036
1109
  FORCE_LOCAL_PATTERNS,
1037
1110
  DIMENSION_WEIGHTS,