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.
- package/config/difficulty-anchors.json +12 -0
- package/package.json +2 -2
- package/scripts/harvest-followup-eval.js +87 -0
- package/src/api/router.js +344 -39
- package/src/cache/embeddings.js +30 -3
- package/src/clients/cursor-utils.js +791 -0
- package/src/clients/databricks.js +136 -4
- package/src/clients/health-probe.js +10 -0
- package/src/config/index.js +10 -0
- package/src/orchestrator/index.js +8 -0
- package/src/orchestrator/sse-transformer.js +37 -3
- package/src/routing/affinity-store.js +136 -1
- package/src/routing/capabilities.js +6 -0
- package/src/routing/complexity-analyzer.js +8 -4
- package/src/routing/harness-envelope.js +71 -0
- package/src/routing/index.js +98 -13
- package/src/routing/intent-score.js +108 -75
- package/src/routing/jev-router.js +131 -25
- package/src/routing/model-registry.js +6 -3
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/passthrough-route.js +20 -1
- package/src/routing/risk-analyzer.js +8 -1
- package/src/routing/shortfall.js +12 -6
- package/src/routing/task-ledger.js +443 -0
- package/src/routing/telemetry.js +44 -2
- package/src/routing/vision.js +1 -1
|
@@ -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
|
-
|
|
3472
|
-
|
|
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' &&
|
|
3612
|
+
if (b?.type === 'text' && typeof b.text === 'string' && _stripBadgeText(b.text) !== b.text) {
|
|
3491
3613
|
changed = true;
|
|
3492
|
-
const strippedText = b.text
|
|
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
|
/**
|
package/src/config/index.js
CHANGED
|
@@ -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).
|
|
29
|
-
//
|
|
30
|
-
//
|
|
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 = {
|
|
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 };
|