lynkr 9.0.1 → 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.
Files changed (58) hide show
  1. package/README.md +70 -21
  2. package/bin/cli.js +34 -4
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/index.js +7 -3
  7. package/install.sh +3 -3
  8. package/lynkr-skill.tar.gz +0 -0
  9. package/native/Cargo.toml +26 -0
  10. package/native/index.js +29 -0
  11. package/native/lynkr-native.node +0 -0
  12. package/native/src/lib.rs +321 -0
  13. package/package.json +6 -5
  14. package/public/dashboard.html +665 -0
  15. package/src/api/files-multipart.js +30 -0
  16. package/src/api/files-router.js +81 -0
  17. package/src/api/middleware/budget.js +19 -1
  18. package/src/api/middleware/load-shedding.js +17 -0
  19. package/src/api/openai-router.js +353 -301
  20. package/src/api/router.js +275 -40
  21. package/src/cache/prompt.js +13 -0
  22. package/src/clients/databricks.js +42 -18
  23. package/src/clients/ollama-utils.js +21 -17
  24. package/src/clients/openai-format.js +50 -10
  25. package/src/clients/openrouter-utils.js +42 -37
  26. package/src/clients/prompt-cache-injection.js +140 -0
  27. package/src/clients/provider-capabilities.js +41 -0
  28. package/src/clients/responses-format.js +8 -7
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +16 -0
  33. package/src/context/distill.js +15 -0
  34. package/src/context/tool-result-compressor.js +563 -0
  35. package/src/dashboard/api.js +170 -0
  36. package/src/dashboard/router.js +13 -0
  37. package/src/headroom/client.js +3 -109
  38. package/src/headroom/index.js +0 -14
  39. package/src/memory/extractor.js +22 -0
  40. package/src/memory/search.js +0 -50
  41. package/src/orchestrator/index.js +163 -204
  42. package/src/orchestrator/preflight.js +188 -0
  43. package/src/routing/index.js +64 -32
  44. package/src/routing/interaction.js +183 -0
  45. package/src/routing/risk-analyzer.js +194 -0
  46. package/src/routing/telemetry.js +47 -2
  47. package/src/server.js +15 -0
  48. package/src/stores/file-store.js +104 -0
  49. package/src/stores/response-store.js +25 -0
  50. package/src/tools/index.js +1 -1
  51. package/src/tools/smart-selection.js +11 -2
  52. package/src/tools/web.js +1 -1
  53. package/src/training/trajectory-compressor.js +266 -0
  54. package/src/usage/aggregator.js +206 -0
  55. package/src/utils/markdown-ansi.js +146 -0
  56. package/.lynkr/telemetry.db +0 -0
  57. package/.lynkr/telemetry.db-shm +0 -0
  58. package/.lynkr/telemetry.db-wal +0 -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
+ };
@@ -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;
@@ -247,37 +294,8 @@ async function determineProviderSmart(payload, options = {}) {
247
294
  selectedModel = modelSelection.model;
248
295
  logger.debug({ tier, provider, model: selectedModel }, '[Routing] Using tier config');
249
296
 
250
- // Cost optimization: check if cheaper model can handle this tier
251
- let costOptimized = false;
252
- if (config.routing?.costOptimization && tier) {
253
- try {
254
- const optimizer = getCostOptimizer();
255
- const availableProviders = [provider];
256
-
257
- // Also consider local provider if not already selected
258
- const localProvider = getBestLocalProvider();
259
- if (localProvider !== provider) {
260
- availableProviders.push(localProvider);
261
- }
262
-
263
- const cheapest = optimizer.findCheapestForTier(tier, availableProviders);
264
- if (cheapest && cheapest.provider !== provider) {
265
- logger.debug({
266
- from: provider,
267
- to: cheapest.provider,
268
- tier,
269
- savings: `${cheapest.model} is cheaper`,
270
- }, '[Routing] Cost optimization: switching provider');
271
-
272
- provider = cheapest.provider;
273
- selectedModel = cheapest.model;
274
- costOptimized = true;
275
- method = 'cost_optimized';
276
- }
277
- } catch (err) {
278
- logger.debug({ err: err.message }, 'Cost optimization failed');
279
- }
280
- }
297
+ // TIER_* env vars are the final word no cost optimization override.
298
+ // The user explicitly configured provider:model per tier; respect that.
281
299
 
282
300
  const decision = {
283
301
  provider,
@@ -291,7 +309,8 @@ async function determineProviderSmart(payload, options = {}) {
291
309
  analysis,
292
310
  embeddingsResult,
293
311
  agenticResult,
294
- costOptimized,
312
+ costOptimized: false,
313
+ risk,
295
314
  };
296
315
 
297
316
  // Phase 3: Record metrics
@@ -351,6 +370,18 @@ function getRoutingHeaders(decision) {
351
370
  headers['X-Lynkr-Cost-Optimized'] = 'true';
352
371
  }
353
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
+
354
385
  return headers;
355
386
  }
356
387
 
@@ -379,6 +410,7 @@ module.exports = {
379
410
 
380
411
  // Re-export analyzer for direct access
381
412
  analyzeComplexity: require('./complexity-analyzer').analyzeComplexity,
413
+ analyzeRisk,
382
414
 
383
415
  // Intelligent routing modules
384
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
+ };