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.
@@ -3382,6 +3382,121 @@ async function invokeCodex(body, _incomingHeaders = {}) {
3382
3382
  };
3383
3383
  }
3384
3384
 
3385
+ async function invokeCursor(body, _incomingHeaders = {}) {
3386
+ const cursorUtils = require("./cursor-utils");
3387
+
3388
+ if (config.cursor?.enabled === false) {
3389
+ throw new Error("Cursor provider is disabled (set CURSOR_ENABLED=true to use cursor: tiers)");
3390
+ }
3391
+
3392
+ const model = body._tierModel || config.cursor?.model || cursorUtils.DEFAULT_MODEL;
3393
+ const { prompt, systemContext } = cursorUtils.convertAnthropicToCursorPrompt(body);
3394
+
3395
+ if (!prompt) {
3396
+ throw new Error("Cursor: no prompt content to send");
3397
+ }
3398
+
3399
+ const fullPrompt = systemContext ? `System context:\n${systemContext}\n\nUser request:\n${prompt}` : prompt;
3400
+ const binaryPath = cursorUtils.getBinaryPath(config.cursor);
3401
+ const baseTimeoutMs = config.cursor?.timeout || cursorUtils.DEFAULT_TIMEOUT_MS;
3402
+ const workspace = body._workspace || null;
3403
+
3404
+ // Streaming (Phase 3, 2026-09-27): synthesize an OpenAI SSE stream from
3405
+ // ACP session/update deltas. "cursor" is registered in
3406
+ // DEFAULT_OPENAI_SSE_PROVIDERS, so the orchestrator either transforms this
3407
+ // to Anthropic SSE (badge injection included) or passes it through to
3408
+ // OpenAI-surface clients. The legacy spawn fallback emits no deltas — its
3409
+ // full text arrives as one chunk at the end, which is still a valid
3410
+ // stream. Errors after SSE has started cannot re-enter tier fallback (the
3411
+ // 200 is already committed) — same semantics as every streaming provider.
3412
+ if (body.stream === true) {
3413
+ const { PassThrough } = require("node:stream");
3414
+ const stream = new PassThrough();
3415
+ const chunkId = `chatcmpl-cursor-${Date.now()}`;
3416
+ let deltasSent = false;
3417
+ const writeChunk = (delta, finishReason = null) => {
3418
+ stream.write(`data: ${JSON.stringify({
3419
+ id: chunkId,
3420
+ object: "chat.completion.chunk",
3421
+ created: Math.floor(Date.now() / 1000),
3422
+ model,
3423
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
3424
+ })}\n\n`);
3425
+ };
3426
+ writeChunk({ role: "assistant" });
3427
+ // SSE comment keep-alive: harmless to parsers, keeps idle-timeout-prone
3428
+ // clients on passthrough surfaces alive during silent gaps.
3429
+ const keepAlive = setInterval(() => { try { stream.write(": ka\n\n"); } catch { /* stream gone */ } }, 15000);
3430
+ keepAlive.unref?.();
3431
+ cursorUtils.runCursorAgent({
3432
+ prompt: fullPrompt,
3433
+ resumePrompt: cursorUtils.latestUserTurnPrompt(body),
3434
+ sessionKey: cursorUtils.deriveSessionKey(body),
3435
+ model,
3436
+ baseTimeoutMs,
3437
+ binaryPath,
3438
+ workspace,
3439
+ onDelta: (text, kind) => {
3440
+ deltasSent = true;
3441
+ if (kind === "thought") {
3442
+ writeChunk({ reasoning_content: text });
3443
+ } else if (kind === "tool") {
3444
+ // Visible in EVERY client (Cursor chat ignores reasoning_content),
3445
+ // badge-styled so stripLynkrBadges removes it from resubmitted
3446
+ // history — narration renders once and never re-enters context.
3447
+ writeChunk({ content: `\n\n*[Lynkr] ${String(text).replace(/[*\n]+/g, " ").trim()}*\n\n` });
3448
+ } else {
3449
+ writeChunk({ content: text });
3450
+ }
3451
+ },
3452
+ }).then((run) => {
3453
+ clearInterval(keepAlive);
3454
+ if (!deltasSent && run.text) writeChunk({ content: run.text });
3455
+ writeChunk({}, "stop");
3456
+ stream.write("data: [DONE]\n\n");
3457
+ stream.end();
3458
+ }).catch((err) => {
3459
+ clearInterval(keepAlive);
3460
+ logger.warn({ err: err.message }, "[Cursor] streaming serve failed mid-flight");
3461
+ writeChunk({ content: `\n[cursor provider error: ${String(err.message || err).slice(0, 200)}]` }, "stop");
3462
+ stream.write("data: [DONE]\n\n");
3463
+ stream.end();
3464
+ });
3465
+ return { ok: true, status: 200, stream, contentType: "text/event-stream", headers: {} };
3466
+ }
3467
+
3468
+ let run;
3469
+ try {
3470
+ run = await cursorUtils.runCursorAgent({
3471
+ prompt: fullPrompt,
3472
+ resumePrompt: cursorUtils.latestUserTurnPrompt(body),
3473
+ sessionKey: cursorUtils.deriveSessionKey(body),
3474
+ model,
3475
+ baseTimeoutMs,
3476
+ binaryPath,
3477
+ workspace,
3478
+ });
3479
+ } catch (err) {
3480
+ // classifyCursorError already attached the honest cause (timeout vs
3481
+ // auth vs missing binary) — no blanket subscription hint here.
3482
+ throw new Error(`Cursor CLI failed: ${err.message || err}`);
3483
+ }
3484
+
3485
+ if (!run.text) {
3486
+ throw new Error("Cursor: empty response from cursor-agent");
3487
+ }
3488
+
3489
+ const anthropicJson = cursorUtils.convertCursorResponseToAnthropic(run.text, model, run.usage, run.thinking);
3490
+
3491
+ return {
3492
+ ok: true,
3493
+ status: 200,
3494
+ json: anthropicJson,
3495
+ text: JSON.stringify(anthropicJson),
3496
+ contentType: "application/json",
3497
+ };
3498
+ }
3499
+
3385
3500
  /**
3386
3501
  * Compute request cost in USD from model pricing × token usage.
3387
3502
  * Registry returns per-1M-token prices ({ input, output }); returns null when
@@ -3457,6 +3572,13 @@ function captureResponseText(resultJson) {
3457
3572
  // non-greedy lazy match is unnecessary — match up to (and including) the
3458
3573
  // closing `*` plus trailing whitespace.
3459
3574
  const LYNKR_BADGE_PREFIX_RE = /^\*\[Lynkr\][^*\n]*\*\s*/;
3575
+ // Mid-message badge LINES (tool narration, 2026-09-27) — strip anywhere in
3576
+ // assistant content, swallowing surrounding blank lines so paragraphs reflow.
3577
+ const LYNKR_BADGE_LINE_RE = /\n{0,2}\*\[Lynkr\][^*\n]*\*[ \t]*(?=\n|$)/g;
3578
+
3579
+ function _stripBadgeText(text) {
3580
+ return text.replace(LYNKR_BADGE_PREFIX_RE, "").replace(LYNKR_BADGE_LINE_RE, "");
3581
+ }
3460
3582
 
3461
3583
  function stripLynkrBadges(messages) {
3462
3584
  if (!Array.isArray(messages)) return messages;
@@ -3468,8 +3590,8 @@ function stripLynkrBadges(messages) {
3468
3590
  // what the orchestrator's OpenAI-format response branch produces, and
3469
3591
  // it's where badges actually leak in the Ollama agent loop.
3470
3592
  if (typeof msg.content === 'string') {
3471
- if (!LYNKR_BADGE_PREFIX_RE.test(msg.content)) return msg;
3472
- const stripped = msg.content.replace(LYNKR_BADGE_PREFIX_RE, '');
3593
+ const stripped = _stripBadgeText(msg.content);
3594
+ if (stripped === msg.content) return msg;
3473
3595
  mutated = true;
3474
3596
  // Badge-only content must not become an empty string — Anthropic
3475
3597
  // rejects empty assistant content (this is the interrupted-response
@@ -3487,9 +3609,9 @@ function stripLynkrBadges(messages) {
3487
3609
  let changed = false;
3488
3610
  const rebuilt = [];
3489
3611
  for (const b of msg.content) {
3490
- if (b?.type === 'text' && typeof b.text === 'string' && LYNKR_BADGE_PREFIX_RE.test(b.text)) {
3612
+ if (b?.type === 'text' && typeof b.text === 'string' && _stripBadgeText(b.text) !== b.text) {
3491
3613
  changed = true;
3492
- const strippedText = b.text.replace(LYNKR_BADGE_PREFIX_RE, '');
3614
+ const strippedText = _stripBadgeText(b.text);
3493
3615
  if (strippedText.trim()) rebuilt.push({ ...b, text: strippedText });
3494
3616
  // badge-only block → drop
3495
3617
  } else {
@@ -3533,6 +3655,7 @@ const PROVIDER_INVOKERS = {
3533
3655
  vertex: invokeVertex,
3534
3656
  moonshot: invokeMoonshot,
3535
3657
  codex: invokeCodex,
3658
+ cursor: invokeCursor,
3536
3659
  baidu: invokeBaidu,
3537
3660
  fireworks: invokeFireworks,
3538
3661
  orcarouter: invokeOrcaRouter,
@@ -3585,6 +3708,9 @@ async function invokeModel(body, options = {}) {
3585
3708
  escalation_source: body._escalationSource ?? null,
3586
3709
  pinned: body._pinnedRoute ? true : false,
3587
3710
  switch_reason: body._switchReason ?? null,
3711
+ // TaskBand stamp for the continuation telemetry columns
3712
+ // (telemetry.taskbandFields reads routingResult.taskband).
3713
+ taskband: body._taskband ?? null,
3588
3714
  // WS4 — off-policy evaluation from telemetry alone requires
3589
3715
  // propensity + candidates on every row. Deterministic default is
3590
3716
  // 1.0 with a single-entry candidate list matching the served pair.
@@ -3802,6 +3928,7 @@ async function invokeModel(body, options = {}) {
3802
3928
  pinned: routingResult.pinned ? 1 : 0,
3803
3929
  switch_reason: routingResult.switch_reason ?? null,
3804
3930
  ...telemetry.jevFields(routingResult),
3931
+ ...telemetry.taskbandFields(routingResult),
3805
3932
  cache_decision: routingResult._cacheDecision ?? null,
3806
3933
  cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
3807
3934
  cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4038,6 +4165,7 @@ async function invokeModel(body, options = {}) {
4038
4165
  pinned: routingResult.pinned ? 1 : 0,
4039
4166
  switch_reason: routingResult.switch_reason ?? null,
4040
4167
  ...telemetry.jevFields(routingResult),
4168
+ ...telemetry.taskbandFields(routingResult),
4041
4169
  cache_decision: routingResult._cacheDecision ?? null,
4042
4170
  });
4043
4171
 
@@ -4152,6 +4280,7 @@ async function invokeModel(body, options = {}) {
4152
4280
  pinned: routingResult.pinned ? 1 : 0,
4153
4281
  switch_reason: routingResult.switch_reason ?? null,
4154
4282
  ...telemetry.jevFields(routingResult),
4283
+ ...telemetry.taskbandFields(routingResult),
4155
4284
  cache_decision: routingResult._cacheDecision ?? null,
4156
4285
  });
4157
4286
 
@@ -4250,6 +4379,7 @@ async function invokeModel(body, options = {}) {
4250
4379
  pinned: routingResult.pinned ? 1 : 0,
4251
4380
  switch_reason: routingResult.switch_reason ?? null,
4252
4381
  ...telemetry.jevFields(routingResult),
4382
+ ...telemetry.taskbandFields(routingResult),
4253
4383
  cache_decision: routingResult._cacheDecision ?? null,
4254
4384
  cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
4255
4385
  cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
@@ -4312,6 +4442,7 @@ async function invokeModel(body, options = {}) {
4312
4442
  pinned: routingResult.pinned ? 1 : 0,
4313
4443
  switch_reason: routingResult.switch_reason ?? null,
4314
4444
  ...telemetry.jevFields(routingResult),
4445
+ ...telemetry.taskbandFields(routingResult),
4315
4446
  cache_decision: routingResult._cacheDecision ?? null,
4316
4447
  });
4317
4448
 
@@ -4415,6 +4546,7 @@ module.exports = {
4415
4546
  invokeFireworks,
4416
4547
  invokeAtlas,
4417
4548
  invokeOrcaRouter,
4549
+ invokeCursor,
4418
4550
  PROVIDER_INVOKERS,
4419
4551
  stripLynkrBadges,
4420
4552
  destroyHttpAgents,
@@ -60,6 +60,16 @@ function _registerBuiltins() {
60
60
  : {};
61
61
  probes.set('lmstudio', () => _cheapGet(`${config.lmstudio.endpoint}/v1/models`, headers));
62
62
  }
63
+ if (config.cursor?.enabled === true) {
64
+ // Cheap local check: the CLI binary responds to --version. Auth status
65
+ // (subscription) is NOT probed here — a logged-out CLI still prints a
66
+ // version, and per-request failures surface via normal tier fallback.
67
+ probes.set('cursor', async () => {
68
+ const { execFileSync } = require('node:child_process');
69
+ const binary = config.cursor?.binaryPath?.trim() || process.env.CURSOR_BINARY_PATH?.trim() || 'cursor-agent';
70
+ execFileSync(binary, ['--version'], { timeout: PROBE_TIMEOUT_MS, stdio: 'ignore' });
71
+ });
72
+ }
63
73
  }
64
74
 
65
75
  /**
@@ -727,6 +727,12 @@ var config = {
727
727
  model: process.env.CODEX_MODEL?.trim() || "gpt-5.3-codex",
728
728
  timeout: Number.parseInt(process.env.CODEX_TIMEOUT || "120000", 10) || 120000,
729
729
  },
730
+ cursor: {
731
+ enabled: process.env.CURSOR_ENABLED === "true",
732
+ binaryPath: process.env.CURSOR_BINARY_PATH?.trim() || "cursor-agent",
733
+ model: process.env.CURSOR_MODEL?.trim() || "composer-2.5",
734
+ timeout: Number.parseInt(process.env.CURSOR_TIMEOUT || "120000", 10) || 120000,
735
+ },
730
736
  hotReload: {
731
737
  enabled: hotReloadEnabled,
732
738
  debounceMs: Number.isNaN(hotReloadDebounceMs) ? 1000 : hotReloadDebounceMs,
@@ -1197,6 +1203,10 @@ function reloadConfig() {
1197
1203
  config.orcarouter.authBaseUrl = orcaAuth;
1198
1204
  config.orcarouter.apiBaseUrl = orcaApi;
1199
1205
  config.orcarouter.endpoint = process.env.ORCAROUTER_ENDPOINT?.trim() || `${orcaApi}/v1/chat/completions`;
1206
+ config.cursor.enabled = process.env.CURSOR_ENABLED === "true";
1207
+ config.cursor.binaryPath = process.env.CURSOR_BINARY_PATH?.trim() || "cursor-agent";
1208
+ config.cursor.model = process.env.CURSOR_MODEL?.trim() || "composer-2.5";
1209
+ config.cursor.timeout = Number.parseInt(process.env.CURSOR_TIMEOUT || "120000", 10) || 120000;
1200
1210
 
1201
1211
  // Model provider settings
1202
1212
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
@@ -65,6 +65,8 @@ function getDestinationUrl(providerType) {
65
65
  return config.orcarouter?.endpoint ?? 'https://api.orcarouter.ai/v1/chat/completions';
66
66
  case 'codex':
67
67
  return 'codex://app-server (local process)';
68
+ case 'cursor':
69
+ return 'cursor://cursor-agent (local CLI)';
68
70
  default:
69
71
  return 'unknown';
70
72
  }
@@ -2783,6 +2785,12 @@ IMPORTANT TOOL USAGE RULES:
2783
2785
  if (Array.isArray(anthropicPayload?.content)) {
2784
2786
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2785
2787
  }
2788
+ } else if (actualProvider === "cursor") {
2789
+ // Cursor CLI responses are already in Anthropic format from invokeCursor
2790
+ anthropicPayload = databricksResponse.json;
2791
+ if (Array.isArray(anthropicPayload?.content)) {
2792
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
2793
+ }
2786
2794
  } else if (databricksResponse.json?.type === "message" && Array.isArray(databricksResponse.json?.content)) {
2787
2795
  // Shape-detected: already Anthropic (some clients convert upstream).
2788
2796
  // Re-converting via toAnthropicResponse reads the absent choices[]
@@ -25,9 +25,9 @@ const logger = require("../logger");
25
25
  // invoke fn converts buffered responses to Anthropic, and its Anthropic-format
26
26
  // endpoint has the native passthrough path instead). moonshot joined the list
27
27
  // once invokeMoonshot learned to return the raw stream (its old forced
28
- // stream:false predated this transformer). Caveat: reasoning_content deltas
29
- // (kimi thinking) are not reshaped — thinking text is dropped from streamed
30
- // responses; the buffered path still lifts it into thinking blocks.
28
+ // stream:false predated this transformer). reasoning_content deltas (kimi/
29
+ // glm/gpt-oss/cursor thoughts) are reshaped into Anthropic thinking blocks
30
+ // (2026-09-27) so the thinking phase streams live instead of dropping.
31
31
  // baidu (Qianfan's /v2/chat/completions) already returns the raw stream the
32
32
  // same way moonshot does (invokeBaidu's `if (response?.stream) return
33
33
  // response;`) and its endpoint is documented as OpenAI-compatible — but
@@ -38,6 +38,7 @@ const logger = require("../logger");
38
38
  // shared transformer for one provider's quirk.
39
39
  const DEFAULT_OPENAI_SSE_PROVIDERS = [
40
40
  "openai",
41
+ "cursor",
41
42
  "atlas",
42
43
  "azure-openai",
43
44
  "openrouter",
@@ -163,6 +164,7 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
163
164
  let model = fallbackModel;
164
165
  let nextIndex = 0;
165
166
  let textIndex = null; // open text block index, null when closed
167
+ let thinkingIndex = null; // open thinking block index, null when closed
166
168
  let finishReason = null;
167
169
  const toolAcc = new Map(); // openai tool index -> { id, name, args }
168
170
 
@@ -239,6 +241,12 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
239
241
  return out;
240
242
  };
241
243
 
244
+ const closeThinkingBlock = function* () {
245
+ if (thinkingIndex === null) return;
246
+ yield _sse("content_block_stop", { type: "content_block_stop", index: thinkingIndex });
247
+ thinkingIndex = null;
248
+ };
249
+
242
250
  const closeTextBlock = function* () {
243
251
  // Flush a held-back fragment that turned out not to be a tag.
244
252
  if (_tagTail && !_inThink && textIndex !== null) {
@@ -306,11 +314,34 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
306
314
 
307
315
  yield* startMessage();
308
316
 
317
+ // Reasoning deltas (kimi/glm/gpt-oss/cursor-ACP thoughts) become a
318
+ // proper Anthropic thinking block so clients show live activity during
319
+ // the thinking phase instead of dead air (2026-09-26 complaint: badge,
320
+ // then minutes of silence). Thinking and text are sequential blocks —
321
+ // close one before opening the other.
322
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
323
+ yield* closeTextBlock();
324
+ if (thinkingIndex === null) {
325
+ thinkingIndex = nextIndex++;
326
+ yield _sse("content_block_start", {
327
+ type: "content_block_start",
328
+ index: thinkingIndex,
329
+ content_block: { type: "thinking", thinking: "" },
330
+ });
331
+ }
332
+ yield _sse("content_block_delta", {
333
+ type: "content_block_delta",
334
+ index: thinkingIndex,
335
+ delta: { type: "thinking_delta", thinking: delta.reasoning_content },
336
+ });
337
+ }
338
+
309
339
  // Text deltas are straightforward: open a block on first text, then
310
340
  // emit a text_delta per chunk.
311
341
  if (typeof delta.content === "string" && delta.content.length > 0) {
312
342
  const visible = filterThink(delta.content);
313
343
  if (visible.length > 0) {
344
+ yield* closeThinkingBlock();
314
345
  if (textIndex === null) {
315
346
  textIndex = nextIndex++;
316
347
  yield _sse("content_block_start", {
@@ -331,6 +362,7 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
331
362
  // function.arguments are NOT parseable individually — only the full
332
363
  // concatenation is valid JSON, so blocks are emitted at stream end.
333
364
  if (Array.isArray(delta.tool_calls)) {
365
+ yield* closeThinkingBlock();
334
366
  yield* closeTextBlock();
335
367
  for (const tc of delta.tool_calls) {
336
368
  const idx = tc.index ?? 0;
@@ -345,6 +377,7 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
345
377
  } catch (err) {
346
378
  logger.warn({ err: err.message }, "[SSETransform] Upstream stream failed mid-flight");
347
379
  yield* startMessage();
380
+ yield* closeThinkingBlock();
348
381
  yield* closeTextBlock();
349
382
  yield _sse("error", {
350
383
  type: "error",
@@ -358,6 +391,7 @@ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
358
391
 
359
392
  // Normal end of stream ([DONE] or upstream EOF).
360
393
  yield* startMessage();
394
+ yield* closeThinkingBlock();
361
395
  yield* closeTextBlock();
362
396
 
363
397
  // An EOF with no finish_reason is a dropped upstream, not a completed
@@ -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
+ };
@@ -71,6 +71,12 @@ function buildRequirementVector({ dimensions = {}, agenticResult = null } = {})
71
71
  // Agentic work always needs tool orchestration — floor only, never lower.
72
72
  if (agenticResult?.isAgentic) {
73
73
  toolUse = Math.max(toolUse, 0.6);
74
+ } else if (agenticResult && agenticResult.isAgentic === false) {
75
+ // The detector already scored this turn NON-agentic using EFFECTIVE
76
+ // tools (harness baseline subtracted, WS3.2). Attached-tool dims must
77
+ // not out-vote that verdict: a bare "Hi" from a harness with 9 default
78
+ // tools is not a tool-orchestration workload. Cap, never raise.
79
+ toolUse = Math.min(toolUse, 0.25);
74
80
  }
75
81
 
76
82
  const round3 = (v) => Math.round(_clamp01(v) * 1000) / 1000;
@@ -422,18 +422,22 @@ function extractContent(payload) {
422
422
  return '';
423
423
  }
424
424
 
425
- // Get last user message
425
+ // Get last user message. Harness envelopes (Cursor's <user_info>/<rules>/
426
+ // attached context inside the user message) are stripped so force patterns,
427
+ // risk keywords (risk-analyzer imports this) and text dims score the ASK,
428
+ // not the harness boilerplate.
429
+ const { stripHarnessEnvelope } = require('./harness-envelope');
426
430
  for (let i = payload.messages.length - 1; i >= 0; i--) {
427
431
  const msg = payload.messages[i];
428
432
  if (msg?.role === 'user') {
429
433
  if (typeof msg.content === 'string') {
430
- return msg.content;
434
+ return stripHarnessEnvelope(msg.content);
431
435
  }
432
436
  if (Array.isArray(msg.content)) {
433
- return msg.content
437
+ return stripHarnessEnvelope(msg.content
434
438
  .filter(block => block?.type === 'text')
435
439
  .map(block => block.text || '')
436
- .join(' ');
440
+ .join(' '));
437
441
  }
438
442
  }
439
443
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Harness-envelope hygiene for trigger/risk/dimension text.
3
+ *
4
+ * GUI harnesses (Cursor at minimum) deliver the user's ask embedded in a
5
+ * context envelope INSIDE the user message: <user_info>, <git_status>,
6
+ * <rules> (workspace rules — routinely contain phrases like "never expose
7
+ * API keys" or "do not deploy to production"), <agent_transcripts>, attached
8
+ * file contents. Trigger-style scanners (force patterns, risk keywords) and
9
+ * text dimensions must evaluate the ASK, not the harness's boilerplate — a
10
+ * workspace rule about keys must not make "Hi" high-risk (live incident
11
+ * 2026-09-26: routing_method=risk REASONING serve on a bare greeting).
12
+ *
13
+ * Claude Code payloads never carry these tags (its wrapper text rides in
14
+ * system-reminders, stripped elsewhere) — this is a no-op for them.
15
+ *
16
+ * Pure functions, no I/O. Never throws.
17
+ */
18
+
19
+ const ENVELOPE_TAGS = [
20
+ 'user_info',
21
+ 'git_status',
22
+ 'agent_transcripts',
23
+ 'rules',
24
+ 'always_applied_workspace_rules',
25
+ 'uuid',
26
+ 'project_layout',
27
+ 'attached_files',
28
+ 'file_contents',
29
+ 'additional_data',
30
+ 'custom_instructions',
31
+ 'linter_errors',
32
+ 'recently_viewed_files',
33
+ 'open_files',
34
+ 'user_rules',
35
+ 'memories',
36
+ 'workspace_rules',
37
+ ];
38
+
39
+ const PAIRED_RES = ENVELOPE_TAGS.map(
40
+ (t) => new RegExp(`<${t}(?:\\s[^>]*)?>[\\s\\S]*?<\\/${t}>`, 'gi')
41
+ );
42
+ // Unclosed blocks that OPEN at line start swallow to end-of-string (telemetry
43
+ // and upstream truncation cut envelopes mid-block). Mid-line opens are
44
+ // preserved — a user QUOTING a tag is content, not envelope (same rationale
45
+ // as the jev-router cleaner's line-start rule).
46
+ const UNCLOSED_RES = ENVELOPE_TAGS.map(
47
+ (t) => new RegExp(`(?:^|\\n)<${t}(?:\\s[^>]*)?>[\\s\\S]*$`, 'i')
48
+ );
49
+ const USER_QUERY_RE = /<user_query(?:\s[^>]*)?>([\s\S]*?)<\/user_query>/gi;
50
+
51
+ /**
52
+ * @param {string} text - one user message's text content
53
+ * @returns {string} the user's ask with harness envelope blocks removed;
54
+ * when the harness marks the ask explicitly (<user_query>), that wins.
55
+ */
56
+ function stripHarnessEnvelope(text) {
57
+ if (typeof text !== 'string' || text.length === 0) return typeof text === 'string' ? text : '';
58
+ try {
59
+ if (!text.includes('<')) return text;
60
+ const queries = [...text.matchAll(USER_QUERY_RE)].map((m) => m[1].trim()).filter(Boolean);
61
+ if (queries.length > 0) return queries.join(' ');
62
+ let out = text;
63
+ for (const re of PAIRED_RES) out = out.replace(re, ' ');
64
+ for (const re of UNCLOSED_RES) out = out.replace(re, ' ');
65
+ return out.replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
66
+ } catch {
67
+ return text;
68
+ }
69
+ }
70
+
71
+ module.exports = { stripHarnessEnvelope, ENVELOPE_TAGS };