lynkr 9.0.2 → 9.1.2
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/bin/cli.js +18 -1
- package/bin/lynkr-trajectory.js +136 -0
- package/bin/lynkr-usage.js +219 -0
- package/funding.json +110 -0
- package/package.json +2 -2
- package/public/dashboard.html +665 -0
- package/src/api/files-router.js +6 -6
- package/src/api/middleware/budget.js +19 -1
- package/src/api/middleware/load-shedding.js +17 -0
- package/src/api/openai-router.js +1 -1
- package/src/api/router.js +185 -47
- package/src/clients/databricks.js +9 -5
- package/src/clients/openai-format.js +31 -5
- package/src/config/index.js +7 -0
- package/src/dashboard/api.js +170 -0
- package/src/dashboard/router.js +13 -0
- package/src/headroom/client.js +3 -109
- package/src/headroom/index.js +0 -14
- package/src/memory/search.js +0 -50
- package/src/orchestrator/index.js +62 -5
- package/src/orchestrator/preflight.js +188 -0
- package/src/routing/index.js +61 -0
- package/src/routing/interaction.js +183 -0
- package/src/routing/risk-analyzer.js +194 -0
- package/src/routing/telemetry.js +7 -0
- package/src/server.js +3 -0
- package/src/stores/file-store.js +42 -7
- package/src/tools/smart-selection.js +11 -2
- package/src/training/trajectory-compressor.js +266 -0
- package/src/usage/aggregator.js +206 -0
- package/src/utils/markdown-ansi.js +146 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight Checks
|
|
3
|
+
*
|
|
4
|
+
* Runs user-supplied commands before invoking the model. If they all
|
|
5
|
+
* exit 0, the work is already done — we skip the LLM call entirely
|
|
6
|
+
* and return a synthetic "preflight_satisfied" response at zero cost.
|
|
7
|
+
*
|
|
8
|
+
* Typical use case: a fix-the-failing-test request that arrives after
|
|
9
|
+
* the test already passes (CI lag, retry-after-fix, idempotent agent
|
|
10
|
+
* retries).
|
|
11
|
+
*
|
|
12
|
+
* The request opts in by including a top-level `preflight_commands`
|
|
13
|
+
* array on the Anthropic-format payload, e.g.:
|
|
14
|
+
*
|
|
15
|
+
* {
|
|
16
|
+
* "model": "...",
|
|
17
|
+
* "messages": [...],
|
|
18
|
+
* "preflight_commands": ["pnpm test -- user-service"]
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Disabled by default — gated on LYNKR_PREFLIGHT_ENABLED=true. The
|
|
22
|
+
* commands run with the same permissions as the Lynkr server, so
|
|
23
|
+
* operators should only enable this on workspaces where that is OK.
|
|
24
|
+
*
|
|
25
|
+
* @module orchestrator/preflight
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const { spawnSync } = require('child_process');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const config = require('../config');
|
|
31
|
+
const logger = require('../logger');
|
|
32
|
+
|
|
33
|
+
const MAX_COMMANDS = 10;
|
|
34
|
+
const MAX_OUTPUT_BYTES = 4000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract the preflight command list from a request payload.
|
|
38
|
+
* Accepts either `preflight_commands` (Lynkr-specific) or
|
|
39
|
+
* `metadata.lynkr_preflight_commands` (for clients that strip unknown
|
|
40
|
+
* top-level fields).
|
|
41
|
+
*
|
|
42
|
+
* @param {object} payload
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
function extractCommands(payload) {
|
|
46
|
+
if (!payload) return [];
|
|
47
|
+
const raw =
|
|
48
|
+
payload.preflight_commands ||
|
|
49
|
+
payload.metadata?.lynkr_preflight_commands ||
|
|
50
|
+
[];
|
|
51
|
+
if (!Array.isArray(raw)) return [];
|
|
52
|
+
return raw
|
|
53
|
+
.filter(cmd => typeof cmd === 'string' && cmd.trim().length > 0)
|
|
54
|
+
.slice(0, MAX_COMMANDS);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the workspace path for command execution. Falls back to
|
|
59
|
+
* process.cwd() if no workspace is supplied (the caller should usually
|
|
60
|
+
* pass one explicitly).
|
|
61
|
+
*
|
|
62
|
+
* @param {string|null|undefined} cwd
|
|
63
|
+
* @returns {string|null} absolute path, or null if invalid
|
|
64
|
+
*/
|
|
65
|
+
function resolveCwd(cwd) {
|
|
66
|
+
if (!cwd || typeof cwd !== 'string') return null;
|
|
67
|
+
if (!path.isAbsolute(cwd)) return null;
|
|
68
|
+
return cwd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run a single command, returning a structured result.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} command
|
|
75
|
+
* @param {string} cwd
|
|
76
|
+
* @param {number} timeoutMs
|
|
77
|
+
* @returns {{ command: string, exit_code: number|null, stdout: string, stderr: string, timed_out: boolean }}
|
|
78
|
+
*/
|
|
79
|
+
function runCommand(command, cwd, timeoutMs) {
|
|
80
|
+
const result = spawnSync(command, {
|
|
81
|
+
cwd,
|
|
82
|
+
shell: true,
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
timeout: timeoutMs,
|
|
85
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
command,
|
|
89
|
+
exit_code: result.status,
|
|
90
|
+
stdout: (result.stdout || '').slice(-MAX_OUTPUT_BYTES),
|
|
91
|
+
stderr: (result.stderr || '').slice(-MAX_OUTPUT_BYTES),
|
|
92
|
+
timed_out: result.signal === 'SIGTERM',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Try the preflight pass. Returns null when preflight should be
|
|
98
|
+
* skipped (disabled, no commands, missing cwd). Returns a result
|
|
99
|
+
* object otherwise.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} args
|
|
102
|
+
* @param {object} args.payload - Anthropic-format request payload
|
|
103
|
+
* @param {string} [args.cwd] - Workspace cwd (absolute path)
|
|
104
|
+
* @returns {null | {
|
|
105
|
+
* satisfied: boolean,
|
|
106
|
+
* results: object[],
|
|
107
|
+
* failedCommand: string|null,
|
|
108
|
+
* reason: string,
|
|
109
|
+
* }}
|
|
110
|
+
*/
|
|
111
|
+
function tryPreflight({ payload, cwd }) {
|
|
112
|
+
if (!config.routing?.preflightEnabled) return null;
|
|
113
|
+
const commands = extractCommands(payload);
|
|
114
|
+
if (commands.length === 0) return null;
|
|
115
|
+
const workspaceCwd = resolveCwd(cwd);
|
|
116
|
+
if (!workspaceCwd) {
|
|
117
|
+
logger.debug({ cwd }, '[Preflight] No valid cwd, skipping');
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const timeoutMs = config.routing?.preflightTimeoutMs || 120000;
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const command of commands) {
|
|
124
|
+
const r = runCommand(command, workspaceCwd, timeoutMs);
|
|
125
|
+
results.push(r);
|
|
126
|
+
if (r.exit_code !== 0) {
|
|
127
|
+
return {
|
|
128
|
+
satisfied: false,
|
|
129
|
+
results,
|
|
130
|
+
failedCommand: command,
|
|
131
|
+
reason: r.timed_out
|
|
132
|
+
? `Preflight command timed out: ${command}`
|
|
133
|
+
: `Preflight command exited ${r.exit_code}: ${command}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
satisfied: true,
|
|
139
|
+
results,
|
|
140
|
+
failedCommand: null,
|
|
141
|
+
reason: 'All preflight commands passed.',
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a synthetic "preflight satisfied" Anthropic Message response
|
|
147
|
+
* that processMessage can return without hitting the model.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} args
|
|
150
|
+
* @param {string} args.model
|
|
151
|
+
* @param {object} args.preflightResult
|
|
152
|
+
* @returns {object} The full processMessage return value.
|
|
153
|
+
*/
|
|
154
|
+
function buildSatisfiedResponse({ model, preflightResult }) {
|
|
155
|
+
const summary = `Preflight satisfied — work appears already complete (${preflightResult.results.length} command${preflightResult.results.length === 1 ? '' : 's'} passed).`;
|
|
156
|
+
return {
|
|
157
|
+
response: {
|
|
158
|
+
json: {
|
|
159
|
+
id: `msg_preflight_${Date.now()}`,
|
|
160
|
+
type: 'message',
|
|
161
|
+
role: 'assistant',
|
|
162
|
+
content: [{ type: 'text', text: summary }],
|
|
163
|
+
model,
|
|
164
|
+
stop_reason: 'end_turn',
|
|
165
|
+
stop_sequence: null,
|
|
166
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
167
|
+
lynkr_preflight: {
|
|
168
|
+
satisfied: true,
|
|
169
|
+
reason: preflightResult.reason,
|
|
170
|
+
results: preflightResult.results,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
ok: true,
|
|
174
|
+
status: 200,
|
|
175
|
+
},
|
|
176
|
+
steps: 0,
|
|
177
|
+
durationMs: 0,
|
|
178
|
+
terminationReason: 'preflight_satisfied',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
tryPreflight,
|
|
184
|
+
buildSatisfiedResponse,
|
|
185
|
+
extractCommands,
|
|
186
|
+
// Exposed for tests
|
|
187
|
+
resolveCwd,
|
|
188
|
+
};
|
package/src/routing/index.js
CHANGED
|
@@ -22,6 +22,7 @@ const {
|
|
|
22
22
|
const { getAgenticDetector, AGENT_TYPES } = require('./agentic-detector');
|
|
23
23
|
const { getModelTierSelector, TIER_DEFINITIONS } = require('./model-tiers');
|
|
24
24
|
const { getCostOptimizer } = require('./cost-optimizer');
|
|
25
|
+
const { analyzeRisk } = require('./risk-analyzer');
|
|
25
26
|
|
|
26
27
|
// Telemetry modules
|
|
27
28
|
const telemetry = require('./telemetry');
|
|
@@ -97,6 +98,18 @@ function getBestLocalProvider() {
|
|
|
97
98
|
async function determineProviderSmart(payload, options = {}) {
|
|
98
99
|
const primaryProvider = config.modelProvider?.type ?? 'databricks';
|
|
99
100
|
|
|
101
|
+
// Risk analysis runs orthogonally to complexity. We compute it once
|
|
102
|
+
// up-front so it can short-circuit force_local and feed the tier
|
|
103
|
+
// selector below. Even when tier routing is disabled we still surface
|
|
104
|
+
// the signal for telemetry.
|
|
105
|
+
let risk = null;
|
|
106
|
+
try {
|
|
107
|
+
risk = analyzeRisk(payload);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
logger.debug({ err: err.message }, '[Routing] Risk analysis failed, ignoring');
|
|
110
|
+
risk = null;
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
// If tier routing is disabled, use static configuration
|
|
101
114
|
if (!config.modelTiers?.enabled) {
|
|
102
115
|
return {
|
|
@@ -104,9 +117,39 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
104
117
|
model: null,
|
|
105
118
|
method: 'static',
|
|
106
119
|
reason: 'tier_routing_disabled',
|
|
120
|
+
risk,
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
// High-risk requests jump straight to COMPLEX and skip the rest of
|
|
125
|
+
// the analysis. This is independent of complexity score — a one-line
|
|
126
|
+
// edit to auth/middleware.ts should never go to a local model.
|
|
127
|
+
if (risk?.level === 'high' && isFallbackEnabled()) {
|
|
128
|
+
try {
|
|
129
|
+
const selector = getModelTierSelector();
|
|
130
|
+
const modelSelection = selector.selectModel('COMPLEX', null);
|
|
131
|
+
const decision = {
|
|
132
|
+
provider: modelSelection.provider,
|
|
133
|
+
model: modelSelection.model,
|
|
134
|
+
tier: 'COMPLEX',
|
|
135
|
+
method: 'risk',
|
|
136
|
+
reason: 'high_risk_forced_tier',
|
|
137
|
+
score: 100,
|
|
138
|
+
risk,
|
|
139
|
+
};
|
|
140
|
+
routingMetrics.record(decision);
|
|
141
|
+
logger.debug({
|
|
142
|
+
tier: 'COMPLEX',
|
|
143
|
+
provider: decision.provider,
|
|
144
|
+
instructionHits: risk.instructionHits,
|
|
145
|
+
pathHits: risk.pathHits,
|
|
146
|
+
}, '[Routing] High risk → forcing tier');
|
|
147
|
+
return decision;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.debug({ err: err.message }, '[Routing] Risk-forced tier selection failed, falling through');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
// Quick check for force patterns
|
|
111
154
|
if (shouldForceLocal(payload)) {
|
|
112
155
|
// When tier routing is enabled, respect TIER_SIMPLE instead of blindly choosing local
|
|
@@ -121,6 +164,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
121
164
|
method: 'force',
|
|
122
165
|
reason: 'force_local_pattern',
|
|
123
166
|
score: 0,
|
|
167
|
+
risk,
|
|
124
168
|
};
|
|
125
169
|
routingMetrics.record(decision);
|
|
126
170
|
return decision;
|
|
@@ -135,6 +179,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
135
179
|
method: 'force',
|
|
136
180
|
reason: 'force_local_pattern',
|
|
137
181
|
score: 0,
|
|
182
|
+
risk,
|
|
138
183
|
};
|
|
139
184
|
routingMetrics.record(decision);
|
|
140
185
|
return decision;
|
|
@@ -148,6 +193,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
148
193
|
method: 'force',
|
|
149
194
|
reason: 'force_cloud_pattern',
|
|
150
195
|
score: 100,
|
|
196
|
+
risk,
|
|
151
197
|
};
|
|
152
198
|
routingMetrics.record(decision);
|
|
153
199
|
return decision;
|
|
@@ -201,6 +247,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
201
247
|
reason: 'autonomous_workflow',
|
|
202
248
|
score: analysis.score,
|
|
203
249
|
agenticResult,
|
|
250
|
+
risk,
|
|
204
251
|
};
|
|
205
252
|
routingMetrics.record(decision);
|
|
206
253
|
return decision;
|
|
@@ -263,6 +310,7 @@ async function determineProviderSmart(payload, options = {}) {
|
|
|
263
310
|
embeddingsResult,
|
|
264
311
|
agenticResult,
|
|
265
312
|
costOptimized: false,
|
|
313
|
+
risk,
|
|
266
314
|
};
|
|
267
315
|
|
|
268
316
|
// Phase 3: Record metrics
|
|
@@ -322,6 +370,18 @@ function getRoutingHeaders(decision) {
|
|
|
322
370
|
headers['X-Lynkr-Cost-Optimized'] = 'true';
|
|
323
371
|
}
|
|
324
372
|
|
|
373
|
+
if (decision.risk?.level) {
|
|
374
|
+
headers['X-Lynkr-Risk'] = decision.risk.level;
|
|
375
|
+
const hits = Array.from(new Set([
|
|
376
|
+
...(decision.risk.instructionHits || []),
|
|
377
|
+
...(decision.risk.pathHits || []),
|
|
378
|
+
]));
|
|
379
|
+
if (hits.length > 0) {
|
|
380
|
+
// Header values are ASCII-only; comma-join the first few hits.
|
|
381
|
+
headers['X-Lynkr-Risk-Hits'] = hits.slice(0, 8).join(',');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
325
385
|
return headers;
|
|
326
386
|
}
|
|
327
387
|
|
|
@@ -350,6 +410,7 @@ module.exports = {
|
|
|
350
410
|
|
|
351
411
|
// Re-export analyzer for direct access
|
|
352
412
|
analyzeComplexity: require('./complexity-analyzer').analyzeComplexity,
|
|
413
|
+
analyzeRisk,
|
|
353
414
|
|
|
354
415
|
// Intelligent routing modules
|
|
355
416
|
getAgenticDetector,
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routing Interaction Block
|
|
3
|
+
*
|
|
4
|
+
* Builds an "interaction" block that explains, in plain text, what
|
|
5
|
+
* Lynkr decided to do with a request — which tier, which provider,
|
|
6
|
+
* why it routed there, and what (if anything) the user should do next.
|
|
7
|
+
*
|
|
8
|
+
* Lynkr already surfaces this information via X-Lynkr-* response
|
|
9
|
+
* headers, but headers are invisible to most users in Claude Code /
|
|
10
|
+
* Cursor / Codex. The interaction block lives in the response body
|
|
11
|
+
* so it shows up alongside the model's reply when the visible-routing
|
|
12
|
+
* env flag is on (LYNKR_VISIBLE_ROUTING=true).
|
|
13
|
+
*
|
|
14
|
+
* @module routing/interaction
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rough estimate of cost savings vs always-COMPLEX baseline. Not
|
|
19
|
+
* invoice-grade, just a reproducible number for users to glance at.
|
|
20
|
+
*
|
|
21
|
+
* @param {string|null} tier
|
|
22
|
+
* @param {string|null} provider
|
|
23
|
+
* @returns {number} 0-100
|
|
24
|
+
*/
|
|
25
|
+
function estimateSavingsPercent(tier, provider) {
|
|
26
|
+
if (!tier) return 0;
|
|
27
|
+
const t = tier.toUpperCase();
|
|
28
|
+
// Local providers carry the same savings band as their tier.
|
|
29
|
+
const isLocal = provider && ['ollama', 'llamacpp', 'lmstudio'].includes(provider);
|
|
30
|
+
if (t === 'SIMPLE') return isLocal ? 100 : 70;
|
|
31
|
+
if (t === 'MEDIUM') return isLocal ? 90 : 45;
|
|
32
|
+
if (t === 'COMPLEX') return 10;
|
|
33
|
+
if (t === 'REASONING') return 0;
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Choose a mode label that describes what happened.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} decision
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function modeFor(decision) {
|
|
44
|
+
if (decision.method === 'risk') return 'risk_forced_tier';
|
|
45
|
+
if (decision.method === 'agentic') return 'agentic_workflow';
|
|
46
|
+
if (decision.method === 'force' && decision.reason === 'force_local_pattern') return 'force_local';
|
|
47
|
+
if (decision.method === 'force' && decision.reason === 'force_cloud_pattern') return 'force_cloud';
|
|
48
|
+
if (decision.method === 'static') return 'static';
|
|
49
|
+
return 'tier_routed';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Produce a one-line, terminal-friendly route label, e.g.
|
|
54
|
+
* "[Lynkr] tier=COMPLEX provider=databricks risk=high score=78"
|
|
55
|
+
*
|
|
56
|
+
* @param {object} decision
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function routeLabel(decision) {
|
|
60
|
+
const parts = ['[Lynkr]'];
|
|
61
|
+
if (decision.tier) parts.push(`tier=${decision.tier}`);
|
|
62
|
+
if (decision.provider) parts.push(`provider=${decision.provider}`);
|
|
63
|
+
if (decision.model) parts.push(`model=${decision.model}`);
|
|
64
|
+
if (decision.risk?.level) parts.push(`risk=${decision.risk.level}`);
|
|
65
|
+
if (typeof decision.score === 'number') parts.push(`score=${decision.score}`);
|
|
66
|
+
return parts.join(' ');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Headline + next_step are model-facing prose. We keep them terse so
|
|
71
|
+
* they don't pollute the user's view when the model echoes them back.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} decision
|
|
74
|
+
* @returns {{ headline: string, next_step: string }}
|
|
75
|
+
*/
|
|
76
|
+
function copyFor(decision) {
|
|
77
|
+
const mode = modeFor(decision);
|
|
78
|
+
if (mode === 'risk_forced_tier') {
|
|
79
|
+
return {
|
|
80
|
+
headline: `Lynkr routed to ${decision.tier} tier because the request touches a protected domain.`,
|
|
81
|
+
next_step: 'Review the response carefully — sensitive logic was involved.',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (mode === 'agentic_workflow') {
|
|
85
|
+
return {
|
|
86
|
+
headline: `Lynkr detected an agentic workflow and routed to ${decision.provider || decision.tier}.`,
|
|
87
|
+
next_step: 'No action needed — autonomous workflows always use cloud providers.',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (mode === 'force_local') {
|
|
91
|
+
return {
|
|
92
|
+
headline: 'Lynkr routed to the local tier (greeting or trivial request).',
|
|
93
|
+
next_step: 'No action needed.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (mode === 'force_cloud') {
|
|
97
|
+
return {
|
|
98
|
+
headline: `Lynkr forced cloud routing (${decision.provider || 'cloud'}) for this request.`,
|
|
99
|
+
next_step: 'No action needed.',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (mode === 'static') {
|
|
103
|
+
return {
|
|
104
|
+
headline: `Lynkr used the static provider ${decision.provider}.`,
|
|
105
|
+
next_step: 'Tier routing is disabled — set TIER_* env vars to enable.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
headline: `Lynkr routed to the ${decision.tier || 'default'} tier (${decision.provider || 'unknown'}).`,
|
|
110
|
+
next_step: 'No action needed.',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the full interaction block.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} decision - The routing decision (from determineProviderSmart
|
|
118
|
+
* or the pre-route in api/router.js). Must at least have `provider`; ideally
|
|
119
|
+
* includes `tier`, `model`, `method`, `reason`, `score`, and `risk`.
|
|
120
|
+
* @returns {object}
|
|
121
|
+
*/
|
|
122
|
+
function buildInteractionBlock(decision) {
|
|
123
|
+
if (!decision || typeof decision !== 'object') return null;
|
|
124
|
+
const { headline, next_step } = copyFor(decision);
|
|
125
|
+
return {
|
|
126
|
+
tool: 'lynkr.route',
|
|
127
|
+
mode: modeFor(decision),
|
|
128
|
+
headline,
|
|
129
|
+
route_label: routeLabel(decision),
|
|
130
|
+
reason: decision.reason || 'unspecified',
|
|
131
|
+
tier: decision.tier || null,
|
|
132
|
+
provider: decision.provider || null,
|
|
133
|
+
model: decision.model || null,
|
|
134
|
+
risk: decision.risk?.level || 'low',
|
|
135
|
+
risk_hits: Array.from(new Set([
|
|
136
|
+
...(decision.risk?.instructionHits || []),
|
|
137
|
+
...(decision.risk?.pathHits || []),
|
|
138
|
+
])),
|
|
139
|
+
complexity_score: typeof decision.score === 'number' ? decision.score : null,
|
|
140
|
+
estimated_savings_percent: estimateSavingsPercent(decision.tier, decision.provider),
|
|
141
|
+
next_step,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Attach an interaction block to an Anthropic-format response body.
|
|
147
|
+
* Mutates and returns the body.
|
|
148
|
+
*
|
|
149
|
+
* Anthropic clients ignore unknown top-level fields, so this is safe.
|
|
150
|
+
*
|
|
151
|
+
* @param {object} body
|
|
152
|
+
* @param {object} interaction
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
function attachToAnthropicResponse(body, interaction) {
|
|
156
|
+
if (!body || !interaction) return body;
|
|
157
|
+
body.lynkr_interaction = interaction;
|
|
158
|
+
return body;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Attach an interaction block to an OpenAI chat-completions response.
|
|
163
|
+
* Mutates and returns the body.
|
|
164
|
+
*
|
|
165
|
+
* @param {object} body
|
|
166
|
+
* @param {object} interaction
|
|
167
|
+
* @returns {object}
|
|
168
|
+
*/
|
|
169
|
+
function attachToOpenAIResponse(body, interaction) {
|
|
170
|
+
if (!body || !interaction) return body;
|
|
171
|
+
body.lynkr_interaction = interaction;
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = {
|
|
176
|
+
buildInteractionBlock,
|
|
177
|
+
attachToAnthropicResponse,
|
|
178
|
+
attachToOpenAIResponse,
|
|
179
|
+
// Exposed for tests
|
|
180
|
+
estimateSavingsPercent,
|
|
181
|
+
modeFor,
|
|
182
|
+
routeLabel,
|
|
183
|
+
};
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Risk Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Scores a request along a risk axis that is orthogonal to complexity.
|
|
5
|
+
* A trivially short edit to `auth/middleware.ts` is still high risk and
|
|
6
|
+
* should not be served by a cheap local model.
|
|
7
|
+
*
|
|
8
|
+
* @module routing/risk-analyzer
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { extractContent } = require('./complexity-analyzer');
|
|
12
|
+
|
|
13
|
+
// Substring keywords found in file paths or instruction text.
|
|
14
|
+
// Matched case-insensitively as raw substrings, so "auth" hits
|
|
15
|
+
// "src/auth/login.ts" and "authentication".
|
|
16
|
+
const PROTECTED_PATH_KEYWORDS = [
|
|
17
|
+
'auth', 'oauth', 'jwt', 'session', 'security', 'permission', 'rbac',
|
|
18
|
+
'payment', 'payments', 'billing', 'invoice', 'subscription',
|
|
19
|
+
'migration', 'migrations', 'schema',
|
|
20
|
+
'infra', 'terraform', 'kustomize', 'helm', 'kubernetes',
|
|
21
|
+
'.github/workflows', '.env', 'secret', 'credential',
|
|
22
|
+
'api-key', 'api_key', 'apikey', 'token',
|
|
23
|
+
'webhook', 'admin',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Whole-word instruction keywords that signal sensitive intent regardless
|
|
27
|
+
// of which files are involved. Higher signal than path keywords because
|
|
28
|
+
// they reflect what the user is *asking for*.
|
|
29
|
+
const HIGH_RISK_INSTRUCTION_KEYWORDS = [
|
|
30
|
+
'authentication', 'authorization', 'permission', 'security',
|
|
31
|
+
'payment', 'billing', 'migration', 'database schema',
|
|
32
|
+
'encrypt', 'decrypt', 'secret', 'credential', 'api key',
|
|
33
|
+
'production', 'deploy', 'rollout', 'rollback',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Path-extracting patterns. We look at:
|
|
37
|
+
// 1. Anything that looks like a file path inside the instruction text.
|
|
38
|
+
// 2. Explicit path-like fields in tool inputs (e.g. tool_use blocks).
|
|
39
|
+
const PATH_LIKE_RE = /(?:^|[\s`'"([])([./a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,8})(?=[\s`'")\]:,;]|$)/g;
|
|
40
|
+
const SLASHED_PATH_RE = /(?:^|[\s`'"([])((?:[a-zA-Z0-9_.-]+\/)+[a-zA-Z0-9_.-]+)(?=[\s`'")\]:,;]|$)/g;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pull every path-shaped substring out of free-form text.
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
function extractPathsFromText(text) {
|
|
48
|
+
if (!text) return [];
|
|
49
|
+
const out = new Set();
|
|
50
|
+
let m;
|
|
51
|
+
while ((m = PATH_LIKE_RE.exec(text)) !== null) {
|
|
52
|
+
out.add(m[1]);
|
|
53
|
+
}
|
|
54
|
+
while ((m = SLASHED_PATH_RE.exec(text)) !== null) {
|
|
55
|
+
out.add(m[1]);
|
|
56
|
+
}
|
|
57
|
+
return Array.from(out);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Walk every tool_use block in the conversation and collect any string
|
|
62
|
+
* inputs that look like paths. Catches cases where the model already
|
|
63
|
+
* called an Edit/Read tool on a sensitive file.
|
|
64
|
+
* @param {object} payload
|
|
65
|
+
* @returns {string[]}
|
|
66
|
+
*/
|
|
67
|
+
function extractPathsFromToolUses(payload) {
|
|
68
|
+
const out = new Set();
|
|
69
|
+
const messages = payload?.messages;
|
|
70
|
+
if (!Array.isArray(messages)) return [];
|
|
71
|
+
|
|
72
|
+
for (const msg of messages) {
|
|
73
|
+
if (!Array.isArray(msg?.content)) continue;
|
|
74
|
+
for (const block of msg.content) {
|
|
75
|
+
if (block?.type !== 'tool_use' || !block.input) continue;
|
|
76
|
+
const stack = [block.input];
|
|
77
|
+
while (stack.length) {
|
|
78
|
+
const node = stack.pop();
|
|
79
|
+
if (typeof node === 'string') {
|
|
80
|
+
if (node.includes('/') || node.includes('.')) {
|
|
81
|
+
// Treat short tool-input strings that look path-y as paths.
|
|
82
|
+
if (node.length <= 200) out.add(node);
|
|
83
|
+
}
|
|
84
|
+
} else if (Array.isArray(node)) {
|
|
85
|
+
for (const v of node) stack.push(v);
|
|
86
|
+
} else if (node && typeof node === 'object') {
|
|
87
|
+
for (const v of Object.values(node)) stack.push(v);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Array.from(out);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find which keywords from `keywords` appear (case-insensitively) inside
|
|
97
|
+
* any of `haystack`. Substring match — by design — so "auth" matches
|
|
98
|
+
* both "src/auth/login.ts" and the word "authorization".
|
|
99
|
+
* @param {string[]} keywords
|
|
100
|
+
* @param {string[]} haystack
|
|
101
|
+
* @returns {string[]} hit keywords, sorted
|
|
102
|
+
*/
|
|
103
|
+
function findHits(keywords, haystack) {
|
|
104
|
+
const hits = new Set();
|
|
105
|
+
const joined = haystack.join('\n').toLowerCase();
|
|
106
|
+
for (const kw of keywords) {
|
|
107
|
+
if (joined.includes(kw.toLowerCase())) hits.add(kw);
|
|
108
|
+
}
|
|
109
|
+
return Array.from(hits).sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Analyze the risk level of a request.
|
|
114
|
+
*
|
|
115
|
+
* Risk is orthogonal to complexity:
|
|
116
|
+
* - low → no protected paths or sensitive keywords detected
|
|
117
|
+
* - medium → protected paths *or* a read-only task on a protected area
|
|
118
|
+
* - high → instruction explicitly names sensitive domain logic,
|
|
119
|
+
* or protected paths combined with a write-intent task
|
|
120
|
+
*
|
|
121
|
+
* @param {object} payload - Anthropic-format request payload
|
|
122
|
+
* @returns {{ level: 'low'|'medium'|'high',
|
|
123
|
+
* reason: string,
|
|
124
|
+
* pathHits: string[],
|
|
125
|
+
* instructionHits: string[],
|
|
126
|
+
* paths: string[] }}
|
|
127
|
+
*/
|
|
128
|
+
function analyzeRisk(payload) {
|
|
129
|
+
const instructionText = extractContent(payload) || '';
|
|
130
|
+
const lowText = instructionText.toLowerCase();
|
|
131
|
+
|
|
132
|
+
const textPaths = extractPathsFromText(instructionText);
|
|
133
|
+
const toolPaths = extractPathsFromToolUses(payload);
|
|
134
|
+
const allPaths = Array.from(new Set([...textPaths, ...toolPaths]));
|
|
135
|
+
|
|
136
|
+
// Instruction-level hits scan the raw text. Path-level hits scan only
|
|
137
|
+
// the extracted path strings so phrases like "authentication is hard"
|
|
138
|
+
// don't double-fire as a path hit.
|
|
139
|
+
const instructionHits = findHits(HIGH_RISK_INSTRUCTION_KEYWORDS, [instructionText]);
|
|
140
|
+
const pathHits = findHits(PROTECTED_PATH_KEYWORDS, allPaths.length ? allPaths : []);
|
|
141
|
+
// Also let path keywords match against the instruction text — covers
|
|
142
|
+
// "update the auth flow" with no path mentioned.
|
|
143
|
+
const textPathHits = findHits(PROTECTED_PATH_KEYWORDS, [instructionText]);
|
|
144
|
+
const mergedPathHits = Array.from(new Set([...pathHits, ...textPathHits])).sort();
|
|
145
|
+
|
|
146
|
+
if (instructionHits.length > 0) {
|
|
147
|
+
return {
|
|
148
|
+
level: 'high',
|
|
149
|
+
reason: 'High-risk instruction keyword detected.',
|
|
150
|
+
pathHits: mergedPathHits,
|
|
151
|
+
instructionHits,
|
|
152
|
+
paths: allPaths,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (mergedPathHits.length > 0) {
|
|
157
|
+
// Read-only intent on a protected area is medium, not high.
|
|
158
|
+
// Heuristic: presence of explain/summarize/read verbs.
|
|
159
|
+
const readOnly = /\b(explain|summarize|describe|what does|walk me through|read|show|list|search|find|grep|locate)\b/i.test(lowText);
|
|
160
|
+
if (readOnly) {
|
|
161
|
+
return {
|
|
162
|
+
level: 'medium',
|
|
163
|
+
reason: 'Protected paths involved but task appears read-only.',
|
|
164
|
+
pathHits: mergedPathHits,
|
|
165
|
+
instructionHits: [],
|
|
166
|
+
paths: allPaths,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
level: 'high',
|
|
171
|
+
reason: 'Protected path referenced with write-capable intent.',
|
|
172
|
+
pathHits: mergedPathHits,
|
|
173
|
+
instructionHits: [],
|
|
174
|
+
paths: allPaths,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
level: 'low',
|
|
180
|
+
reason: 'No risk signals detected.',
|
|
181
|
+
pathHits: [],
|
|
182
|
+
instructionHits: [],
|
|
183
|
+
paths: allPaths,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
analyzeRisk,
|
|
189
|
+
PROTECTED_PATH_KEYWORDS,
|
|
190
|
+
HIGH_RISK_INSTRUCTION_KEYWORDS,
|
|
191
|
+
// Exposed for tests
|
|
192
|
+
extractPathsFromText,
|
|
193
|
+
extractPathsFromToolUses,
|
|
194
|
+
};
|