lynkr 9.14.11 → 9.14.13

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Subscription quota ledger (Phase 4 — quota-aware descents).
3
+ *
4
+ * Flat-fee subscriptions have no dollar price per token, but they do have a
5
+ * quota bucket: sustained Opus serving drains weekly runway several times
6
+ * faster than Haiku. This module tracks per-session burn in QUOTA UNITS so
7
+ * the downgrade gate can shorten its horizon under pressure (see
8
+ * cache-switch-cost.js `sessionBurnPressure`): high burn → fewer effective
9
+ * remaining turns → holds collapse → descents clear sooner.
10
+ *
11
+ * Units: 1 quota unit = 1000 Haiku-class tokens (input+output blended).
12
+ * Model weight = (inputPerM + outputPerM) / (haiku input+output), resolved
13
+ * via cache-economics with a fail-to-middle default (2.0) for unpriced ids —
14
+ * unknown models should exert middling pressure, never zero, never Opus.
15
+ * Today's registry: haiku 1.0, sonnet-5 2.0, opus-4-8 5.0.
16
+ *
17
+ * Pressure = min(1, windowBurn / BURN_WINDOW_QUOTA) over the trailing window
18
+ * (default 10 turns). BURN_WINDOW_QUOTA is a heuristic constant: ~ten sustained
19
+ * full-context Opus turns. It is deliberately coarse — pressure only shortens
20
+ * the hold horizon, it never forces a descent past the gate's own math, and
21
+ * upgrades/tool-loops/compaction-floors bypass the gate entirely.
22
+ *
23
+ * Pure except for the in-memory session map (no I/O, no DB). Never throws.
24
+ */
25
+
26
+ const BURN_WINDOW_TURNS = 10;
27
+ // ~10 sustained 10k-token Opus turns (10 × 10 × 5.0). Sustained peaks pin
28
+ // pressure at 1; occasional upgrades barely register. Heuristic — see above.
29
+ const BURN_WINDOW_QUOTA = 500;
30
+ const MAX_SESSIONS = 500;
31
+ const UNKNOWN_MODEL_WEIGHT = 2.0;
32
+ const HAIKU_BASELINE_PER_M = 6; // claude-haiku-4-5-20251001: 1 input + 5 output
33
+
34
+ const _windows = new Map(); // sessionId -> number[] (recent turn burns)
35
+
36
+ function _evictIfNeeded() {
37
+ while (_windows.size > MAX_SESSIONS) {
38
+ const oldest = _windows.keys().next();
39
+ if (oldest.done) break;
40
+ _windows.delete(oldest.value);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Quota weight for a model id: output+input price relative to Haiku baseline.
46
+ * @param {string|null} model
47
+ * @param {function|null} [resolveFn] - (provider, model) => {inputPerM, outputPerM} (tests)
48
+ */
49
+ function modelWeight(model, resolveFn = null) {
50
+ try {
51
+ const resolve = resolveFn || require('./cache-economics').resolveCacheEconomics;
52
+ const econ = resolve('azure-anthropic', model);
53
+ if (!econ || typeof econ.inputPerM !== 'number' || typeof econ.outputPerM !== 'number') {
54
+ return UNKNOWN_MODEL_WEIGHT;
55
+ }
56
+ if (econ.unknownPricing) return UNKNOWN_MODEL_WEIGHT;
57
+ return (econ.inputPerM + econ.outputPerM) / HAIKU_BASELINE_PER_M;
58
+ } catch {
59
+ return UNKNOWN_MODEL_WEIGHT;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Record one served turn.
65
+ * @param {string|null} sessionId
66
+ * @param {object} usage - {model, inputTokens, outputTokens}
67
+ * @param {function|null} [resolveFn] - test injection for modelWeight
68
+ */
69
+ function record(sessionId, usage = {}, resolveFn = null) {
70
+ if (!sessionId) return;
71
+ const input = Number(usage.inputTokens) || 0;
72
+ const output = Number(usage.outputTokens) || 0;
73
+ if (input <= 0 && output <= 0) return;
74
+ const burn = ((input + output) / 1000) * modelWeight(usage.model, resolveFn);
75
+ let window = _windows.get(sessionId);
76
+ if (!window) {
77
+ window = [];
78
+ _windows.set(sessionId, window);
79
+ }
80
+ window.push(burn);
81
+ while (window.length > BURN_WINDOW_TURNS) window.shift();
82
+ _evictIfNeeded();
83
+ }
84
+
85
+ /**
86
+ * Current burn pressure for a session: 0 (idle/cheap) → 1 (sustained peaks).
87
+ */
88
+ function pressure(sessionId) {
89
+ const window = _windows.get(sessionId);
90
+ if (!window || window.length === 0) return 0;
91
+ const burn = window.reduce((a, b) => a + b, 0);
92
+ return Math.max(0, Math.min(1, burn / BURN_WINDOW_QUOTA));
93
+ }
94
+
95
+ /** Test/ops helpers. */
96
+ function _reset() { _windows.clear(); }
97
+ function _windowBurn(sessionId) {
98
+ const window = _windows.get(sessionId);
99
+ return window ? window.reduce((a, b) => a + b, 0) : 0;
100
+ }
101
+
102
+ module.exports = {
103
+ record,
104
+ pressure,
105
+ modelWeight,
106
+ BURN_WINDOW_TURNS,
107
+ BURN_WINDOW_QUOTA,
108
+ _reset,
109
+ _windowBurn,
110
+ };
@@ -125,9 +125,22 @@ function findHits(keywords, haystack) {
125
125
  * not what the harness injected; genuinely risky asks live in the typed
126
126
  * text and still fire. (The intent scorers have stripped these blocks
127
127
  * since WS3 — this brings the risk scan in line.)
128
+ *
129
+ * Bare-line MCP boilerplate (HARNESS_BOILERPLATE_LINE) gets the same
130
+ * treatment: auth notices also arrive as PLAIN text outside any tags.
128
131
  * @param {string} text
129
132
  * @returns {string}
130
133
  */
134
+ // Bare-line harness boilerplate that escapes the tag stripping above: MCP
135
+ // auth notices also arrive as PLAIN text (tool results, echoed banners),
136
+ // where the same credential-flavored words detonate the keyword scan —
137
+ // live 2026-09-18: "Can you pull sprint stories…" + an "Authentication
138
+ // successful. Connected to claude.ai BT1_MCP" notice routed REASONING on
139
+ // instructionHits ["authentication"] the user never typed. Full-LINE match
140
+ // only: these are fixed harness templates, never user prose — typed risky
141
+ // asks ("disable the authentication check") span the line differently and
142
+ // keep firing. Tool_use inputs are untouched (real activity still counts).
143
+ const HARNESS_BOILERPLATE_LINE = /^\s*(authentication successful[.!]?(\s+connected to .+)?|connected to \S+ MCP\.?|(\d+\s+)?MCP servers? need authentication\b.*|run \/mcp\b.*)\s*$/i;
131
144
  function stripSystemReminders(text) {
132
145
  if (typeof text !== 'string' || !text) return '';
133
146
  return text
@@ -139,7 +152,10 @@ function stripSystemReminders(text) {
139
152
  // "Hi", force-escalated to COMPLEX on it) and <user_instructions>
140
153
  // carries AGENTS.md contents the user never typed this turn.
141
154
  .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, ' ')
142
- .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ');
155
+ .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, ' ')
156
+ .split('\n')
157
+ .filter((line) => !HARNESS_BOILERPLATE_LINE.test(line))
158
+ .join('\n');
143
159
  }
144
160
 
145
161
  /**
@@ -181,6 +181,14 @@ function init() {
181
181
  // NULL on rows where the bandit didn't run (deterministic decisions)
182
182
  // or recorded before this column existed.
183
183
  ["context", "TEXT"],
184
+ // Jev routing judge — shadow verdict recorded alongside every scored
185
+ // decision so heuristic/Jev eras stay separable. NULL when Jev didn't
186
+ // run (pinned serves, side frames, no key) or predates this column.
187
+ ["jev_verdict", "TEXT"],
188
+ ["jev_confidence", "REAL"],
189
+ ["jev_probabilities", "TEXT"],
190
+ ["jev_model", "TEXT"],
191
+ ["criteria_hash", "TEXT"],
184
192
  ];
185
193
  for (const [col, type] of additiveCols) {
186
194
  if (!existingCols.has(col)) {
@@ -243,7 +251,8 @@ function record(data) {
243
251
  retry_count, circuit_breaker_state, quality_score, tokens_per_second,
244
252
  cost_efficiency, request_text, response_text,
245
253
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
246
- cache_decision, cache_read_tokens, cache_creation_tokens, context
254
+ cache_decision, cache_read_tokens, cache_creation_tokens, context,
255
+ jev_verdict, jev_confidence, jev_probabilities, jev_model, criteria_hash
247
256
  ) VALUES (
248
257
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
249
258
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -252,7 +261,8 @@ function record(data) {
252
261
  @retry_count, @circuit_breaker_state, @quality_score, @tokens_per_second,
253
262
  @cost_efficiency, @request_text, @response_text,
254
263
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
255
- @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context
264
+ @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context,
265
+ @jev_verdict, @jev_confidence, @jev_probabilities, @jev_model, @criteria_hash
256
266
  )`
257
267
  );
258
268
  if (!insert) return;
@@ -308,6 +318,13 @@ function record(data) {
308
318
  context: data.context == null
309
319
  ? null
310
320
  : (typeof data.context === "string" ? data.context : JSON.stringify(data.context)),
321
+ jev_verdict: data.jev_verdict ?? null,
322
+ jev_confidence: data.jev_confidence ?? null,
323
+ jev_probabilities: data.jev_probabilities === null || data.jev_probabilities === undefined
324
+ ? null
325
+ : (typeof data.jev_probabilities === "string" ? data.jev_probabilities : JSON.stringify(data.jev_probabilities)),
326
+ jev_model: data.jev_model ?? null,
327
+ criteria_hash: data.criteria_hash ?? null,
311
328
  });
312
329
  } catch (err) {
313
330
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -1136,8 +1153,36 @@ function getAnalytics(opts = {}) {
1136
1153
  }
1137
1154
  }
1138
1155
 
1156
+ /**
1157
+ * Map a Jev verdict (decision.jev or analysis.jev shape) onto the
1158
+ * jev_* telemetry columns. Null-safe: anything missing yields all-null
1159
+ * fields so call sites spread this unconditionally.
1160
+ */
1161
+ function jevFields(src) {
1162
+ const j = (src && typeof src === 'object')
1163
+ ? (src.jev && typeof src.jev === 'object' ? src.jev
1164
+ : (src.analysis && typeof src.analysis === 'object' && src.analysis.jev ? src.analysis.jev : null))
1165
+ : null;
1166
+ if (!j) {
1167
+ return {
1168
+ jev_verdict: null, jev_confidence: null, jev_probabilities: null,
1169
+ jev_model: null, criteria_hash: null,
1170
+ };
1171
+ }
1172
+ return {
1173
+ jev_verdict: j.tier ?? null,
1174
+ jev_confidence: j.confidence ?? null,
1175
+ jev_probabilities: j.probabilities === null || j.probabilities === undefined
1176
+ ? null
1177
+ : (typeof j.probabilities === 'string' ? j.probabilities : JSON.stringify(j.probabilities)),
1178
+ jev_model: j.model ?? null,
1179
+ criteria_hash: j.criteriaHash ?? j.criteria_hash ?? null,
1180
+ };
1181
+ }
1182
+
1139
1183
  module.exports = {
1140
1184
  record,
1185
+ jevFields,
1141
1186
  query,
1142
1187
  getStats: getStatsCached,
1143
1188
  getProviderStats: getProviderStatsCached,