lynkr 9.7.2 → 9.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +29 -19
  2. package/bin/cli.js +11 -0
  3. package/bin/lynkr-init.js +14 -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 +24 -3
  8. package/scripts/audit-log-reader.js +399 -0
  9. package/scripts/calibrate-thresholds.js +38 -157
  10. package/scripts/compact-dictionary.js +204 -0
  11. package/scripts/test-deduplication.js +448 -0
  12. package/scripts/ws7-anchor-replay.js +108 -0
  13. package/skills/lynkr/SKILL.md +195 -0
  14. package/src/agents/context-manager.js +18 -2
  15. package/src/agents/definitions/loader.js +90 -0
  16. package/src/agents/executor.js +24 -2
  17. package/src/agents/index.js +9 -1
  18. package/src/agents/parallel-coordinator.js +2 -2
  19. package/src/agents/reflector.js +11 -1
  20. package/src/api/middleware/loop-guard.js +87 -0
  21. package/src/api/middleware/request-logging.js +5 -64
  22. package/src/api/middleware/session.js +0 -0
  23. package/src/api/openai-router.js +120 -101
  24. package/src/api/providers-handler.js +27 -2
  25. package/src/api/router.js +450 -125
  26. package/src/budget/index.js +2 -19
  27. package/src/cache/semantic.js +9 -0
  28. package/src/clients/databricks.js +459 -146
  29. package/src/clients/gpt-utils.js +11 -105
  30. package/src/clients/openai-format.js +10 -3
  31. package/src/clients/openrouter-utils.js +49 -24
  32. package/src/clients/prompt-cache-injection.js +1 -0
  33. package/src/clients/provider-capabilities.js +1 -1
  34. package/src/clients/responses-format.js +34 -3
  35. package/src/clients/routing.js +15 -0
  36. package/src/config/index.js +36 -2
  37. package/src/context/gcf.js +275 -0
  38. package/src/context/tool-result-compressor.js +51 -9
  39. package/src/dashboard/api.js +1 -0
  40. package/src/logger/index.js +14 -1
  41. package/src/memory/search.js +12 -40
  42. package/src/memory/tools.js +3 -24
  43. package/src/orchestrator/bypass.js +4 -2
  44. package/src/orchestrator/index.js +144 -88
  45. package/src/routing/affinity-store.js +194 -0
  46. package/src/routing/agentic-detector.js +36 -6
  47. package/src/routing/bandit.js +25 -6
  48. package/src/routing/calibration.js +212 -0
  49. package/src/routing/client-profiles.js +292 -0
  50. package/src/routing/complexity-analyzer.js +48 -11
  51. package/src/routing/deescalator.js +148 -0
  52. package/src/routing/degradation.js +109 -0
  53. package/src/routing/feedback.js +157 -0
  54. package/src/routing/index.js +897 -87
  55. package/src/routing/intent-score.js +339 -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 +298 -10
  65. package/src/routing/verifier.js +267 -0
  66. package/src/server.js +66 -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
@@ -8,19 +8,30 @@
8
8
  *
9
9
  * Normalisation uses running min/max so we don't need to pre-compute global
10
10
  * scales.
11
+ *
12
+ * WS5.1 — state persists to data/reward-state.json so the normaliser ranges
13
+ * survive process restarts. Without this, the first N requests after every
14
+ * restart get scaled against a re-learned range and produce noisy rewards.
11
15
  */
12
16
 
17
+ const fs = require('fs');
18
+ const path = require('path');
13
19
  const logger = require('../logger');
14
20
 
21
+ const STATE_PATH = path.join(__dirname, '../../data/reward-state.json');
15
22
  const DEFAULT_LAMBDA = 0.3;
16
23
  const DEFAULT_MU = 0.1;
24
+ const SAVE_EVERY = 25;
17
25
 
18
26
  class RewardPipeline {
19
- constructor({ lambda = DEFAULT_LAMBDA, mu = DEFAULT_MU } = {}) {
27
+ constructor({ lambda = DEFAULT_LAMBDA, mu = DEFAULT_MU, statePath = STATE_PATH } = {}) {
20
28
  this.lambda = lambda;
21
29
  this.mu = mu;
22
30
  this.costRange = { min: Infinity, max: -Infinity };
23
31
  this.latencyRange = { min: Infinity, max: -Infinity };
32
+ this.observations = 0;
33
+ this.statePath = statePath;
34
+ this._load();
24
35
  }
25
36
 
26
37
  observe({ cost, latency }) {
@@ -46,11 +57,66 @@ class RewardPipeline {
46
57
  */
47
58
  reward(obs) {
48
59
  this.observe(obs);
60
+ this.observations++;
61
+ // Periodic save mirrors bandit.js: cheap enough to write inline every
62
+ // N observations, no risk of blocking the response path since callers
63
+ // invoke reward() from setImmediate.
64
+ if (this.observations % SAVE_EVERY === 0) this._save();
49
65
  const q = typeof obs.quality === 'number' ? obs.quality : 50;
50
66
  const cn = this._normalize(obs.cost ?? 0, this.costRange);
51
67
  const ln = this._normalize(obs.latency ?? 0, this.latencyRange);
52
68
  return Math.max(0, Math.min(100, q - this.lambda * cn * 100 - this.mu * ln * 100));
53
69
  }
70
+
71
+ _save() {
72
+ try {
73
+ fs.mkdirSync(path.dirname(this.statePath), { recursive: true });
74
+ fs.writeFileSync(this.statePath, JSON.stringify({
75
+ savedAt: Date.now(),
76
+ lambda: this.lambda,
77
+ mu: this.mu,
78
+ observations: this.observations,
79
+ // Serialise Infinity as null; _load() re-hydrates.
80
+ costRange: {
81
+ min: isFinite(this.costRange.min) ? this.costRange.min : null,
82
+ max: isFinite(this.costRange.max) ? this.costRange.max : null,
83
+ },
84
+ latencyRange: {
85
+ min: isFinite(this.latencyRange.min) ? this.latencyRange.min : null,
86
+ max: isFinite(this.latencyRange.max) ? this.latencyRange.max : null,
87
+ },
88
+ }, null, 0));
89
+ } catch (err) {
90
+ logger.debug({ err: err.message }, '[Reward] State save failed');
91
+ }
92
+ }
93
+
94
+ _load() {
95
+ try {
96
+ if (!fs.existsSync(this.statePath)) return;
97
+ const raw = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
98
+ if (raw.costRange) {
99
+ this.costRange = {
100
+ min: raw.costRange.min == null ? Infinity : raw.costRange.min,
101
+ max: raw.costRange.max == null ? -Infinity : raw.costRange.max,
102
+ };
103
+ }
104
+ if (raw.latencyRange) {
105
+ this.latencyRange = {
106
+ min: raw.latencyRange.min == null ? Infinity : raw.latencyRange.min,
107
+ max: raw.latencyRange.max == null ? -Infinity : raw.latencyRange.max,
108
+ };
109
+ }
110
+ this.observations = raw.observations || 0;
111
+ logger.info({
112
+ cost: this.costRange,
113
+ latency: this.latencyRange,
114
+ observations: this.observations,
115
+ }, '[Reward] State loaded');
116
+ } catch (err) {
117
+ logger.debug({ err: err.message }, '[Reward] State load failed');
118
+ }
119
+ }
54
120
  }
55
121
 
56
122
  let _instance = null;
@@ -59,4 +125,4 @@ function getRewardPipeline() {
59
125
  return _instance;
60
126
  }
61
127
 
62
- module.exports = { RewardPipeline, getRewardPipeline };
128
+ module.exports = { RewardPipeline, getRewardPipeline, SAVE_EVERY };
@@ -114,6 +114,34 @@ function findHits(keywords, haystack) {
114
114
  return Array.from(hits).sort();
115
115
  }
116
116
 
117
+ /**
118
+ * Strip harness-injected <system-reminder> blocks from instruction text
119
+ * before risk scanning. Claude Code appends these blocks to the latest
120
+ * user message (CLAUDE.md contents, MCP-server auth notices, tool-search
121
+ * hints). Their boilerplate routinely contains words like "authentication",
122
+ * "credential", and "security" — live traffic showed a bare "23+45" turn
123
+ * force-escalated to COMPLEX on instructionHits ["credential","security"]
124
+ * that the user never typed. Risk must reflect what the USER is asking,
125
+ * not what the harness injected; genuinely risky asks live in the typed
126
+ * text and still fire. (The intent scorers have stripped these blocks
127
+ * since WS3 — this brings the risk scan in line.)
128
+ * @param {string} text
129
+ * @returns {string}
130
+ */
131
+ function stripSystemReminders(text) {
132
+ if (typeof text !== 'string' || !text) return '';
133
+ return text
134
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
135
+ // Codex harness blocks arrive as user-role messages and get merged into
136
+ // the typed text by the orchestrator's consecutive-role coalescing.
137
+ // <environment_context> carries <permission_profile> ("permission" is a
138
+ // high-risk keyword — live traffic showed every Codex turn, including
139
+ // "Hi", force-escalated to COMPLEX on it) and <user_instructions>
140
+ // carries AGENTS.md contents the user never typed this turn.
141
+ .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
142
+ .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ');
143
+ }
144
+
117
145
  /**
118
146
  * Analyze the risk level of a request.
119
147
  *
@@ -131,7 +159,7 @@ function findHits(keywords, haystack) {
131
159
  * paths: string[] }}
132
160
  */
133
161
  function analyzeRisk(payload) {
134
- const instructionText = extractContent(payload) || '';
162
+ const instructionText = stripSystemReminders(extractContent(payload) || '');
135
163
  const lowText = instructionText.toLowerCase();
136
164
 
137
165
  const textPaths = extractPathsFromText(instructionText);
@@ -193,6 +221,7 @@ module.exports = {
193
221
  analyzeRisk,
194
222
  PROTECTED_PATH_KEYWORDS,
195
223
  HIGH_RISK_INSTRUCTION_KEYWORDS,
224
+ stripSystemReminders,
196
225
  // Exposed for tests
197
226
  extractPathsFromText,
198
227
  extractPathsFromToolUses,
@@ -15,7 +15,7 @@
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const logger = require('../logger');
18
- const { analyzeRisk: regexAnalyzeRisk } = require('./risk-analyzer');
18
+ const { analyzeRisk: regexAnalyzeRisk, stripSystemReminders } = require('./risk-analyzer');
19
19
 
20
20
  const MODEL_PATH = path.join(__dirname, '../../data/risk-classifier.json');
21
21
  const DECISION_THRESHOLD = 0.5;
@@ -84,7 +84,10 @@ function analyzeRisk(payload) {
84
84
  const model = _loadModel();
85
85
  if (!model) return regexResult;
86
86
 
87
- // Build the text we feed to the classifier: latest user message + tool defs + system fingerprint
87
+ // Build the text we feed to the classifier: latest user message + system
88
+ // fingerprint. Harness-injected <system-reminder> blocks are stripped for
89
+ // the same reason as in the regex analyzer — their boilerplate contains
90
+ // risk keywords ("authentication", "credential") the user never typed.
88
91
  let text = '';
89
92
  if (Array.isArray(payload?.messages)) {
90
93
  for (let i = payload.messages.length - 1; i >= 0; i--) {
@@ -98,6 +101,7 @@ function analyzeRisk(payload) {
98
101
  }
99
102
  }
100
103
  }
104
+ text = stripSystemReminders(text);
101
105
  if (typeof payload?.system === 'string') text += ' ' + payload.system;
102
106
 
103
107
  const prob = _predict(text, model);
@@ -9,88 +9,216 @@
9
9
  * Azure: 400 "No tool call found for function call output with call_id …"
10
10
  * Moonshot: 400 "Invalid request: tool_call_id is not found"
11
11
  *
12
- * To prevent that, once a session has chosen a provider we keep subsequent
13
- * turns on it *while the payload carries tool history*. Fresh turns (no tool
14
- * state) still route normally, so per-turn tier routing is preserved.
12
+ * WS1 extends this from a per-turn tool-safety guard into a cache-aware sticky
13
+ * routing pin: routing decisions are made **once per session** (persisted to
14
+ * SQLite so restarts don't lose them) and re-evaluated only at explicit
15
+ * triggers — compaction, guard escalation, provider unavailable, or an
16
+ * economic downgrade that beats the estimated cold-cache re-read cost.
17
+ *
18
+ * The in-memory Map is a read-through cache over `affinity-store` (SQLite);
19
+ * the store is authoritative across restarts, the Map keeps hot sessions
20
+ * off the SQLite path.
15
21
  *
16
22
  * @module routing/session-affinity
17
23
  */
18
24
 
25
+ const store = require("./affinity-store");
26
+
19
27
  const MAX_ENTRIES = 2000;
20
- const TTL_MS = 60 * 60 * 1000; // 1 hour
28
+ // 6h TTL long enough that a working session (dev machine idled overnight)
29
+ // keeps its pin, short enough that abandoned sessions eventually clear.
30
+ // Override with LYNKR_STICKY_TTL_MS.
31
+ function _ttlMs() {
32
+ const raw = Number(process.env.LYNKR_STICKY_TTL_MS);
33
+ return Number.isFinite(raw) && raw > 0 ? raw : 6 * 60 * 60 * 1000;
34
+ }
35
+
36
+ /**
37
+ * Pin entry shape.
38
+ * @typedef {Object} Pin
39
+ * @property {string} provider
40
+ * @property {string|null} model
41
+ * @property {string|null} tier
42
+ * @property {number|null} score - decision.score at pin time (so pinned turns can display the original intent score in the badge instead of a misleading full-payload complexity score)
43
+ * @property {number|null} messageCount - payload.messages.length at pin time
44
+ * @property {number|null} promptTokensEst - countPayloadTokens estimate at pin time
45
+ * @property {number} ts
46
+ */
21
47
 
22
- /** @type {Map<string, {provider:string, model:string|null, tier:string|null, ts:number}>} */
48
+ /** @type {Map<string, Pin>} */
23
49
  const pins = new Map();
24
50
 
25
51
  function _evictIfNeeded() {
26
52
  if (pins.size <= MAX_ENTRIES) return;
27
- // Map preserves insertion order — drop the oldest.
28
53
  const oldest = pins.keys().next().value;
29
54
  if (oldest !== undefined) pins.delete(oldest);
30
55
  }
31
56
 
32
57
  /**
33
- * True when the payload contains an in-flight tool exchange — i.e. a prior
34
- * assistant tool_use or a user tool_result. These are the turns whose
35
- * tool-call IDs break if the provider changes.
58
+ * True when the payload is MID tool exchange — the LAST message is
59
+ * submitting tool_result blocks (or replaying a dangling tool_use). Those
60
+ * are the frames whose tool-call IDs break if the provider changes.
61
+ *
62
+ * COMPLETED tool exchanges earlier in the history are safe to carry across
63
+ * providers: the tool_use/tool_result pairs are internally consistent and
64
+ * any Anthropic-format backend accepts them.
65
+ *
66
+ * The original implementation matched tool blocks ANYWHERE in history,
67
+ * which meant one tool call permanently welded the session to its pin:
68
+ * every subsequent frame — including freshly typed user messages — took
69
+ * the unconditional serve, silently disabling the risk/context/vision
70
+ * guards AND the WS1.5 drift check for the rest of the session. Live
71
+ * symptom (2026-07-07): a heavyweight typed ask displayed
72
+ * "score 0 · pin@0" because drift never ran after the session's first
73
+ * tool use.
36
74
  * @param {object} payload
37
75
  * @returns {boolean}
38
76
  */
39
77
  function payloadHasToolHistory(payload) {
40
78
  const messages = payload?.messages;
41
- if (!Array.isArray(messages)) return false;
42
- for (const msg of messages) {
43
- const content = msg?.content;
44
- if (!Array.isArray(content)) continue;
45
- for (const block of content) {
46
- const t = block?.type;
47
- if (t === "tool_use" || t === "tool_result") return true;
48
- }
49
- }
50
- return false;
79
+ if (!Array.isArray(messages) || messages.length === 0) return false;
80
+ const content = messages[messages.length - 1]?.content;
81
+ if (!Array.isArray(content)) return false;
82
+ return content.some((b) => b?.type === "tool_result" || b?.type === "tool_use");
51
83
  }
52
84
 
53
85
  /**
54
- * Return the pinned routing decision for a session, or null if none / expired.
86
+ * Load a pin: memory first, then SQLite. Returns null on TTL expiry or miss.
55
87
  * @param {string} sessionId
88
+ * @returns {Pin|null}
56
89
  */
57
- function getPinned(sessionId) {
90
+ function getPin(sessionId) {
58
91
  if (!sessionId) return null;
59
- const entry = pins.get(sessionId);
60
- if (!entry) return null;
61
- if (Date.now() - entry.ts > TTL_MS) {
62
- pins.delete(sessionId);
63
- return null;
92
+ const ttl = _ttlMs();
93
+
94
+ const inMem = pins.get(sessionId);
95
+ if (inMem) {
96
+ if (Date.now() - inMem.ts > ttl) {
97
+ pins.delete(sessionId);
98
+ store.remove(sessionId);
99
+ return null;
100
+ }
101
+ return inMem;
64
102
  }
65
- return entry;
103
+
104
+ // Cold cache — read through the store.
105
+ const fromStore = store.load(sessionId, ttl);
106
+ if (!fromStore) return null;
107
+ pins.set(sessionId, fromStore);
108
+ _evictIfNeeded();
109
+ return fromStore;
66
110
  }
67
111
 
68
112
  /**
69
- * Record the provider a session routed to, for reuse on later tool-bearing turns.
113
+ * Write-through: update the in-memory Map and persist to SQLite.
114
+ * Accepts a routing decision plus session-scoped stats used by the re-pin
115
+ * heuristics.
116
+ *
70
117
  * @param {string} sessionId
71
118
  * @param {{provider:string, model?:string|null, tier?:string|null}} decision
119
+ * @param {{messageCount?:number|null, promptTokensEst?:number|null}} [stats]
72
120
  */
73
- function setPinned(sessionId, decision) {
121
+ function setPin(sessionId, decision, stats = {}) {
74
122
  if (!sessionId || !decision?.provider) return;
75
- // Refresh insertion order so active sessions aren't evicted.
76
- pins.delete(sessionId);
77
- pins.set(sessionId, {
123
+ const pin = {
78
124
  provider: decision.provider,
79
125
  model: decision.model ?? null,
80
126
  tier: decision.tier ?? null,
127
+ score: typeof decision.score === 'number' ? decision.score : null,
128
+ messageCount: stats.messageCount ?? null,
129
+ promptTokensEst: stats.promptTokensEst ?? null,
81
130
  ts: Date.now(),
82
- });
131
+ };
132
+ // Refresh insertion order so active sessions aren't evicted.
133
+ pins.delete(sessionId);
134
+ pins.set(sessionId, pin);
83
135
  _evictIfNeeded();
136
+ store.save(sessionId, pin);
84
137
  }
85
138
 
86
- /** Test/maintenance helper. */
139
+ /**
140
+ * Decide whether the session should be re-routed from its current pin based
141
+ * on the incoming payload. Currently detects "compaction" — the client
142
+ * shrunk its message history (e.g. Claude Code's /compact), which resets
143
+ * the provider's prompt cache anyway, so we're free to re-route without
144
+ * incurring an extra cold-cache read.
145
+ *
146
+ * @param {Pin|null} pin
147
+ * @param {object} payload
148
+ * @returns {{repin: boolean, reason: string|null}}
149
+ */
150
+ function shouldRepin(pin, payload) {
151
+ if (!pin) return { repin: true, reason: "no_pin" };
152
+ const currentMsgCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
153
+ const pinnedMsgCount = pin.messageCount ?? null;
154
+ // A 1-2 message payload against a long-conversation pin is NOT compaction
155
+ // — it's a new conversation whose opener collided with an old session's
156
+ // fingerprint (compaction always leaves summary + recent turns, never a
157
+ // bare opener). Mislabeling it lets the compaction floor pin greetings
158
+ // to expensive tiers.
159
+ if (pinnedMsgCount != null && currentMsgCount <= 2 && pinnedMsgCount > 4) {
160
+ return { repin: true, reason: "new_conversation" };
161
+ }
162
+ if (pinnedMsgCount != null && currentMsgCount < pinnedMsgCount - 2) {
163
+ return { repin: true, reason: "compaction" };
164
+ }
165
+ return { repin: false, reason: null };
166
+ }
167
+
168
+ /** Test/maintenance helper — clear the in-memory Map only. */
87
169
  function _clear() {
88
170
  pins.clear();
89
171
  }
90
172
 
173
+ /** Test helper — clear both memory and persistent store. */
174
+ function _clearAll() {
175
+ pins.clear();
176
+ store._clear();
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Legacy compatibility surface
181
+ //
182
+ // Preserved so existing call sites and tests keep working during the WS1
183
+ // rollout. New code should use getPin/setPin (which record message_count and
184
+ // prompt tokens for the sticky-routing triggers).
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /** @deprecated use getPin */
188
+ function getPinned(sessionId) {
189
+ const p = getPin(sessionId);
190
+ if (!p) return null;
191
+ return { provider: p.provider, model: p.model, tier: p.tier, ts: p.ts };
192
+ }
193
+
194
+ /**
195
+ * Drop a session's pin (memory + store). Used when a mid-tool-exchange
196
+ * frame carries embedded typed text that trips a trigger (force-cloud /
197
+ * autonomous): the frame itself must still serve the pin (tool_use ↔
198
+ * tool_result id linkage breaks on a mid-exchange switch), but the pin
199
+ * must not survive to the next turn boundary.
200
+ * @param {string} sessionId
201
+ */
202
+ function removePin(sessionId) {
203
+ if (!sessionId) return;
204
+ pins.delete(sessionId);
205
+ store.remove(sessionId);
206
+ }
207
+
208
+ /** @deprecated use setPin */
209
+ function setPinned(sessionId, decision) {
210
+ setPin(sessionId, decision);
211
+ }
212
+
91
213
  module.exports = {
92
214
  payloadHasToolHistory,
215
+ getPin,
216
+ setPin,
217
+ removePin,
218
+ shouldRepin,
219
+ // legacy
93
220
  getPinned,
94
221
  setPinned,
95
222
  _clear,
223
+ _clearAll,
96
224
  };