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.
@@ -77,6 +77,14 @@ function _db() {
77
77
  ttl_ms INTEGER NOT NULL
78
78
  );
79
79
  CREATE INDEX IF NOT EXISTS idx_cache_state_ts ON session_cache_state(last_request_at);
80
+ CREATE TABLE IF NOT EXISTS session_task_state (
81
+ session_id TEXT PRIMARY KEY,
82
+ anchor_hash TEXT,
83
+ effective_band INTEGER,
84
+ open INTEGER,
85
+ low_streak INTEGER,
86
+ updated_at INTEGER
87
+ );
80
88
  `);
81
89
  schemaEnsured = true;
82
90
  } catch (err) {
@@ -253,6 +261,122 @@ function loadCacheState(sessionId) {
253
261
  }
254
262
  }
255
263
 
264
+ // TaskBand — per-session task difficulty state. Lives in its OWN table for
265
+ // the same reason as session_cache_state: piggybacking non-pin rows onto
266
+ // session_pins let tier-less rows be served as routing pins (see note above
267
+ // saveCacheState). Falls back to this in-memory Map when the DB is
268
+ // unavailable so continuation floors keep working without persistence.
269
+ const taskStateMem = new Map();
270
+
271
+ /**
272
+ * Load TaskBand state for a session. Returns null when absent or on any
273
+ * DB failure.
274
+ *
275
+ * @param {string} sessionId
276
+ * @returns {{anchorHash:string|null, effectiveBand:number, open:boolean, lowStreak:number}|null}
277
+ */
278
+ function getTaskState(sessionId) {
279
+ if (!sessionId) return null;
280
+ const db = _db();
281
+ if (!db) {
282
+ const mem = taskStateMem.get(sessionId);
283
+ return mem ? { ...mem } : null;
284
+ }
285
+ try {
286
+ const row = _stmt(
287
+ db,
288
+ "task_state_load",
289
+ "SELECT anchor_hash, effective_band, open, low_streak FROM session_task_state WHERE session_id = ?"
290
+ ).get(sessionId);
291
+ if (!row) return null;
292
+ return {
293
+ anchorHash: row.anchor_hash ?? null,
294
+ effectiveBand: Number(row.effective_band) || 0,
295
+ open: !!row.open,
296
+ lowStreak: Number(row.low_streak) || 0,
297
+ };
298
+ } catch (err) {
299
+ degradation.record("feedback", err);
300
+ return null;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Upsert TaskBand state. effective_band is MAX-merged only while the stored
306
+ * anchor matches — a new anchor means a new task, whose band must not
307
+ * inherit the old peak, so the whole row is overwritten instead.
308
+ *
309
+ * @param {string} sessionId
310
+ * @param {{anchorHash:string|null, effectiveBand:number, open:boolean, lowStreak?:number}} state
311
+ */
312
+ function setTaskState(sessionId, state) {
313
+ if (!sessionId || !state) return;
314
+ const next = {
315
+ anchorHash: state.anchorHash ?? null,
316
+ effectiveBand: Number.isFinite(state.effectiveBand) ? state.effectiveBand : 0,
317
+ open: state.open ? 1 : 0,
318
+ lowStreak: Number.isFinite(state.lowStreak) ? state.lowStreak : 0,
319
+ };
320
+ const db = _db();
321
+ if (!db) {
322
+ const prev = taskStateMem.get(sessionId);
323
+ if (prev && prev.anchorHash === next.anchorHash) {
324
+ next.effectiveBand = Math.max(prev.effectiveBand, next.effectiveBand);
325
+ }
326
+ taskStateMem.set(sessionId, {
327
+ anchorHash: next.anchorHash,
328
+ effectiveBand: next.effectiveBand,
329
+ open: !!next.open,
330
+ lowStreak: next.lowStreak,
331
+ });
332
+ return;
333
+ }
334
+ try {
335
+ _stmt(
336
+ db,
337
+ "task_state_upsert",
338
+ // IS (not =) so a null↔null anchor comparison still MAX-merges. All
339
+ // SET expressions see the pre-update row, so assignment order below
340
+ // doesn't affect the CASE.
341
+ `INSERT INTO session_task_state (session_id, anchor_hash, effective_band, open, low_streak, updated_at)
342
+ VALUES (@session_id, @anchor_hash, @effective_band, @open, @low_streak, @updated_at)
343
+ ON CONFLICT(session_id) DO UPDATE SET
344
+ effective_band = CASE WHEN anchor_hash IS excluded.anchor_hash
345
+ THEN MAX(effective_band, excluded.effective_band)
346
+ ELSE excluded.effective_band END,
347
+ anchor_hash = excluded.anchor_hash,
348
+ open = excluded.open,
349
+ low_streak = excluded.low_streak,
350
+ updated_at = excluded.updated_at`
351
+ ).run({
352
+ session_id: sessionId,
353
+ anchor_hash: next.anchorHash,
354
+ effective_band: next.effectiveBand,
355
+ open: next.open,
356
+ low_streak: next.lowStreak,
357
+ updated_at: Date.now(),
358
+ });
359
+ } catch (err) {
360
+ degradation.record("feedback", err);
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Remove TaskBand state for a session.
366
+ * @param {string} sessionId
367
+ */
368
+ function clearTaskState(sessionId) {
369
+ if (!sessionId) return;
370
+ taskStateMem.delete(sessionId);
371
+ const db = _db();
372
+ if (!db) return;
373
+ try {
374
+ _stmt(db, "task_state_delete", "DELETE FROM session_task_state WHERE session_id = ?").run(sessionId);
375
+ } catch (err) {
376
+ degradation.record("feedback", err);
377
+ }
378
+ }
379
+
256
380
  /**
257
381
  * Remove a pin.
258
382
  * @param {string} sessionId
@@ -298,4 +422,15 @@ function _clear() {
298
422
  } catch { /* best-effort */ }
299
423
  }
300
424
 
301
- module.exports = { load, save, remove, cleanup, saveCacheState, loadCacheState, _clear };
425
+ module.exports = {
426
+ load,
427
+ save,
428
+ remove,
429
+ cleanup,
430
+ saveCacheState,
431
+ loadCacheState,
432
+ getTaskState,
433
+ setTaskState,
434
+ clearTaskState,
435
+ _clear,
436
+ };
@@ -625,7 +625,16 @@ async function checkPinScoreDrift(pin, payload) {
625
625
  // ceiling+margin comparisons are meaningless.
626
626
  let freshScore = null;
627
627
  if (intentScoreMode() !== 'legacy') {
628
- const intent = await scoreIntent({ messages: [{ role: 'user', content: text }] });
628
+ // TaskBand: same judge context + memo identity as the fresh-decision
629
+ // scoring of this ask (the router stamps these on the body) — drift
630
+ // must compare scores produced from identical inputs.
631
+ const intent = await scoreIntent({
632
+ messages: [{ role: 'user', content: text }],
633
+ _conversationContext: typeof payload?._conversationContext === 'string'
634
+ ? payload._conversationContext : null,
635
+ _taskAnchorHash: payload?._taskAnchorHash ?? null,
636
+ _isContinuation: !!payload?._isContinuation,
637
+ });
629
638
  if (intent && Number.isFinite(intent.score)) freshScore = intent.score;
630
639
  }
631
640
  if (freshScore === null) {
@@ -872,6 +881,18 @@ function writeSessionPin(sessionId, decision, payload) {
872
881
  }, '[Routing] Risk-forced decision — pin write skipped');
873
882
  return;
874
883
  }
884
+ // Continuation-floored tiers are inherited, not scored — pinning one would
885
+ // fossilize the floor past its decay window (same one-way ratchet as risk
886
+ // above; the floor re-derives on every turn that still earns it).
887
+ if (method.includes('+continuation_inherit')) {
888
+ logger.debug({
889
+ sessionId,
890
+ provider: decision.provider,
891
+ tier: decision.tier,
892
+ method,
893
+ }, '[Routing] Continuation-floored decision — pin write skipped');
894
+ return;
895
+ }
875
896
  const messageCount = Array.isArray(payload?.messages) ? payload.messages.length : 0;
876
897
  // Opener-only sessions (≤2 messages) never pin: a bare opener has nothing
877
898
  // to stabilize, and identical openers share a fingerprint for the 6h TTL,
@@ -1106,11 +1127,20 @@ async function _determineProviderSmartInner(payload, options = {}) {
1106
1127
  analysis.score = intent.score;
1107
1128
  analysis.scoreMode = intent.mode; // 'anchor' | 'lexical' (clean-text fallback)
1108
1129
  analysis.anchorClass = intent.class ?? null;
1130
+ // Pure anchor score BEFORE classifier reconciliation — the Jev
1131
+ // override cap derives its base tier from this, so the judge can
1132
+ // never stack a second band on top of a reconcile lift.
1133
+ analysis.anchorScore = Number.isFinite(intent.anchorScore) ? intent.anchorScore : null;
1109
1134
  // Jev verdict rides alongside the score so the shortfall floor,
1110
1135
  // risk corroboration and telemetry can consume it downstream.
1111
1136
  // Null unless the Jev leg ran (pinned serves, side frames and
1112
1137
  // classifier skips leave no verdict — same as classifierResult).
1113
1138
  if (intent.jev && typeof intent.jev === 'object') analysis.jev = intent.jev;
1139
+ // How _reconcile disposed of the verdict ('down_capped' means a weak
1140
+ // context-fed downward verdict was floored at one band below the
1141
+ // anchor) — the override below must honor that gate, not re-apply
1142
+ // the raw verdict.
1143
+ analysis.reconciled = intent.reconciled ?? false;
1114
1144
  intentScored = true;
1115
1145
  logger.debug({
1116
1146
  score: intent.score,
@@ -1230,6 +1260,8 @@ async function _determineProviderSmartInner(payload, options = {}) {
1230
1260
  let baseTier = null;
1231
1261
  const escalations = [];
1232
1262
  let _jevOverrode = false;
1263
+ let _jevCapChanged = false;
1264
+ let _jevCapBaseTier = null;
1233
1265
  if (config.modelTiers?.enabled) {
1234
1266
  try {
1235
1267
  const selector = getModelTierSelector();
@@ -1244,11 +1276,32 @@ async function _determineProviderSmartInner(payload, options = {}) {
1244
1276
  // badges and telemetry stay coherent with the decided tier.
1245
1277
  try {
1246
1278
  const { jevTierOverride } = require('./jev-router');
1247
- const _ov = jevTierOverride({
1248
- baseTier,
1279
+ // Cap base for upward verdicts: the stronger of the pure anchor
1280
+ // tier and any inherited continuation floor the request carries.
1281
+ // The judge may lift one band above THAT and no further (live bug:
1282
+ // uncapped verdicts catapulted follow-ups straight to REASONING).
1283
+ _jevCapBaseTier = Number.isFinite(analysis.anchorScore)
1284
+ ? selector.getTier(analysis.anchorScore)
1285
+ : baseTier;
1286
+ const _inheritedFloor = payload?._inheritedFloorTier;
1287
+ if (_inheritedFloor && (TIER_DEFINITIONS[_inheritedFloor]?.priority ?? -1)
1288
+ > (TIER_DEFINITIONS[_jevCapBaseTier]?.priority ?? -1)) {
1289
+ _jevCapBaseTier = _inheritedFloor;
1290
+ }
1291
+ const _jevArgs = {
1249
1292
  jevTier: analysis.jev?.tier,
1250
1293
  jevConfidence: analysis.jev?.confidence,
1251
- });
1294
+ };
1295
+ // 'down_capped' means _reconcile already floored this exact verdict
1296
+ // at one band below the anchor (weak, context-fed); the verdict sits
1297
+ // strictly below baseTier, so only the uncapped downward leg could
1298
+ // fire here — landing the full drop the reconcile gate prevented.
1299
+ const _downCapped = analysis.reconciled === 'down_capped';
1300
+ const _ov = _downCapped ? null : jevTierOverride(_jevArgs, baseTier, _jevCapBaseTier);
1301
+ // REASONING cap = the full range: the delta against it is exactly
1302
+ // "did the cap change the outcome" for the method/ledger marker.
1303
+ const _ovUncapped = _downCapped ? null : jevTierOverride(_jevArgs, baseTier, 'REASONING');
1304
+ if ((_ovUncapped?.tier ?? null) !== (_ov?.tier ?? null)) _jevCapChanged = true;
1252
1305
  if (_ov) {
1253
1306
  escalations.push({
1254
1307
  source: 'jev_override',
@@ -1323,6 +1376,7 @@ async function _determineProviderSmartInner(payload, options = {}) {
1323
1376
  // but Phase 1.2 lets the cost-optimizer pick a cheaper qualifying model when safe).
1324
1377
  let provider;
1325
1378
  let method = _jevOverrode ? 'tier_config+jev_override' : 'tier_config';
1379
+ if (_jevCapChanged) method += '+jev_override_capped';
1326
1380
  let costOptimized = false;
1327
1381
 
1328
1382
  const selector = getModelTierSelector();
@@ -1426,14 +1480,24 @@ async function _determineProviderSmartInner(payload, options = {}) {
1426
1480
  // any other decision.
1427
1481
  try {
1428
1482
  const { jevFloorTarget } = require('./jev-router');
1429
- const _floorTier = shortfallInfo && shortfallInfo.legacy
1430
- ? jevFloorTarget({
1483
+ const _floorArgs = shortfallInfo && shortfallInfo.legacy
1484
+ ? {
1431
1485
  selectedTier: tier,
1432
1486
  legacyTier: shortfallInfo.legacy.tier,
1433
1487
  jevTier: analysis.jev?.tier,
1434
1488
  jevConfidence: analysis.jev?.confidence,
1435
- })
1489
+ }
1436
1490
  : null;
1491
+ // Same cap as the override above: the judged floor can't hold a tier
1492
+ // more than one band above the anchor/inherited-floor base.
1493
+ const _floorTier = _floorArgs ? jevFloorTarget(_floorArgs, _jevCapBaseTier) : null;
1494
+ if (_floorArgs) {
1495
+ const _floorUncapped = jevFloorTarget(_floorArgs, null);
1496
+ if ((_floorUncapped ?? null) !== (_floorTier ?? null)
1497
+ && !method.includes('+jev_override_capped')) {
1498
+ method = method + '+jev_override_capped';
1499
+ }
1500
+ }
1437
1501
  if (_floorTier) {
1438
1502
  const _floorSel = selector.selectModel(_floorTier, null);
1439
1503
  if (_floorSel && _floorSel.provider && _floorSel.model) {
@@ -96,53 +96,29 @@ function intentScoreMode() {
96
96
  /**
97
97
  * Extract the text the USER actually authored this turn: the latest user
98
98
  * message that has real text after stripping harness-injected content.
99
- * Returns null when there is nothing to score (e.g. tool-result-only turn).
99
+ * Returns {text:null, index:-1} when there is nothing to score (e.g.
100
+ * tool-result-only turn). Per-message cleaning delegates to jev-router's
101
+ * shared cleaner (wrapper/command tags, line-start unclosed-tag rule,
102
+ * whole-message harness content) so the anchor scorer, the judge and the
103
+ * task ledger all read the same user text.
100
104
  */
101
- function extractCleanUserText(payload) {
105
+ function _latestUserAsk(payload) {
102
106
  const msgs = payload?.messages;
103
- if (!Array.isArray(msgs)) return null;
107
+ if (!Array.isArray(msgs)) return { text: null, index: -1 };
108
+ const { cleanUserText } = require('./jev-router');
104
109
  for (let i = msgs.length - 1; i >= 0; i--) {
105
110
  const msg = msgs[i];
106
111
  if (msg?.role !== 'user') continue;
107
- let text = null;
108
- if (typeof msg.content === 'string') {
109
- text = msg.content;
110
- } else if (Array.isArray(msg.content)) {
111
- text = msg.content
112
- .filter((b) => b?.type === 'text' && typeof b.text === 'string')
113
- .map((b) => b.text)
114
- .join(' ');
115
- }
116
- if (text == null) continue;
117
- text = text
118
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
119
- // Harness task/background-agent notifications — not user-authored.
120
- .replace(/<task-notification>[\s\S]*?<\/task-notification>/g, '')
121
- // Codex harness blocks (merged into the typed text upstream):
122
- // sandbox/permission profile and AGENTS.md contents.
123
- .replace(/<environment_context>[\s\S]*?<\/environment_context>/g, '')
124
- .replace(/<user_instructions>[\s\S]*?<\/user_instructions>/g, '')
125
- // Goose harness block wrapping every typed message (time, cwd, todo
126
- // notes) — its "tasks"/"update"/"requirements" vocabulary scored a
127
- // bare "Hi" as substantive/MEDIUM.
128
- .replace(/<turn-context>[\s\S]*?<\/turn-context>/g, '')
129
- // Lynkr's own injected notices (quota banners, badges) start with the
130
- // [Lynkr] marker — the user didn't type those.
131
- .replace(/^\s*\[Lynkr\][^\n]*$/gm, '')
132
- .trim();
133
- // Whole-message harness content: compaction/continuation summaries and
134
- // system notifications arrive as user-role messages the user never
135
- // typed. Treat as empty and keep walking back.
136
- if (/^\s*(\[SYSTEM NOTIFICATION|<conversation[\s>]|<session[\s>]|\[Request interrupted|This session is being continued from a previous conversation)/i.test(text)) {
137
- // Continuation summaries PARAPHRASE the prior session, so they carry
138
- // force-phrase vocabulary the user typed hours ago.
139
- text = '';
140
- }
141
- if (text) return text;
112
+ const text = cleanUserText(msg);
113
+ if (text) return { text, index: i };
142
114
  // A user message that was ALL injected content (or all tool_results)
143
115
  // doesn't end the search — keep walking back for the real user turn.
144
116
  }
145
- return null;
117
+ return { text: null, index: -1 };
118
+ }
119
+
120
+ function extractCleanUserText(payload) {
121
+ return _latestUserAsk(payload).text;
146
122
  }
147
123
 
148
124
  function cosine(a, b) {
@@ -317,13 +293,21 @@ function _lexicalCleanScore(text) {
317
293
  // - Agreement → keep anchor score as-is.
318
294
  // - Classifier lower than anchor → trust classifier (catches embedding
319
295
  // false-positives like "list exports" scoring REASONING). Position
320
- // score at midpoint of classifier's target band.
321
- // - Classifier higher than anchor → safety-gate: require confidence≥0.8
296
+ // score at midpoint of classifier's target band. When the verdict was
297
+ // produced WITH conversation_context at confidence < 0.6, the drop is
298
+ // capped at one band below the anchor: background-thread context reads
299
+ // follow-ups as small asks, and a weak context-fed verdict must not
300
+ // collapse a heavyweight anchor to SIMPLE.
301
+ // - Classifier higher than anchor → safety-gate: require confidence≥0.3
302
+ // (set 2026-09-22 — see JEV_PROMOTE_CONFIDENCE note)
322
303
  // before trusting an escalation to a more expensive tier. Below that,
323
304
  // keep the cheaper anchor decision.
324
- function _reconcile(anchorScore, anchorClass, classifierResult) {
305
+ function _reconcile(anchorScore, anchorClass, classifierResult, hasContext = false) {
325
306
  if (!classifierResult) return { score: anchorScore, reconciled: false };
326
- if (classifierResult.confidence < 0.6) return { score: anchorScore, reconciled: false };
307
+ // Single 0.3 cut (set 2026-09-22): below it the verdict is noise in both
308
+ // directions. Note this also lets 0.3+ verdicts pull DOWN a band —
309
+ // the price of trusting the judge symmetrically.
310
+ if (classifierResult.confidence < 0.3) return { score: anchorScore, reconciled: false };
327
311
 
328
312
  // Anchor class → implied tier (matches model-tiers.js band definitions).
329
313
  const anchorTier = anchorScore <= 19 ? 'SIMPLE'
@@ -343,6 +327,13 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
343
327
 
344
328
  if (classifierIdx < anchorIdx) {
345
329
  // Classifier says LOWER tier — trust it. Fixes over-routing.
330
+ if (hasContext && classifierResult.confidence < 0.6) {
331
+ const flooredIdx = Math.max(classifierIdx, anchorIdx - 1);
332
+ return {
333
+ score: TIER_MIDPOINT[TIER_ORDER[flooredIdx]],
334
+ reconciled: flooredIdx > classifierIdx ? 'down_capped' : 'down',
335
+ };
336
+ }
346
337
  return { score: TIER_MIDPOINT[classifierTier], reconciled: 'down' };
347
338
  }
348
339
  // Classifier says HIGHER tier — gate on confidence, and cap the jump at
@@ -354,7 +345,7 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
354
345
  // the adjacent band now takes consecutive turns that keep re-scoring
355
346
  // higher, which is exactly the persistence a genuinely hard conversation
356
347
  // exhibits.
357
- if (classifierResult.confidence >= 0.8) {
348
+ if (classifierResult.confidence >= 0.3) {
358
349
  const cappedIdx = Math.min(classifierIdx, anchorIdx + 1);
359
350
  return {
360
351
  score: TIER_MIDPOINT[TIER_ORDER[cappedIdx]],
@@ -364,11 +355,14 @@ function _reconcile(anchorScore, anchorClass, classifierResult) {
364
355
  return { score: anchorScore, reconciled: 'up_gated' };
365
356
  }
366
357
 
367
- // Memoize the final reconciled score per cleaned text. The classifier leg is
368
- // live and can time out under load, sending identical text down different
369
- // fallback paths — which made the same turn score differently between the
370
- // router and the pin-drift checker (observed: 63 vs 77 for one prompt under
371
- // suite-wide Ollama contention). First resolution wins for the process life.
358
+ // Memoize the final reconciled score per cleaned text + task identity. The
359
+ // classifier leg is live and can time out under load, sending identical text
360
+ // down different fallback paths — which made the same turn score differently
361
+ // between the router and the pin-drift checker (observed: 63 vs 77 for one
362
+ // prompt under suite-wide Ollama contention). First resolution wins for the
363
+ // process life. The key carries the task anchor hash and continuation flag:
364
+ // the judge's verdict depends on them, so identical text under a different
365
+ // task must not share an entry — a text-only key served stale-context scores.
372
366
  const _scoreMemo = new Map();
373
367
  const _SCORE_MEMO_MAX = 500;
374
368
 
@@ -376,20 +370,36 @@ async function scoreIntent(payload, opts = {}) {
376
370
  const mode = opts.mode ?? intentScoreMode();
377
371
  if (mode === 'legacy') return null;
378
372
 
379
- const text = extractCleanUserText(payload);
373
+ const { text, index: askIdx } = _latestUserAsk(payload);
380
374
  if (!text) return null;
381
375
 
382
376
  // Only memoize plain calls — opts that alter scoring (custom centroids,
383
- // embedFn, risk/context inputs) must not share cache entries.
377
+ // embedFn, risk inputs) must not share cache entries.
384
378
  const memoizable = !opts.centroids && !opts.embedFn && !opts.forceMatched
385
379
  && !opts.riskLevel && !opts.skipClassifier && !opts.priorTurns;
386
- if (memoizable && _scoreMemo.has(text)) return _scoreMemo.get(text);
380
+
381
+ // Prior-task ledger (messages BEFORE the current ask): names the task for
382
+ // the memo key and feeds the judge's context + flat signals. It never
383
+ // touches the anchor scorer's input — envelope invariance holds.
384
+ let ledger = null;
385
+ if (!opts.skipClassifier) {
386
+ try {
387
+ const { deriveTaskLedger } = require('./task-ledger');
388
+ const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
389
+ ledger = deriveTaskLedger(msgs.slice(0, Math.max(askIdx, 0)));
390
+ } catch { /* ledger unavailable — task-less key, context-free judge */ }
391
+ }
392
+
393
+ const memoKey = memoizable
394
+ ? `${text}\0${payload?._taskAnchorHash || ledger?.anchorHash || ''}\0${payload?._isContinuation ? '1' : '0'}`
395
+ : null;
396
+ if (memoizable && _scoreMemo.has(memoKey)) return _scoreMemo.get(memoKey);
387
397
  const _memoSet = (result) => {
388
398
  if (memoizable && result) {
389
399
  if (_scoreMemo.size >= _SCORE_MEMO_MAX) {
390
400
  _scoreMemo.delete(_scoreMemo.keys().next().value);
391
401
  }
392
- _scoreMemo.set(text, result);
402
+ _scoreMemo.set(memoKey, result);
393
403
  }
394
404
  return result;
395
405
  };
@@ -423,27 +433,31 @@ async function scoreIntent(payload, opts = {}) {
423
433
  // on NODE_ENV — .env pins it to production in every context.
424
434
  let classifierResult = null;
425
435
  let jevResult = null;
436
+ // Task context (anchor + last ask of the prior-task ledger) so the
437
+ // judge resolves follow-ups. Explicit caller context wins;
438
+ // single-message payloads yield null (unchanged behavior). Tracked
439
+ // out here so _reconcile knows whether the verdict was context-fed.
440
+ let jevContext = typeof payload?._conversationContext === 'string'
441
+ ? payload._conversationContext
442
+ : null;
426
443
  if (!opts.skipClassifier) {
427
444
  try {
428
445
  const { classifyJev } = require('./jev-router');
429
- const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
430
- const toolDefs = Array.isArray(payload?.tools) ? payload.tools : [];
431
- const toolResults = msgs.filter((m) =>
432
- m?.role === 'user' && Array.isArray(m.content) && m.content.some((c) => c?.type === 'tool_result')
433
- ).length;
434
- const jevFetchFn = opts.jevFetchFn;
446
+ let signals = {};
447
+ try {
448
+ const { buildTaskContext, buildJevSignals } = require('./task-ledger');
449
+ if (jevContext === null) jevContext = buildTaskContext(ledger);
450
+ signals = buildJevSignals({
451
+ payload,
452
+ ledger,
453
+ isContinuation: payload?._isContinuation === true,
454
+ inheritedFloorIdx: payload?._inheritedFloorIdx ?? null,
455
+ });
456
+ } catch { /* ledger unavailable — judge runs on bare signals */ }
435
457
  jevResult = await classifyJev(text, {
436
- context: typeof payload?._conversationContext === 'string'
437
- ? payload._conversationContext
438
- : null,
439
- signals: {
440
- message_count: msgs.length,
441
- tools_attached: toolDefs.length,
442
- effective_tools: toolDefs.length,
443
- has_tool_history: toolResults > 0,
444
- session_turn: Math.max(1, Math.ceil(msgs.length / 2)),
445
- },
446
- fetchFn: jevFetchFn,
458
+ context: jevContext,
459
+ signals,
460
+ fetchFn: opts.jevFetchFn,
447
461
  });
448
462
  if (jevResult) {
449
463
  classifierResult = { tier: jevResult.tier, confidence: jevResult.confidence };
@@ -453,7 +467,7 @@ async function scoreIntent(payload, opts = {}) {
453
467
  }
454
468
  }
455
469
 
456
- const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult);
470
+ const { score, reconciled } = _reconcile(anchorScore, cls, classifierResult, !!jevContext);
457
471
  if (reconciled) {
458
472
  logger.debug({
459
473
  text: text.slice(0, 80),