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.
- package/README.md +63 -25
- package/bin/cli.js +16 -1
- package/bin/lynkr-init.js +44 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +23 -2
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/build-eval-set.js +256 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/validate-difficulty-classifier.js +123 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +467 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +932 -47
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +88 -15
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/difficulty-classifier.js +219 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +931 -90
- package/src/routing/intent-score.js +441 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +86 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- 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
|
|
76
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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 (
|
|
640
|
-
const totalWeight =
|
|
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 /
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|