lynkr 9.14.13 → 9.14.15

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/lynkr-init.js CHANGED
@@ -595,6 +595,13 @@ async function runInteractive(opts) {
595
595
  const decay = parseFloat(decayRaw);
596
596
  if (!Number.isNaN(decay) && decay > 0 && decay <= 1) env.LYNKR_INTENT_DECAY = String(decay);
597
597
 
598
+ // Jev routing judge (TypeSafe System One): second opinion on intent
599
+ // difficulty + risk corroboration. Optional — anchor scoring carries
600
+ // routing alone when unset. Written under "# Provider credentials" by
601
+ // the _API_KEY grouping in buildEnvContent.
602
+ const tsKey = await askWithDefault(ask, 'TypeSafe API key for the Jev routing judge (optional — Enter to skip)', process.env.TYPESAFE_API_KEY || '');
603
+ if (tsKey) env.TYPESAFE_API_KEY = tsKey;
604
+
598
605
  close();
599
606
  console.log('');
600
607
  writeEnvFile(buildEnvContent(env, isWrap, tierConfig), opts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "9.14.13",
3
+ "version": "9.14.15",
4
4
  "files": [
5
5
  "index.js",
6
6
  "install.sh",
@@ -38,7 +38,7 @@
38
38
  "start:supervised": "while true; do node index.js 2>&1 | npx pino-pretty --sync; code=$?; echo \"[supervisor] lynkr exited ($code) — restarting in 3s\"; sleep 3; done",
39
39
  "lint": "eslint src index.js",
40
40
  "test": "npm run test:unit && npm run test:performance",
41
- "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js",
41
+ "test:unit": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js test/tenant-policy-ingress-parity.test.js test/decide.test.js test/embeddings-degradation.test.js test/health-probe.test.js test/stuck-detector.test.js test/onnx-embedder.test.js test/ope.test.js test/hierarchical-budget.test.js test/token-rate-limit.test.js test/otel-export.test.js test/mcp-broker.test.js test/compression-budget.test.js test/gpt-utils.test.js test/dedup-observe-only.test.js test/context-window-header.test.js test/token-budget-auto.test.js test/opencode-setup.test.js test/auth-mode-first-party.test.js test/task-ledger.test.js test/jev-router.test.js test/jev-routing.test.js test/force-patterns.test.js test/upstream-fidelity.test.js test/tool-schema-compression.test.js test/passthrough-route.test.js test/quota-ledger.test.js",
42
42
  "test:memory": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
43
43
  "test:new-features": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
44
44
  "test:performance": "LYNKR_KNN_DIR=/tmp/lynkr-test-knn DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Harvest short follow-up turns from routing telemetry for the TaskBand
4
+ * continuation-detector eval set.
5
+ *
6
+ * Reads <cwd>/.lynkr/telemetry.db (read-only — never mutates production
7
+ * telemetry), groups rows by session ordered by timestamp, and emits every
8
+ * turn whose request_text is ≤200 chars: short asks are where "do the same
9
+ * here" follow-ups live, and where single-message scoring is blindest.
10
+ * Each line carries up to 3 prior asks from the same session so a labeler
11
+ * can judge continuation vs. new task without replaying the session.
12
+ *
13
+ * Output: data/followup-eval-unlabeled.jsonl (gitignored), one JSON object
14
+ * per line: {session_id, ts, request_text, prior_asks, chosen_tier,
15
+ * complexity_score, label: null} — label is filled in by hand.
16
+ *
17
+ * Usage: node scripts/harvest-followup-eval.js
18
+ */
19
+
20
+ const fs = require("fs");
21
+ const path = require("path");
22
+
23
+ const MAX_ASK_CHARS = 200;
24
+ const MAX_PRIOR_ASKS = 3;
25
+ const OUT_FILE = path.join(__dirname, "../data/followup-eval-unlabeled.jsonl");
26
+
27
+ let Database;
28
+ try {
29
+ Database = require("better-sqlite3");
30
+ } catch {
31
+ console.error("better-sqlite3 is not installed (it is an optionalDependency) — cannot read telemetry.db.");
32
+ console.error("Install it with: npm install better-sqlite3");
33
+ process.exit(1);
34
+ }
35
+
36
+ function main() {
37
+ const dbPath = path.join(process.cwd(), ".lynkr", "telemetry.db");
38
+ if (!fs.existsSync(dbPath)) {
39
+ console.error(`No telemetry database at ${dbPath} — run the proxy from this directory first to collect telemetry.`);
40
+ process.exit(1);
41
+ }
42
+
43
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
44
+
45
+ const rows = db
46
+ .prepare(
47
+ `SELECT session_id, timestamp, request_text, tier, complexity_score
48
+ FROM routing_telemetry
49
+ WHERE session_id IS NOT NULL AND request_text IS NOT NULL AND request_text != ''
50
+ ORDER BY session_id, timestamp ASC`
51
+ )
52
+ .all();
53
+ db.close();
54
+
55
+ const out = [];
56
+ let sessions = 0;
57
+ let prevSession = null;
58
+ let priorAsks = [];
59
+
60
+ for (const row of rows) {
61
+ if (row.session_id !== prevSession) {
62
+ prevSession = row.session_id;
63
+ priorAsks = [];
64
+ sessions++;
65
+ }
66
+ const text = String(row.request_text);
67
+ if (text.length <= MAX_ASK_CHARS) {
68
+ out.push(JSON.stringify({
69
+ session_id: row.session_id,
70
+ ts: row.timestamp,
71
+ request_text: text,
72
+ prior_asks: priorAsks.slice(-MAX_PRIOR_ASKS),
73
+ chosen_tier: row.tier ?? null,
74
+ complexity_score: row.complexity_score ?? null,
75
+ label: null,
76
+ }));
77
+ }
78
+ priorAsks.push(text);
79
+ }
80
+
81
+ fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
82
+ fs.writeFileSync(OUT_FILE, out.length ? out.join("\n") + "\n" : "");
83
+ console.log(`Scanned ${rows.length} rows across ${sessions} sessions.`);
84
+ console.log(`Wrote ${OUT_FILE}: ${out.length} candidate follow-up turns (label: null — awaiting hand labels).`);
85
+ }
86
+
87
+ main();
package/src/api/router.js CHANGED
@@ -44,6 +44,24 @@ const router = express.Router();
44
44
 
45
45
  const rateLimiter = createRateLimiter();
46
46
 
47
+ // TaskBand holdout: a deterministic slice of sessions never receives the
48
+ // continuation floor, so telemetry keeps a running baseline cohort proving
49
+ // floored sessions aren't quietly doing worse. FNV-1a bucketing mirrors
50
+ // deescalator.isHeldOut; the percentage is task-ledger's hardcoded
51
+ // HOLDOUT_PCT (no env knob by design).
52
+ function _taskbandHoldout(sessionKey, pct) {
53
+ if (!sessionKey) return false;
54
+ const cut = Math.round(Math.max(0, Math.min(1, Number(pct) || 0)) * 100);
55
+ if (cut === 0) return false;
56
+ let hash = 0x811c9dc5;
57
+ const s = String(sessionKey);
58
+ for (let i = 0; i < s.length; i++) {
59
+ hash ^= s.charCodeAt(i);
60
+ hash = Math.imul(hash, 0x01000193);
61
+ }
62
+ return ((hash >>> 0) % 100) < cut;
63
+ }
64
+
47
65
  /**
48
66
  * Decide which tier/provider/model handles an OAuth-subscription request.
49
67
  *
@@ -170,45 +188,25 @@ async function pickTierByIntent(body) {
170
188
  };
171
189
  }
172
190
 
173
- // Condense a message's text for classifier context: text blocks only,
174
- // reminders stripped, whitespace collapsed, hard-capped. Best-effort.
175
- const _condenseText = (msg, max = 140) => {
176
- if (!msg) return '';
177
- let text = '';
178
- if (typeof msg.content === 'string') text = msg.content;
179
- else if (Array.isArray(msg.content)) {
180
- text = msg.content
181
- .filter((b) => b?.type === 'text' && typeof b.text === 'string')
182
- .map((b) => b.text)
183
- .join(' ');
184
- }
185
- return stripReminders(text).replace(/\s+/g, ' ').trim().slice(0, max);
186
- };
187
-
188
- // Conversation context for the difficulty classifier: the nearest prior
189
- // assistant reply and the user turn before it. A bare follow-up ("Who
190
- // kills him ?") is unclassifiable in isolation — the single-message
191
+ // Task-ledger context for the difficulty judge: a slim stable string
192
+ // (task anchor + last ask) folded from the PREFIX of the thread — the ask
193
+ // being scored never feeds its own context. A bare follow-up ("Who kills
194
+ // him ?") is unclassifiable in isolation — the single-message
191
195
  // intentPayload below is context-blind BY DESIGN (envelope invariance),
192
196
  // so the context travels as a separate condensed string, never as extra
193
- // messages that would pollute the anchor score.
194
- const _contextBefore = (msg) => {
197
+ // messages that would pollute the anchor score. The ledger's prefix memo
198
+ // keeps the per-index derivation O(1) per new ask.
199
+ let _taskLedgerMod = null;
200
+ try { _taskLedgerMod = require("../routing/task-ledger"); } catch { /* context degrades to null */ }
201
+ const _ledgerBefore = (msg) => {
202
+ if (!_taskLedgerMod) return null;
195
203
  const idx = messages.indexOf(msg);
196
204
  if (idx <= 0) return null;
197
- let assistantText = '';
198
- let priorUserText = '';
199
- for (let j = idx - 1; j >= 0; j--) {
200
- const m = messages[j];
201
- if (!assistantText && m?.role === 'assistant') assistantText = _condenseText(m);
202
- else if (assistantText && m?.role === 'user') {
203
- priorUserText = _condenseText(m);
204
- if (priorUserText) break;
205
- }
205
+ try {
206
+ return _taskLedgerMod.deriveTaskLedger(messages.slice(0, idx));
207
+ } catch {
208
+ return null;
206
209
  }
207
- if (!assistantText && !priorUserText) return null;
208
- const parts = [];
209
- if (priorUserText) parts.push(`user asked: """${priorUserText}"""`);
210
- if (assistantText) parts.push(`assistant replied about: """${assistantText}"""`);
211
- return parts.join(' → ');
212
210
  };
213
211
 
214
212
  // Per-message scoring intentionally omits _sessionId so session affinity
@@ -223,6 +221,11 @@ async function pickTierByIntent(body) {
223
221
  for (let i = 0; i < windowUserMsgs.length; i++) {
224
222
  const age = windowUserMsgs.length - 1 - i; // 0 = latest, length-1 = oldest in window
225
223
  const cleaned = cleanMsg(windowUserMsgs[i]);
224
+ const _msgLedger = _ledgerBefore(windowUserMsgs[i]);
225
+ let _msgContext = null;
226
+ try {
227
+ _msgContext = _msgLedger ? _taskLedgerMod.buildTaskContext(_msgLedger) : null;
228
+ } catch { /* context degrades to null */ }
226
229
  const intentPayload = {
227
230
  messages: cleaned ? [cleaned] : [],
228
231
  tools: intentTools,
@@ -230,10 +233,17 @@ async function pickTierByIntent(body) {
230
233
  // agentic detector inside determineProviderSmart can subtract the
231
234
  // harness's baseline tools during intent scoring too.
232
235
  _clientProfile: body?._clientProfile || null,
233
- // Classifier context (see _contextBefore). Underscored — stripped at
234
- // the outbound chokepoint like every internal field.
235
- _conversationContext: _contextBefore(windowUserMsgs[i]),
236
+ // Classifier context + memo identity (see _ledgerBefore). Underscored
237
+ // — stripped at the outbound chokepoint like every internal field.
238
+ _conversationContext: _msgContext,
239
+ _taskAnchorHash: _msgLedger?.anchorHash || null,
236
240
  };
241
+ if (age === 0) {
242
+ // Continuation detection ran ONCE in the handler, on the latest typed
243
+ // ask only — windowed history messages must never carry these.
244
+ if (body?._isContinuation) intentPayload._isContinuation = true;
245
+ if (body?._inheritedFloorTier) intentPayload._inheritedFloorTier = body._inheritedFloorTier;
246
+ }
237
247
  try {
238
248
  const decision = await determineProviderSmart(intentPayload, {
239
249
  workspace: body?._workspace || null,
@@ -359,7 +369,9 @@ async function handleOauthPassthrough(req, res, opts = {}) {
359
369
  // Strip Lynkr's internal underscore-prefixed fields (_sessionId,
360
370
  // _forceProvider, _tierModel, _tierName, _forcedMethod, _baseTier,
361
371
  // _escalationSource, _pinnedRoute, _switchReason, _clientProfile,
362
- // _workspace, _tenantPolicy, _deadlineMs, _suggestionModeModel). Anthropic
372
+ // _workspace, _tenantPolicy, _deadlineMs, _suggestionModeModel,
373
+ // _conversationContext, _taskAnchorHash, _isContinuation,
374
+ // _inheritedFloorTier, _taskband). Anthropic
363
375
  // rejects unknown top-level keys with "Extra inputs are not permitted".
364
376
  // The orchestrator's downstream paths whitelist their outbound bodies,
365
377
  // but the passthrough sends this body VERBATIM — so we must strip here.
@@ -664,6 +676,7 @@ async function handleOauthPassthrough(req, res, opts = {}) {
664
676
  message_count: req.body?.messages?.length || null,
665
677
  tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
666
678
  ...tlm.jevFields(tier),
679
+ ...tlm.taskbandFields(tier),
667
680
  // Inner-decision attribution: the tier label alone can't explain a
668
681
  // serve (a COMPLEX label once served Haiku with no trace because the
669
682
  // tier object carried a null model). Record the decision's own
@@ -1180,6 +1193,96 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1180
1193
  req.body._sessionId = req.sessionId;
1181
1194
  }
1182
1195
 
1196
+ // TaskBand — fold the thread (minus the current ask) into a task ledger
1197
+ // and run continuation detection ONCE, on the latest typed ask only.
1198
+ // Stamped on the body so pin-drift checks, the per-message intent loop
1199
+ // and the difficulty judge all score from identical inputs. Best-effort
1200
+ // throughout: any failure leaves routing exactly as before TaskBand.
1201
+ let _taskLedger = null;
1202
+ let _taskCont = null;
1203
+ let _taskFloorTier = null; // would-be continuation floor (tier name)
1204
+ let _taskFloorHeldOut = false; // holdout cohort: floor logged, not applied
1205
+ let _taskPreFloorTier = null; // fresh tier before the floor raised it
1206
+ let _continuationFloored = false;
1207
+ let _taskSideRequest = false;
1208
+ let _jevStatsBefore = null;
1209
+ try { _jevStatsBefore = require("../routing/jev-router").getJevCacheStats(); } catch { /* stamp stays null */ }
1210
+ try {
1211
+ const taskLedgerMod = require("../routing/task-ledger");
1212
+ const { cleanUserText } = require("../routing/jev-router");
1213
+ const _msgs = Array.isArray(req.body?.messages) ? req.body.messages : [];
1214
+ let _askIdx = -1;
1215
+ let _askText = null;
1216
+ for (let i = _msgs.length - 1; i >= 0; i--) {
1217
+ if (_msgs[i]?.role !== 'user') continue;
1218
+ const t = cleanUserText(_msgs[i]);
1219
+ if (t) { _askIdx = i; _askText = t; break; }
1220
+ }
1221
+ if (_askIdx > 0) _taskLedger = taskLedgerMod.deriveTaskLedger(_msgs.slice(0, _askIdx));
1222
+ if (_taskLedger) {
1223
+ const _ctx = taskLedgerMod.buildTaskContext(_taskLedger);
1224
+ if (_ctx) req.body._conversationContext = _ctx;
1225
+ if (_taskLedger.anchorHash) req.body._taskAnchorHash = _taskLedger.anchorHash;
1226
+ const _embedFn = async (t) => {
1227
+ try {
1228
+ return await require("../routing/knn-router").getKnnRouter().embed(t);
1229
+ } catch { return null; }
1230
+ };
1231
+ _taskCont = await taskLedgerMod.detectContinuation(_askText, _taskLedger, _embedFn);
1232
+ if (_taskCont?.isContinuation) req.body._isContinuation = true;
1233
+ }
1234
+ const {
1235
+ FLOOR_ENABLED = true,
1236
+ DECAY_TURNS = 2,
1237
+ HOLDOUT_PCT = 0.10,
1238
+ } = taskLedgerMod;
1239
+ if (FLOOR_ENABLED && _taskCont?.isContinuation && _taskLedger?.anchorHash) {
1240
+ const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
1241
+ let effIdx = null;
1242
+ let coldFallback = false;
1243
+ const _taskState = req.body._sessionId
1244
+ ? require("../routing/affinity-store").getTaskState(req.body._sessionId)
1245
+ : null;
1246
+ if (_taskState && _taskState.anchorHash === _taskLedger.anchorHash
1247
+ && Number.isInteger(_taskState.effectiveBand)) {
1248
+ effIdx = Math.max(0, Math.min(3, _taskState.effectiveBand));
1249
+ } else if (_taskLedger.anchorText) {
1250
+ // COLD fallback: the task predates its state row (restart, TTL,
1251
+ // anchor change). Anchor-only score of the task's first ask,
1252
+ // capped at MEDIUM — an unproven inheritance must not re-derive
1253
+ // an expensive band from a single string.
1254
+ const { scoreIntent } = require("../routing/intent-score");
1255
+ const anchorIntent = await scoreIntent(
1256
+ { messages: [{ role: 'user', content: _taskLedger.anchorText }] },
1257
+ { skipClassifier: true },
1258
+ );
1259
+ if (anchorIntent && Number.isFinite(anchorIntent.score)) {
1260
+ const t = getModelTierSelector().getTier(anchorIntent.score);
1261
+ effIdx = Math.min(TIER_ORDER.indexOf(t), TIER_ORDER.indexOf('MEDIUM'));
1262
+ coldFallback = true;
1263
+ }
1264
+ }
1265
+ let floorIdx = null;
1266
+ if (effIdx != null && effIdx >= 0) {
1267
+ if (_taskLedger.open) floorIdx = effIdx;
1268
+ else if ((_taskLedger.turnsSinceClose ?? Infinity) <= DECAY_TURNS) floorIdx = effIdx - 1;
1269
+ }
1270
+ if (floorIdx != null && coldFallback) {
1271
+ floorIdx = Math.min(floorIdx, TIER_ORDER.indexOf('COMPLEX'));
1272
+ }
1273
+ if (floorIdx != null && floorIdx >= 0) {
1274
+ _taskFloorTier = TIER_ORDER[floorIdx];
1275
+ _taskFloorHeldOut = _taskbandHoldout(req.body._sessionId, HOLDOUT_PCT);
1276
+ // Held-out sessions must stay floor-free end to end — the judge's
1277
+ // cap widening counts as an application, so the body stamp is
1278
+ // withheld too, keeping the baseline cohort clean.
1279
+ if (!_taskFloorHeldOut) req.body._inheritedFloorTier = _taskFloorTier;
1280
+ }
1281
+ }
1282
+ } catch (err) {
1283
+ logger.debug({ err: err.message }, '[TaskBand] ledger/continuation derivation failed');
1284
+ }
1285
+
1183
1286
  // WS1 — sticky-session reuse. If this session already has a valid pin
1184
1287
  // (guards pass, no compaction), skip pickTierByIntent entirely and reuse
1185
1288
  // the pinned decision. This is the biggest cost win of WS1: repeat turns
@@ -1358,6 +1461,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1358
1461
  || isBareRequest
1359
1462
  || isFingerprintDrift
1360
1463
  || ((!Array.isArray(req.body?.tools) || req.body.tools.length === 0) && isKnownHarness);
1464
+ // Task state must never see side traffic (hoisted for the upkeep block
1465
+ // at the final-decision site — the model-id-pin branch skips this scope).
1466
+ _taskSideRequest = isSideRequest;
1361
1467
  // Side requests short-circuit to the static SIMPLE tier: no pin read
1362
1468
  // (a COMPLEX-pinned conversation would burn expensive tokens on
1363
1469
  // autocomplete), no pin write, no intent scoring of wrapper text.
@@ -1521,6 +1627,63 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1521
1627
  };
1522
1628
  }
1523
1629
  }
1630
+ // TaskBand continuation floor — fresh decisions only, and only to
1631
+ // RAISE. Placement is deliberate: after the compaction floor so the
1632
+ // two floors compose (max wins), before the pin write so a floored
1633
+ // tier can never fossilize into a session pin (writeSessionPin also
1634
+ // refuses '+continuation_inherit', mirroring the risk skip).
1635
+ if (!isSideRequest && _taskFloorTier && tier?.tier) {
1636
+ try {
1637
+ const { TIER_DEFINITIONS } = require("../routing/model-tiers");
1638
+ const pri = (t) => TIER_DEFINITIONS[t]?.priority ?? -1;
1639
+ const wouldRaise = pri(tier.tier) < pri(_taskFloorTier);
1640
+ if (_taskFloorHeldOut && wouldRaise) {
1641
+ tier.method = (tier.method || 'tier_config') + '+continuation_holdout';
1642
+ logger.debug({
1643
+ sessionId: pinCheck.sessionId,
1644
+ freshTier: tier.tier,
1645
+ wouldFloorTo: _taskFloorTier,
1646
+ evidence: _taskCont?.evidence || [],
1647
+ }, "OAuth intent — continuation floor held out (baseline cohort)");
1648
+ } else if (wouldRaise) {
1649
+ const flooredSel = getModelTierSelector().selectModel(_taskFloorTier, null);
1650
+ if (flooredSel?.provider) {
1651
+ const _fromTier = tier.tier;
1652
+ const _fromModel = tier.model || null;
1653
+ _taskPreFloorTier = _fromTier;
1654
+ logger.info({
1655
+ sessionId: pinCheck.sessionId,
1656
+ freshTier: _fromTier,
1657
+ flooredTo: _taskFloorTier,
1658
+ evidence: _taskCont?.evidence || [],
1659
+ }, "OAuth intent — continuation inherits task band");
1660
+ tier = {
1661
+ ...tier,
1662
+ tier: _taskFloorTier,
1663
+ provider: flooredSel.provider,
1664
+ model: flooredSel.model || null,
1665
+ method: (tier.method || 'tier_config') + '+continuation_inherit',
1666
+ switch_reason: tier.switch_reason || 'continuation_inherit',
1667
+ base_tier: tier.base_tier ?? _fromTier,
1668
+ escalation_source: tier.escalation_source ?? 'continuation_inherit',
1669
+ escalations: [
1670
+ ...(Array.isArray(tier.escalations) ? tier.escalations : []),
1671
+ {
1672
+ source: 'continuation_inherit',
1673
+ fromTier: _fromTier,
1674
+ toTier: _taskFloorTier,
1675
+ fromModel: _fromModel,
1676
+ toModel: flooredSel.model || null,
1677
+ },
1678
+ ],
1679
+ };
1680
+ _continuationFloored = true;
1681
+ }
1682
+ }
1683
+ } catch (err) {
1684
+ logger.debug({ err: err.message }, '[TaskBand] continuation floor failed — fresh tier stands');
1685
+ }
1686
+ }
1524
1687
  // Persist the fresh decision so the next turn on this session can
1525
1688
  // reuse it. checkSessionPin returned serve=false (or WS1.5 drift fired),
1526
1689
  // so either there was no pin, the pin lost a guard (context/vision/
@@ -1546,6 +1709,104 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
1546
1709
  tier.switch_reason = 'model_pin_drift';
1547
1710
  }
1548
1711
 
1712
+ // TaskBand telemetry stamp — travels on the decision AND (underscored)
1713
+ // on the body, so the forced-provider path can reconstitute it onto the
1714
+ // routingResult that feeds routing_telemetry.
1715
+ try {
1716
+ let _jevCacheHit = null;
1717
+ if (_jevStatsBefore) {
1718
+ const _jevStatsAfter = require("../routing/jev-router").getJevCacheStats();
1719
+ const _missDelta = _jevStatsAfter.misses - _jevStatsBefore.misses;
1720
+ const _hitDelta = _jevStatsAfter.hits - _jevStatsBefore.hits;
1721
+ // Null means the judge never consulted its cache this request
1722
+ // (pin serve, side frame, classifier skip) — "not measured".
1723
+ _jevCacheHit = _missDelta > 0 ? 0 : (_hitDelta > 0 ? 1 : null);
1724
+ }
1725
+ tier.taskband = {
1726
+ isContinuation: _taskCont?.isContinuation ? 1 : 0,
1727
+ inheritedFloor: _taskFloorTier || null,
1728
+ anchorHash: _taskLedger?.anchorHash || null,
1729
+ jevContext: typeof req.body._conversationContext === 'string'
1730
+ ? req.body._conversationContext : null,
1731
+ jevCacheHit: _jevCacheHit,
1732
+ };
1733
+ req.body._taskband = tier.taskband;
1734
+ } catch (err) {
1735
+ logger.debug({ err: err.message }, '[TaskBand] telemetry stamp failed');
1736
+ }
1737
+
1738
+ // TaskBand task-state upkeep — once per request, at the final-decision
1739
+ // site, never inside the per-message scoring loop. Side requests never
1740
+ // touch task state. Pin replays never write: a pinned tier keeps no
1741
+ // provenance of how it was chosen (a force-triggered tier pins like any
1742
+ // scored one), so replaying it must not launder into earned band — the
1743
+ // fresh serve that created the pin already merged. Model-id pins are
1744
+ // explicit client choices, not earned difficulty — no writes either.
1745
+ try {
1746
+ const _sessForTask = req.body._sessionId || null;
1747
+ if (_sessForTask && _taskLedger?.anchorHash && !_taskSideRequest) {
1748
+ const store = require("../routing/affinity-store");
1749
+ let AUTO_CLOSE_LOW_TURNS = 3;
1750
+ try {
1751
+ const _tl = require("../routing/task-ledger");
1752
+ if (Number.isInteger(_tl.AUTO_CLOSE_LOW_TURNS)) AUTO_CLOSE_LOW_TURNS = _tl.AUTO_CLOSE_LOW_TURNS;
1753
+ } catch { /* default stands */ }
1754
+ const TIER_ORDER = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
1755
+ const _method = tier?.method || '';
1756
+ const _openNow = _taskLedger.open ? 1 : 0;
1757
+ const _row = store.getTaskState(_sessForTask);
1758
+ const _anchorMatches = !!(_row && _row.anchorHash === _taskLedger.anchorHash);
1759
+ if (_method !== 'session_pin' && _method !== 'model_id_pin' && _method !== 'fallback') {
1760
+ // Low-streak decay reads the FRESH-scored tier, not the floored
1761
+ // one — otherwise an applied floor could never auto-close.
1762
+ let _freshTier = _taskPreFloorTier || tier?.tier || null;
1763
+ // A jev-override lift rides a cap the inherited floor may have
1764
+ // widened; only the scored base tier is earned band. Merging the
1765
+ // lifted tier would raise the next turn's floor, which widens the
1766
+ // next cap — the ratchet the one-band-up cap exists to break.
1767
+ if (/(^|\+)jev_override(\+|$)/.test(_method)) {
1768
+ const _scoredIdx = TIER_ORDER.indexOf(tier?.base_tier);
1769
+ if (_scoredIdx >= 0 && _scoredIdx < TIER_ORDER.indexOf(_freshTier)) {
1770
+ _freshTier = tier.base_tier;
1771
+ }
1772
+ }
1773
+ const _freshIdx = TIER_ORDER.indexOf(_freshTier);
1774
+ const _forceOrRisk = /(^|\+)(force|risk)(\+|$)/.test(_method)
1775
+ || tier?.escalation_source === 'risk';
1776
+ if (_freshIdx >= 0) {
1777
+ let _lowStreak = 0;
1778
+ if (_anchorMatches && Number.isInteger(_row.effectiveBand)
1779
+ && _freshIdx <= _row.effectiveBand - 2) {
1780
+ _lowStreak = (_row.lowStreak ?? 0) + 1;
1781
+ }
1782
+ if (_lowStreak >= AUTO_CLOSE_LOW_TURNS) {
1783
+ store.clearTaskState(_sessForTask);
1784
+ } else if (!_continuationFloored && !_forceOrRisk) {
1785
+ // MAX-merge on matching anchor, whole-row overwrite on an
1786
+ // anchor change — both live inside setTaskState.
1787
+ store.setTaskState(_sessForTask, {
1788
+ anchorHash: _taskLedger.anchorHash,
1789
+ effectiveBand: _freshIdx,
1790
+ open: _openNow,
1791
+ lowStreak: _lowStreak,
1792
+ });
1793
+ } else if (_anchorMatches) {
1794
+ // Floored/force/risk serves never merge their band, but the
1795
+ // marker-close and the low streak still have to land.
1796
+ store.setTaskState(_sessForTask, {
1797
+ anchorHash: _row.anchorHash,
1798
+ effectiveBand: Number.isInteger(_row.effectiveBand) ? _row.effectiveBand : _freshIdx,
1799
+ open: _row.open && _openNow ? 1 : 0,
1800
+ lowStreak: _lowStreak,
1801
+ });
1802
+ }
1803
+ }
1804
+ }
1805
+ }
1806
+ } catch (err) {
1807
+ logger.debug({ err: err.message }, '[TaskBand] task-state upkeep failed');
1808
+ }
1809
+
1549
1810
  // Subscription-only fork: byte-for-byte passthrough to api.anthropic.com
1550
1811
  // when the picked tier resolves to azure-anthropic — FIRST-PARTY
1551
1812
  // Anthropic clients only (Claude Code / Claude Desktop wrapping their
@@ -3585,6 +3585,9 @@ async function invokeModel(body, options = {}) {
3585
3585
  escalation_source: body._escalationSource ?? null,
3586
3586
  pinned: body._pinnedRoute ? true : false,
3587
3587
  switch_reason: body._switchReason ?? null,
3588
+ // TaskBand stamp for the continuation telemetry columns
3589
+ // (telemetry.taskbandFields reads routingResult.taskband).
3590
+ taskband: body._taskband ?? null,
3588
3591
  // WS4 — off-policy evaluation from telemetry alone requires
3589
3592
  // propensity + candidates on every row. Deterministic default is
3590
3593
  // 1.0 with a single-entry candidate list matching the served pair.
@@ -3802,6 +3805,7 @@ async function invokeModel(body, options = {}) {
3802
3805
  pinned: routingResult.pinned ? 1 : 0,
3803
3806
  switch_reason: routingResult.switch_reason ?? null,
3804
3807
  ...telemetry.jevFields(routingResult),
3808
+ ...telemetry.taskbandFields(routingResult),
3805
3809
  cache_decision: routingResult._cacheDecision ?? null,
3806
3810
  cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3807
3811
  cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4038,6 +4042,7 @@ async function invokeModel(body, options = {}) {
4038
4042
  pinned: routingResult.pinned ? 1 : 0,
4039
4043
  switch_reason: routingResult.switch_reason ?? null,
4040
4044
  ...telemetry.jevFields(routingResult),
4045
+ ...telemetry.taskbandFields(routingResult),
4041
4046
  cache_decision: routingResult._cacheDecision ?? null,
4042
4047
  });
4043
4048
 
@@ -4152,6 +4157,7 @@ async function invokeModel(body, options = {}) {
4152
4157
  pinned: routingResult.pinned ? 1 : 0,
4153
4158
  switch_reason: routingResult.switch_reason ?? null,
4154
4159
  ...telemetry.jevFields(routingResult),
4160
+ ...telemetry.taskbandFields(routingResult),
4155
4161
  cache_decision: routingResult._cacheDecision ?? null,
4156
4162
  });
4157
4163
 
@@ -4250,6 +4256,7 @@ async function invokeModel(body, options = {}) {
4250
4256
  pinned: routingResult.pinned ? 1 : 0,
4251
4257
  switch_reason: routingResult.switch_reason ?? null,
4252
4258
  ...telemetry.jevFields(routingResult),
4259
+ ...telemetry.taskbandFields(routingResult),
4253
4260
  cache_decision: routingResult._cacheDecision ?? null,
4254
4261
  cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
4255
4262
  cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4312,6 +4319,7 @@ async function invokeModel(body, options = {}) {
4312
4319
  pinned: routingResult.pinned ? 1 : 0,
4313
4320
  switch_reason: routingResult.switch_reason ?? null,
4314
4321
  ...telemetry.jevFields(routingResult),
4322
+ ...telemetry.taskbandFields(routingResult),
4315
4323
  cache_decision: routingResult._cacheDecision ?? null,
4316
4324
  });
4317
4325