lynkr 9.14.14 → 9.14.16

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,443 @@
1
+ /**
2
+ * TaskBand task ledger — pure fold of a conversation prefix into the task
3
+ * currently in flight, so follow-up turns ("do the same here") can inherit
4
+ * the difficulty band the task already earned.
5
+ *
6
+ * Contract (shared with jev-router / intent-score / api-router):
7
+ * - deriveTaskLedger(messages): caller slices the array to EXCLUDE the
8
+ * current ask; the fold only ever sees the thread's past.
9
+ * - detectContinuation(text, ledger, embedFn): cheap veto-first detector,
10
+ * three independent branches (deictic / cosine / files).
11
+ * - buildTaskContext(ledger): slim stable string for the judge — reference
12
+ * material, never instructions.
13
+ * - buildJevSignals({...}): the flat signal set buildJevState normalizes.
14
+ *
15
+ * No env vars — every knob is a hardcoded constant below (same convention
16
+ * as jev-router.js). Requires jev-router for cleanUserText; jev-router must
17
+ * never require this module back (acyclic).
18
+ */
19
+
20
+ const crypto = require('crypto');
21
+
22
+ const COSINE_CUT = 0.70;
23
+ const EMBED_BUDGET_MS = 100;
24
+ const VETO_MAX_CHARS = 400;
25
+ const DEICTIC_MAX_CHARS = 200;
26
+ const DECAY_TURNS = 2;
27
+ const AUTO_CLOSE_LOW_TURNS = 3;
28
+ const MAX_CONTEXT = 360;
29
+ const ANCHOR_MAX = 160;
30
+ const LAST_MAX = 120;
31
+ const FLOOR_ENABLED = true;
32
+ const HOLDOUT_PCT = 0.10;
33
+
34
+ const CONSTANTS = {
35
+ COSINE_CUT,
36
+ EMBED_BUDGET_MS,
37
+ VETO_MAX_CHARS,
38
+ DEICTIC_MAX_CHARS,
39
+ DECAY_TURNS,
40
+ AUTO_CLOSE_LOW_TURNS,
41
+ MAX_CONTEXT,
42
+ ANCHOR_MAX,
43
+ LAST_MAX,
44
+ FLOOR_ENABLED,
45
+ HOLDOUT_PCT,
46
+ };
47
+
48
+ // A gratitude marker only closes when it IS the whole turn — substantive
49
+ // text after it means the user moved on mid-turn ("thanks — now do X"
50
+ // closes AND opens, however short the turn is).
51
+ const MARKER_MAX_CHARS = 30;
52
+ const MARKER_RE = /^\s*(thanks|thank you|got it|perfect|great|awesome|works now|that fixed it)\b/i;
53
+ // A contrast token means the task is NOT done ("thanks but it still fails").
54
+ const CONTRAST_RE = /\b(but|however|instead|what about|still|though|except)\b/i;
55
+ const NEW_TASK_RE = /^\s*(new task|unrelated|different (question|topic)|separately)\b/i;
56
+ const DEICTIC_RE = /\b(same|this one|that one|it again|again|also (this|these)|those)\b/i;
57
+
58
+ // File paths mentioned in ASKS ONLY — tool_use inputs are model-authored
59
+ // and would let the harness's own file churn masquerade as user intent.
60
+ const FILE_RE = /[\w\-./@~]+\.(js|ts|tsx|jsx|py|go|rs|java|json|md|yaml|yml|toml|sql)\b/g;
61
+ const FILE_SCAN_MAX_CHARS = 2000;
62
+
63
+ // Tokens too common to signal novelty. Includes the deictic vocabulary so
64
+ // "do the same thing here" counts ~0 novel tokens against any task.
65
+ const STOPWORDS = new Set([
66
+ 'the', 'a', 'an', 'and', 'or', 'but', 'if', 'then', 'else', 'for', 'to',
67
+ 'of', 'in', 'on', 'at', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
68
+ 'do', 'does', 'did', 'done', 'can', 'could', 'will', 'would', 'should',
69
+ 'may', 'might', 'must', 'this', 'that', 'these', 'those', 'it', 'its',
70
+ 'with', 'as', 'by', 'from', 'we', 'you', 'i', 'me', 'my', 'your', 'our',
71
+ 'they', 'them', 'their', 'he', 'she', 'his', 'her', 'what', 'which',
72
+ 'who', 'whom', 'how', 'when', 'where', 'why', 'not', 'no', 'yes', 'so',
73
+ 'just', 'now', 'also', 'same', 'again', 'here', 'there', 'please', 'ok',
74
+ 'okay', 'one', 'thing', 'too', 'all', 'any', 'some', 'more', 'other',
75
+ 'into', 'out', 'up', 'down', 'about', 'over', 'after', 'before', 'need',
76
+ 'want', 'like', 'make', 'get', 'use', 'let', 'lets', 'us',
77
+ ]);
78
+
79
+ function _sha256(s) {
80
+ return crypto.createHash('sha256').update(s).digest('hex');
81
+ }
82
+
83
+ function _tokensOf(text) {
84
+ const out = new Set();
85
+ const words = String(text).toLowerCase().match(/[a-z0-9_$][\w$.-]*/gi) || [];
86
+ for (const w of words) {
87
+ if (w.length < 3) continue;
88
+ if (STOPWORDS.has(w)) continue;
89
+ out.add(w);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function _filesOf(text) {
95
+ const out = new Set();
96
+ const capped = String(text).slice(0, FILE_SCAN_MAX_CHARS);
97
+ const matches = capped.match(FILE_RE) || [];
98
+ for (const m of matches) out.add(m);
99
+ return out;
100
+ }
101
+
102
+ // --- prefix-chain memo -------------------------------------------------------
103
+ // h_n = sha256(h_prev + NUL + ask_n): the orchestrator re-derives the ledger
104
+ // for every window index, but each prefix extends the last by one ask, so
105
+ // the fold state after ask_n is memoized under h_n and reuse is O(1).
106
+ const _MEMO_MAX = 500;
107
+ const _memo = new Map(); // prefixHash -> frozen fold state
108
+
109
+ function _memoGet(key) {
110
+ const hit = _memo.get(key);
111
+ if (hit) {
112
+ _memo.delete(key);
113
+ _memo.set(key, hit);
114
+ }
115
+ return hit || null;
116
+ }
117
+
118
+ function _memoSet(key, state) {
119
+ _memo.set(key, state);
120
+ while (_memo.size > _MEMO_MAX) {
121
+ const oldest = _memo.keys().next();
122
+ if (oldest.done) break;
123
+ _memo.delete(oldest.value);
124
+ }
125
+ }
126
+
127
+ function _cloneState(s) {
128
+ if (!s) return null;
129
+ return {
130
+ ...s,
131
+ tokenSet: new Set(s.tokenSet),
132
+ fileSet: new Set(s.fileSet),
133
+ };
134
+ }
135
+
136
+ /**
137
+ * One fold step over a cleaned user ask. State is null until a task opens.
138
+ * Segmentation: a marker-only turn closes; marker + substantive content
139
+ * closes and opens a new task anchored at that turn; after a close, the
140
+ * next substantive ask opens a new task. Closed-task fields (anchor,
141
+ * tokenSet, fileSet) survive the close so decayed continuations can still
142
+ * match against them.
143
+ */
144
+ function _foldAsk(state, text) {
145
+ const trimmed = String(text).trim();
146
+ const marker = MARKER_RE.test(trimmed);
147
+ const contrast = CONTRAST_RE.test(trimmed);
148
+ // Close-only requires that nothing substantive survives past the marker:
149
+ // "thanks, now fix router.js" fits under MARKER_MAX_CHARS yet redirects,
150
+ // so length alone can't decide.
151
+ const markerOnly = marker && !contrast
152
+ && trimmed.length <= MARKER_MAX_CHARS
153
+ && !/[\w$]/.test(trimmed.replace(MARKER_RE, ''));
154
+ if (markerOnly) {
155
+ if (!state) return null;
156
+ if (state.open) return { ...state, open: false, turnsSinceClose: 0, lastAsk: text };
157
+ return { ...state, turnsSinceClose: state.turnsSinceClose + 1, lastAsk: text };
158
+ }
159
+ const closesAndOpens = marker && !contrast; // "thanks — now do X"
160
+ if (!state || !state.open || closesAndOpens) {
161
+ return {
162
+ anchorText: text,
163
+ anchorHash: _sha256(text),
164
+ open: true,
165
+ turnsSinceClose: 0,
166
+ askCount: 1,
167
+ tokenSet: _tokensOf(text),
168
+ fileSet: _filesOf(text),
169
+ lastAsk: text,
170
+ };
171
+ }
172
+ const tokenSet = new Set(state.tokenSet);
173
+ for (const t of _tokensOf(text)) tokenSet.add(t);
174
+ const fileSet = new Set(state.fileSet);
175
+ for (const f of _filesOf(text)) fileSet.add(f);
176
+ return {
177
+ ...state,
178
+ askCount: state.askCount + 1,
179
+ tokenSet,
180
+ fileSet,
181
+ lastAsk: text,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Fold an Anthropic-style message array (the thread MINUS the current ask)
187
+ * into the current task's ledger. Pure over its input; the only state is
188
+ * the prefix memo. Returns null when no task ever opened.
189
+ * @param {Array} messages
190
+ * @returns {object|null} Ledger
191
+ */
192
+ function deriveTaskLedger(messages) {
193
+ if (!Array.isArray(messages) || messages.length === 0) return null;
194
+ const { cleanUserText } = require('./jev-router');
195
+
196
+ const asks = []; // { text, index }
197
+ for (let i = 0; i < messages.length; i++) {
198
+ const m = messages[i];
199
+ if (m?.role !== 'user') continue;
200
+ let text = null;
201
+ try {
202
+ text = cleanUserText(m);
203
+ } catch { /* unscoreable message — skip */ }
204
+ if (text) asks.push({ text, index: i });
205
+ }
206
+ if (asks.length === 0) return null;
207
+
208
+ const hashes = [];
209
+ let chain = '';
210
+ for (const a of asks) {
211
+ chain = _sha256(`${chain}\0${a.text}`);
212
+ hashes.push(chain);
213
+ }
214
+
215
+ let state = null;
216
+ let start = 0;
217
+ for (let i = asks.length - 1; i >= 0; i--) {
218
+ const hit = _memoGet(hashes[i]);
219
+ if (hit) {
220
+ state = _cloneState(hit);
221
+ start = i + 1;
222
+ break;
223
+ }
224
+ }
225
+ for (let i = start; i < asks.length; i++) {
226
+ state = _foldAsk(state, asks[i].text);
227
+ _memoSet(hashes[i], _cloneState(state));
228
+ }
229
+ if (!state || !state.anchorText) return null;
230
+
231
+ // Last-turn stats: tool activity AFTER the last prior ask — i.e. between
232
+ // the last two asks of the full thread, since the caller excluded the
233
+ // current one. tool_use counted on assistant turns, is_error on results.
234
+ let toolCalls = 0;
235
+ let errors = 0;
236
+ const lastAskIdx = asks[asks.length - 1].index;
237
+ for (let i = lastAskIdx + 1; i < messages.length; i++) {
238
+ const m = messages[i];
239
+ if (!Array.isArray(m?.content)) continue;
240
+ for (const block of m.content) {
241
+ if (m.role === 'assistant' && block?.type === 'tool_use') toolCalls++;
242
+ if (block?.type === 'tool_result' && block.is_error === true) errors++;
243
+ }
244
+ }
245
+
246
+ return {
247
+ anchorText: state.anchorText,
248
+ anchorHash: state.anchorHash,
249
+ lastAsk: state.lastAsk ?? null,
250
+ open: !!state.open,
251
+ turnsSinceClose: state.open ? 0 : state.turnsSinceClose,
252
+ askCount: state.askCount,
253
+ tokenSet: new Set(state.tokenSet),
254
+ fileSet: new Set(state.fileSet),
255
+ lastTurnStats: { toolCalls, errors },
256
+ prefixHash: hashes[hashes.length - 1],
257
+ };
258
+ }
259
+
260
+ function _cosine(a, b) {
261
+ if (!a || !b || a.length === 0 || a.length !== b.length) return null;
262
+ let dot = 0;
263
+ let na = 0;
264
+ let nb = 0;
265
+ for (let i = 0; i < a.length; i++) {
266
+ dot += a[i] * b[i];
267
+ na += a[i] * a[i];
268
+ nb += b[i] * b[i];
269
+ }
270
+ const denom = Math.sqrt(na) * Math.sqrt(nb);
271
+ return denom > 0 ? dot / denom : null;
272
+ }
273
+
274
+ function _validVec(v) {
275
+ return (Array.isArray(v) || ArrayBuffer.isView(v)) && v.length > 0;
276
+ }
277
+
278
+ /**
279
+ * Is the current ask a continuation of the ledger's task? Veto-first, then
280
+ * three independent branches; any one fires. The embedding branch only runs
281
+ * when the free branches both miss, raced against EMBED_BUDGET_MS so the
282
+ * detector can never hold the request hostage.
283
+ * @param {string} currentAskText - cleaned current ask
284
+ * @param {object|null} ledger
285
+ * @param {(text:string)=>Promise<Float32Array|number[]|null>} embedFn
286
+ * @returns {Promise<{isContinuation:boolean, evidence:string[], abstained:boolean}>}
287
+ */
288
+ async function detectContinuation(currentAskText, ledger, embedFn) {
289
+ const miss = { isContinuation: false, evidence: [], abstained: false };
290
+ if (!ledger || !currentAskText || typeof currentAskText !== 'string') return miss;
291
+ const text = currentAskText;
292
+ if (text.length > VETO_MAX_CHARS) return miss;
293
+ if (NEW_TASK_RE.test(text)) return miss;
294
+ if (!(ledger.open || (ledger.turnsSinceClose ?? Infinity) <= DECAY_TURNS)) return miss;
295
+
296
+ const evidence = [];
297
+
298
+ // Branch A — deictic reference with near-zero novel vocabulary.
299
+ if (DEICTIC_RE.test(text) && text.length <= DEICTIC_MAX_CHARS) {
300
+ let novel = 0;
301
+ const known = ledger.tokenSet instanceof Set ? ledger.tokenSet : new Set();
302
+ for (const t of _tokensOf(text)) {
303
+ if (!known.has(t)) novel++;
304
+ if (novel > 2) break;
305
+ }
306
+ if (novel <= 2) evidence.push('deictic');
307
+ }
308
+
309
+ // Branch C — names a file the task already touched (asks only).
310
+ if (ledger.fileSet instanceof Set && ledger.fileSet.size > 0) {
311
+ for (const f of _filesOf(text)) {
312
+ if (ledger.fileSet.has(f)) {
313
+ evidence.push('files');
314
+ break;
315
+ }
316
+ }
317
+ }
318
+
319
+ // Branch B — semantic proximity to the task anchor. Skipped when a free
320
+ // branch already decided; abstains (never blocks) on timeout/failure.
321
+ let abstained = false;
322
+ if (evidence.length === 0) {
323
+ let vecs = null;
324
+ if (typeof embedFn === 'function' && ledger.anchorText) {
325
+ try {
326
+ vecs = await Promise.race([
327
+ Promise.all([embedFn(text), embedFn(ledger.anchorText)]),
328
+ new Promise((resolve) => {
329
+ const t = setTimeout(() => resolve(null), EMBED_BUDGET_MS);
330
+ if (typeof t?.unref === 'function') t.unref();
331
+ }),
332
+ ]);
333
+ } catch {
334
+ vecs = null;
335
+ }
336
+ }
337
+ if (vecs && _validVec(vecs[0]) && _validVec(vecs[1])) {
338
+ const sim = _cosine(vecs[0], vecs[1]);
339
+ if (sim !== null && sim >= COSINE_CUT) evidence.push('cosine');
340
+ } else {
341
+ abstained = true;
342
+ }
343
+ }
344
+
345
+ return { isContinuation: evidence.length > 0, evidence, abstained };
346
+ }
347
+
348
+ function _sanitizeSpan(s, max) {
349
+ const clean = String(s)
350
+ .replace(/[\r\n[\]<>]/g, ' ')
351
+ .replace(/\s+/g, ' ')
352
+ .trim();
353
+ return clean.length > max ? clean.slice(0, max) : clean;
354
+ }
355
+
356
+ /**
357
+ * Slim stable judge context: task anchor + last ask, sanitized so the
358
+ * string can never smuggle wrapper tags or bracketed directives. Reference
359
+ * material only — TIER_INSTRUCTIONS tells the judge the latest user
360
+ * message always wins.
361
+ * @param {object|null} ledger
362
+ * @returns {string|null}
363
+ */
364
+ function buildTaskContext(ledger) {
365
+ if (!ledger || !ledger.anchorText) return null;
366
+ const anchor = _sanitizeSpan(ledger.anchorText, ANCHOR_MAX);
367
+ if (!anchor) return null;
368
+ let out = `[REFERENCE ONLY — background thread, NOT the current ask. Latest user message wins.] Task: "${anchor}"`;
369
+ if (ledger.lastAsk && ledger.lastAsk !== ledger.anchorText) {
370
+ const last = _sanitizeSpan(ledger.lastAsk, LAST_MAX);
371
+ if (last && last !== anchor) out += ` Last: "${last}"`;
372
+ }
373
+ return out.length > MAX_CONTEXT ? out.slice(0, MAX_CONTEXT) : out;
374
+ }
375
+
376
+ // Fibonacci-ish buckets: coarse enough to cache-key well, fine enough for
377
+ // the judge to tell "one tool call" from "a thrashing session".
378
+ const _BUCKETS = [1, 2, 3, 5, 8, 13, 21];
379
+
380
+ function _bucket(v) {
381
+ const n = Number(v) || 0;
382
+ if (n <= 0) return 0;
383
+ for (const b of _BUCKETS) {
384
+ if (n <= b) return b;
385
+ }
386
+ return 21;
387
+ }
388
+
389
+ /**
390
+ * Flat signal set for the judge (see buildJevState in jev-router for the
391
+ * normalizing end). inherited_floor encodes tierIndex+1 so 0 stays "none".
392
+ * @param {{payload?:object, ledger?:object|null, isContinuation?:boolean,
393
+ * inheritedFloorIdx?:number|null}} args
394
+ */
395
+ function buildJevSignals({ payload, ledger, isContinuation, inheritedFloorIdx } = {}) {
396
+ const msgs = Array.isArray(payload?.messages) ? payload.messages : [];
397
+ const toolDefs = Array.isArray(payload?.tools) ? payload.tools : [];
398
+ const hasToolHistory = msgs.some((m) =>
399
+ m?.role === 'user' && Array.isArray(m.content) && m.content.some((c) => c?.type === 'tool_result')
400
+ );
401
+ let floorIdx = Number.isInteger(inheritedFloorIdx) ? inheritedFloorIdx : null;
402
+ if (floorIdx === null && payload?._inheritedFloorTier) {
403
+ const { VALID_TIERS } = require('./jev-router');
404
+ const i = VALID_TIERS.indexOf(payload._inheritedFloorTier);
405
+ if (i >= 0) floorIdx = i;
406
+ }
407
+ return {
408
+ message_count_bucket: _bucket(msgs.length),
409
+ tools_attached: toolDefs.length,
410
+ effective_tools: toolDefs.length,
411
+ has_tool_history: hasToolHistory ? 1 : 0,
412
+ session_turn_bucket: _bucket(Math.max(1, Math.ceil(msgs.length / 2))),
413
+ is_continuation: isContinuation ? 1 : 0,
414
+ inherited_floor: floorIdx !== null && floorIdx >= 0 ? floorIdx + 1 : 0,
415
+ task_open: ledger?.open ? 1 : 0,
416
+ last_turn_tools_bucket: _bucket(ledger?.lastTurnStats?.toolCalls),
417
+ last_turn_errors_bucket: _bucket(ledger?.lastTurnStats?.errors),
418
+ };
419
+ }
420
+
421
+ function _clearMemo() {
422
+ _memo.clear();
423
+ }
424
+
425
+ module.exports = {
426
+ COSINE_CUT,
427
+ EMBED_BUDGET_MS,
428
+ VETO_MAX_CHARS,
429
+ DEICTIC_MAX_CHARS,
430
+ DECAY_TURNS,
431
+ AUTO_CLOSE_LOW_TURNS,
432
+ MAX_CONTEXT,
433
+ ANCHOR_MAX,
434
+ LAST_MAX,
435
+ FLOOR_ENABLED,
436
+ HOLDOUT_PCT,
437
+ CONSTANTS,
438
+ deriveTaskLedger,
439
+ detectContinuation,
440
+ buildTaskContext,
441
+ buildJevSignals,
442
+ _clearMemo,
443
+ };
@@ -189,6 +189,14 @@ function init() {
189
189
  ["jev_probabilities", "TEXT"],
190
190
  ["jev_model", "TEXT"],
191
191
  ["criteria_hash", "TEXT"],
192
+ // TaskBand — per-decision continuation stamp. NULL on rows recorded
193
+ // before the feature or where the ledger didn't run (side frames,
194
+ // pinned serves) — "not measured" is distinct from 0.
195
+ ["is_continuation", "INTEGER"],
196
+ ["inherited_floor", "TEXT"],
197
+ ["task_anchor_hash", "TEXT"],
198
+ ["jev_context", "TEXT"],
199
+ ["jev_cache_hit", "INTEGER"],
192
200
  ];
193
201
  for (const [col, type] of additiveCols) {
194
202
  if (!existingCols.has(col)) {
@@ -252,7 +260,8 @@ function record(data) {
252
260
  cost_efficiency, request_text, response_text,
253
261
  base_tier, escalation_source, propensity, candidates, pinned, switch_reason,
254
262
  cache_decision, cache_read_tokens, cache_creation_tokens, context,
255
- jev_verdict, jev_confidence, jev_probabilities, jev_model, criteria_hash
263
+ jev_verdict, jev_confidence, jev_probabilities, jev_model, criteria_hash,
264
+ is_continuation, inherited_floor, task_anchor_hash, jev_context, jev_cache_hit
256
265
  ) VALUES (
257
266
  @request_id, @session_id, @timestamp, @complexity_score, @tier,
258
267
  @agentic_type, @tool_count, @input_tokens, @message_count, @request_type,
@@ -262,7 +271,8 @@ function record(data) {
262
271
  @cost_efficiency, @request_text, @response_text,
263
272
  @base_tier, @escalation_source, @propensity, @candidates, @pinned, @switch_reason,
264
273
  @cache_decision, @cache_read_tokens, @cache_creation_tokens, @context,
265
- @jev_verdict, @jev_confidence, @jev_probabilities, @jev_model, @criteria_hash
274
+ @jev_verdict, @jev_confidence, @jev_probabilities, @jev_model, @criteria_hash,
275
+ @is_continuation, @inherited_floor, @task_anchor_hash, @jev_context, @jev_cache_hit
266
276
  )`
267
277
  );
268
278
  if (!insert) return;
@@ -325,6 +335,11 @@ function record(data) {
325
335
  : (typeof data.jev_probabilities === "string" ? data.jev_probabilities : JSON.stringify(data.jev_probabilities)),
326
336
  jev_model: data.jev_model ?? null,
327
337
  criteria_hash: data.criteria_hash ?? null,
338
+ is_continuation: data.is_continuation == null ? null : (data.is_continuation ? 1 : 0),
339
+ inherited_floor: data.inherited_floor ?? null,
340
+ task_anchor_hash: data.task_anchor_hash ?? null,
341
+ jev_context: data.jev_context ?? null,
342
+ jev_cache_hit: data.jev_cache_hit == null ? null : (data.jev_cache_hit ? 1 : 0),
328
343
  });
329
344
  } catch (err) {
330
345
  logger.debug({ err: err.message }, "Telemetry record failed");
@@ -1180,9 +1195,36 @@ function jevFields(src) {
1180
1195
  };
1181
1196
  }
1182
1197
 
1198
+ /**
1199
+ * Map a routing decision's TaskBand stamp (routingResult.taskband =
1200
+ * {isContinuation, inheritedFloor, anchorHash, jevContext, jevCacheHit})
1201
+ * onto the continuation telemetry columns. Null-safe like jevFields: a
1202
+ * missing stamp yields all-null fields so call sites spread this
1203
+ * unconditionally.
1204
+ */
1205
+ function taskbandFields(src) {
1206
+ const t = (src && typeof src === 'object' && src.taskband && typeof src.taskband === 'object')
1207
+ ? src.taskband
1208
+ : null;
1209
+ if (!t) {
1210
+ return {
1211
+ is_continuation: null, inherited_floor: null, task_anchor_hash: null,
1212
+ jev_context: null, jev_cache_hit: null,
1213
+ };
1214
+ }
1215
+ return {
1216
+ is_continuation: t.isContinuation ?? null,
1217
+ inherited_floor: t.inheritedFloor ?? null,
1218
+ task_anchor_hash: t.anchorHash ?? null,
1219
+ jev_context: t.jevContext ?? null,
1220
+ jev_cache_hit: t.jevCacheHit ?? null,
1221
+ };
1222
+ }
1223
+
1183
1224
  module.exports = {
1184
1225
  record,
1185
1226
  jevFields,
1227
+ taskbandFields,
1186
1228
  query,
1187
1229
  getStats: getStatsCached,
1188
1230
  getProviderStats: getProviderStatsCached,
@@ -14,7 +14,7 @@ const VISION_IMAGE_TOKEN_ESTIMATE = 1500;
14
14
 
15
15
  // Providers that can never transport image bytes (no vision path, even after
16
16
  // converter fixes). Everything else either forwards natively or converts.
17
- const TRANSPORTLESS_PROVIDERS = new Set(['llamacpp', 'lmstudio', 'codex']);
17
+ const TRANSPORTLESS_PROVIDERS = new Set(['llamacpp', 'lmstudio', 'codex', 'cursor']);
18
18
 
19
19
  /**
20
20
  * Does a single content block carry image data?