lynkr 9.9.1 → 9.10.0

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.
Files changed (67) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +4 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +2 -2
  7. package/scripts/validate-difficulty-classifier.js +27 -6
  8. package/src/api/providers-handler.js +0 -1
  9. package/src/api/router.js +275 -160
  10. package/src/clients/databricks.js +95 -6
  11. package/src/config/index.js +3 -43
  12. package/src/orchestrator/index.js +117 -1235
  13. package/src/orchestrator/passthrough-stream.js +382 -0
  14. package/src/orchestrator/sse-transformer.js +408 -0
  15. package/src/routing/affinity-store.js +17 -3
  16. package/src/routing/difficulty-classifier.js +52 -10
  17. package/src/routing/index.js +35 -3
  18. package/src/routing/intent-score.js +20 -2
  19. package/src/routing/session-affinity.js +8 -1
  20. package/src/routing/side-channel-detector.js +103 -0
  21. package/src/server.js +0 -46
  22. package/src/agents/context-manager.js +0 -236
  23. package/src/agents/decomposition/dispatcher.js +0 -185
  24. package/src/agents/decomposition/gate.js +0 -136
  25. package/src/agents/decomposition/index.js +0 -183
  26. package/src/agents/decomposition/model-call.js +0 -75
  27. package/src/agents/decomposition/planner.js +0 -223
  28. package/src/agents/decomposition/synthesizer.js +0 -89
  29. package/src/agents/decomposition/telemetry.js +0 -55
  30. package/src/agents/definitions/loader.js +0 -653
  31. package/src/agents/executor.js +0 -457
  32. package/src/agents/index.js +0 -165
  33. package/src/agents/parallel-coordinator.js +0 -68
  34. package/src/agents/reflector.js +0 -331
  35. package/src/agents/skillbook.js +0 -331
  36. package/src/agents/store.js +0 -259
  37. package/src/edits/index.js +0 -171
  38. package/src/indexer/babel-parser.js +0 -213
  39. package/src/indexer/index.js +0 -1629
  40. package/src/indexer/navigation/index.js +0 -32
  41. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  42. package/src/indexer/parser.js +0 -443
  43. package/src/tasks/store.js +0 -349
  44. package/src/tests/coverage.js +0 -173
  45. package/src/tests/index.js +0 -171
  46. package/src/tests/store.js +0 -213
  47. package/src/tools/agent-task.js +0 -145
  48. package/src/tools/code-mode.js +0 -304
  49. package/src/tools/decompose.js +0 -91
  50. package/src/tools/edits.js +0 -94
  51. package/src/tools/execution.js +0 -171
  52. package/src/tools/git.js +0 -1346
  53. package/src/tools/index.js +0 -306
  54. package/src/tools/indexer.js +0 -360
  55. package/src/tools/lazy-loader.js +0 -366
  56. package/src/tools/mcp-remote.js +0 -88
  57. package/src/tools/mcp.js +0 -116
  58. package/src/tools/process.js +0 -167
  59. package/src/tools/smart-selection.js +0 -180
  60. package/src/tools/stubs.js +0 -55
  61. package/src/tools/tasks.js +0 -260
  62. package/src/tools/tests.js +0 -132
  63. package/src/tools/tinyfish.js +0 -358
  64. package/src/tools/truncate.js +0 -106
  65. package/src/tools/web-client.js +0 -71
  66. package/src/tools/web.js +0 -415
  67. package/src/tools/workspace.js +0 -204
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Phase 2b — cross-format SSE streaming transform.
3
+ *
4
+ * When the client speaks Anthropic but the tier-resolved upstream speaks
5
+ * OpenAI Chat Completions, the buffered path costs a full generation of
6
+ * latency before the client sees a single token. This module reshapes the
7
+ * upstream's SSE deltas into Anthropic Messages events in flight.
8
+ *
9
+ * The hard part is tool calls: OpenAI streams tool_calls[i].function.arguments
10
+ * as unparseable JSON slivers spread across chunks. The transformer
11
+ * accumulates fragments per tool index and emits one clean
12
+ * content_block_start + content_block_delta(input_json_delta) +
13
+ * content_block_stop per COMPLETE tool_use, at stream end — matching the
14
+ * shape Lynkr's buffered synthesis has always sent, so clients see no
15
+ * difference in event structure.
16
+ *
17
+ * Default on; kill switch LYNKR_STREAM_TRANSFORM=false. The buffered path
18
+ * stays for body.stream === false and for hallucination-recovery re-prompts
19
+ * (which require a parsed, buffered response).
20
+ */
21
+ const logger = require("../logger");
22
+
23
+ // Upstreams whose streaming wire format is OpenAI Chat Completions SSE.
24
+ // ollama is excluded (own format + buffering quirks), moonshot/zai are
25
+ // excluded (their invoke fns convert buffered responses to Anthropic).
26
+ const DEFAULT_OPENAI_SSE_PROVIDERS = [
27
+ "openai",
28
+ "azure-openai",
29
+ "openrouter",
30
+ "databricks",
31
+ "lmstudio",
32
+ "llamacpp",
33
+ ];
34
+
35
+ function _transformProviders() {
36
+ const env = process.env.LYNKR_STREAM_TRANSFORM_PROVIDERS;
37
+ return new Set(
38
+ env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_OPENAI_SSE_PROVIDERS,
39
+ );
40
+ }
41
+
42
+ function shouldTransform(clientWantsStream, provider) {
43
+ // Default ON since 2026-07-21 (E2E-verified against live upstreams);
44
+ // LYNKR_STREAM_TRANSFORM=false is the kill switch, mirroring
45
+ // LYNKR_NATIVE_PASSTHROUGH. Kill switches stay on streaming paths because
46
+ // past the first byte a stream cannot be retried — reverting behavior via
47
+ // env + restart beats reverting code during an incident.
48
+ if (process.env.LYNKR_STREAM_TRANSFORM === "false") return false;
49
+ if (clientWantsStream !== true) return false;
50
+ return _transformProviders().has(provider);
51
+ }
52
+
53
+ function _sse(event, data) {
54
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
55
+ }
56
+
57
+ function _mapFinishReason(reason) {
58
+ switch (reason) {
59
+ case "tool_calls":
60
+ case "function_call":
61
+ return "tool_use";
62
+ case "length":
63
+ return "max_tokens";
64
+ case "stop":
65
+ default:
66
+ return "end_turn";
67
+ }
68
+ }
69
+
70
+ /** Normalize a web ReadableStream or Node Readable into an async iterator of Buffers. */
71
+ async function* _iterateStream(stream) {
72
+ if (typeof stream.getReader === "function") {
73
+ const reader = stream.getReader();
74
+ try {
75
+ while (true) {
76
+ const { value, done } = await reader.read();
77
+ if (done) return;
78
+ yield value;
79
+ }
80
+ } finally {
81
+ try { reader.releaseLock(); } catch { /* released */ }
82
+ }
83
+ } else {
84
+ yield* stream;
85
+ }
86
+ }
87
+
88
+ /** Split an SSE byte stream into `data:` payload strings. */
89
+ async function* _sseDataLines(stream) {
90
+ const decoder = new TextDecoder();
91
+ let buffer = "";
92
+ for await (const chunk of _iterateStream(stream)) {
93
+ buffer += decoder.decode(chunk, { stream: true });
94
+ const lines = buffer.split("\n");
95
+ buffer = lines.pop() ?? "";
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ if (trimmed.startsWith("data:")) yield trimmed.slice(5).trim();
99
+ }
100
+ }
101
+ const trimmed = buffer.trim();
102
+ if (trimmed.startsWith("data:")) yield trimmed.slice(5).trim();
103
+ }
104
+
105
+ /**
106
+ * Core transform: OpenAI Chat Completions SSE → Anthropic Messages SSE.
107
+ * Yields Anthropic SSE event strings. `stats` accumulates for the onClose
108
+ * finalizer: tool names/arg sizes and usage, gathered DURING the stream so
109
+ * telemetry needs no second pass.
110
+ */
111
+ async function* _openaiToAnthropicEvents(upstream, opts = {}) {
112
+ const fallbackModel = opts.model || "unknown";
113
+ const stats = {
114
+ toolCalls: [], // { name, argBytes }
115
+ usage: { input_tokens: null, output_tokens: null },
116
+ stopReason: null,
117
+ };
118
+
119
+ let started = false;
120
+ let messageId = null;
121
+ let model = fallbackModel;
122
+ let nextIndex = 0;
123
+ let textIndex = null; // open text block index, null when closed
124
+ let finishReason = null;
125
+ const toolAcc = new Map(); // openai tool index -> { id, name, args }
126
+
127
+ const startMessage = function* () {
128
+ if (started) return;
129
+ started = true;
130
+ yield _sse("message_start", {
131
+ type: "message_start",
132
+ message: {
133
+ id: messageId || `msg_${Date.now()}`,
134
+ type: "message",
135
+ role: "assistant",
136
+ content: [],
137
+ model,
138
+ stop_reason: null,
139
+ stop_sequence: null,
140
+ usage: { input_tokens: 0, output_tokens: 1 },
141
+ },
142
+ });
143
+ // LYNKR_VISIBLE_ROUTING badge — a clean first text block, before any
144
+ // upstream content. Unlike the byte-pipe passthrough, the transformer
145
+ // owns the indices, so the badge slots in properly.
146
+ if (opts.badgeText) {
147
+ const index = nextIndex++;
148
+ yield _sse("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } });
149
+ yield _sse("content_block_delta", { type: "content_block_delta", index, delta: { type: "text_delta", text: opts.badgeText } });
150
+ yield _sse("content_block_stop", { type: "content_block_stop", index });
151
+ }
152
+ };
153
+
154
+ const closeTextBlock = function* () {
155
+ if (textIndex === null) return;
156
+ yield _sse("content_block_stop", { type: "content_block_stop", index: textIndex });
157
+ textIndex = null;
158
+ };
159
+
160
+ const flushToolBlocks = function* () {
161
+ for (const [, tool] of [...toolAcc.entries()].sort((a, b) => a[0] - b[0])) {
162
+ const index = nextIndex++;
163
+ const args = tool.args || "{}";
164
+ yield _sse("content_block_start", {
165
+ type: "content_block_start",
166
+ index,
167
+ content_block: {
168
+ type: "tool_use",
169
+ id: tool.id || `toolu_${Date.now()}_${index.toString(36)}`,
170
+ name: tool.name || "unknown",
171
+ input: {},
172
+ },
173
+ });
174
+ yield _sse("content_block_delta", {
175
+ type: "content_block_delta",
176
+ index,
177
+ delta: { type: "input_json_delta", partial_json: args },
178
+ });
179
+ yield _sse("content_block_stop", { type: "content_block_stop", index });
180
+ stats.toolCalls.push({ name: tool.name || "unknown", argBytes: args.length });
181
+ }
182
+ toolAcc.clear();
183
+ };
184
+
185
+ try {
186
+ for await (const payload of _sseDataLines(upstream)) {
187
+ if (payload === "[DONE]") break;
188
+
189
+ let chunk;
190
+ try {
191
+ chunk = JSON.parse(payload);
192
+ } catch {
193
+ continue; // partial/garbled chunk — skip, never crash the stream
194
+ }
195
+
196
+ if (chunk.id && !messageId) messageId = chunk.id;
197
+ if (chunk.model) model = chunk.model;
198
+ if (chunk.usage) {
199
+ if (chunk.usage.prompt_tokens != null) stats.usage.input_tokens = chunk.usage.prompt_tokens;
200
+ if (chunk.usage.completion_tokens != null) stats.usage.output_tokens = chunk.usage.completion_tokens;
201
+ }
202
+
203
+ const choice = chunk.choices?.[0];
204
+ if (!choice) continue;
205
+ if (choice.finish_reason) finishReason = choice.finish_reason;
206
+ const delta = choice.delta || {};
207
+
208
+ yield* startMessage();
209
+
210
+ // Text deltas are straightforward: open a block on first text, then
211
+ // emit a text_delta per chunk.
212
+ if (typeof delta.content === "string" && delta.content.length > 0) {
213
+ if (textIndex === null) {
214
+ textIndex = nextIndex++;
215
+ yield _sse("content_block_start", {
216
+ type: "content_block_start",
217
+ index: textIndex,
218
+ content_block: { type: "text", text: "" },
219
+ });
220
+ }
221
+ yield _sse("content_block_delta", {
222
+ type: "content_block_delta",
223
+ index: textIndex,
224
+ delta: { type: "text_delta", text: delta.content },
225
+ });
226
+ }
227
+
228
+ // Tool-call fragments: accumulate per OpenAI tool index. Fragments of
229
+ // function.arguments are NOT parseable individually — only the full
230
+ // concatenation is valid JSON, so blocks are emitted at stream end.
231
+ if (Array.isArray(delta.tool_calls)) {
232
+ yield* closeTextBlock();
233
+ for (const tc of delta.tool_calls) {
234
+ const idx = tc.index ?? 0;
235
+ if (!toolAcc.has(idx)) toolAcc.set(idx, { id: null, name: null, args: "" });
236
+ const acc = toolAcc.get(idx);
237
+ if (tc.id) acc.id = tc.id;
238
+ if (tc.function?.name) acc.name = (acc.name || "") + tc.function.name;
239
+ if (typeof tc.function?.arguments === "string") acc.args += tc.function.arguments;
240
+ }
241
+ }
242
+ }
243
+ } catch (err) {
244
+ logger.warn({ err: err.message }, "[SSETransform] Upstream stream failed mid-flight");
245
+ yield* startMessage();
246
+ yield* closeTextBlock();
247
+ yield _sse("error", {
248
+ type: "error",
249
+ error: { type: "overloaded_error", message: `Lynkr: upstream stream failed — retry (${err.message})` },
250
+ });
251
+ stats.stopReason = "stream_error";
252
+ if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
253
+ return;
254
+ }
255
+
256
+ // Normal end of stream ([DONE] or upstream EOF).
257
+ yield* startMessage();
258
+ yield* closeTextBlock();
259
+ yield* flushToolBlocks();
260
+
261
+ const stopReason = _mapFinishReason(finishReason);
262
+ stats.stopReason = stopReason;
263
+ yield _sse("message_delta", {
264
+ type: "message_delta",
265
+ delta: { stop_reason: stopReason, stop_sequence: null },
266
+ usage: { output_tokens: stats.usage.output_tokens ?? 0 },
267
+ });
268
+ yield _sse("message_stop", { type: "message_stop" });
269
+
270
+ if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
271
+ }
272
+
273
+ /**
274
+ * OpenAI SSE → Anthropic SSE. Returns a web ReadableStream (exposes
275
+ * getReader(), matching what the router's stream-forwarding path expects).
276
+ */
277
+ function openaiToAnthropicSSE(upstream, opts = {}) {
278
+ const generator = _openaiToAnthropicEvents(upstream, opts);
279
+ const encoder = new TextEncoder();
280
+ return new ReadableStream({
281
+ async pull(controller) {
282
+ try {
283
+ const { value, done } = await generator.next();
284
+ if (done) {
285
+ controller.close();
286
+ return;
287
+ }
288
+ controller.enqueue(encoder.encode(value));
289
+ } catch (err) {
290
+ controller.error(err);
291
+ }
292
+ },
293
+ async cancel() {
294
+ try { await generator.return(); } catch { /* already finished */ }
295
+ },
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Anthropic SSE → OpenAI SSE. The symmetric direction — unused by Lynkr's
301
+ * current routes (OpenAI-speaking clients get buffered conversion) but kept
302
+ * so both directions live and are tested together.
303
+ */
304
+ async function* _anthropicToOpenaiEvents(upstream, opts = {}) {
305
+ const model = opts.model || "unknown";
306
+ let id = `chatcmpl_${Date.now()}`;
307
+ let usage = null;
308
+ let stopReason = null;
309
+ const blockTypes = new Map(); // anthropic block index -> type
310
+ let toolOrdinal = -1; // openai tool_calls array index
311
+
312
+ const chunk = (delta, finish = null) => ({
313
+ id,
314
+ object: "chat.completion.chunk",
315
+ model,
316
+ choices: [{ index: 0, delta, finish_reason: finish }],
317
+ ...(usage && finish ? { usage } : {}),
318
+ });
319
+
320
+ for await (const payload of _sseDataLines(upstream)) {
321
+ let evt;
322
+ try {
323
+ evt = JSON.parse(payload);
324
+ } catch {
325
+ continue;
326
+ }
327
+
328
+ switch (evt.type) {
329
+ case "message_start":
330
+ if (evt.message?.id) id = evt.message.id;
331
+ if (evt.message?.usage?.input_tokens != null) {
332
+ usage = { prompt_tokens: evt.message.usage.input_tokens, completion_tokens: 0, total_tokens: evt.message.usage.input_tokens };
333
+ }
334
+ yield `data: ${JSON.stringify(chunk({ role: "assistant", content: "" }))}\n\n`;
335
+ break;
336
+ case "content_block_start":
337
+ blockTypes.set(evt.index, evt.content_block?.type);
338
+ if (evt.content_block?.type === "tool_use") {
339
+ toolOrdinal += 1;
340
+ yield `data: ${JSON.stringify(chunk({
341
+ tool_calls: [{
342
+ index: toolOrdinal,
343
+ id: evt.content_block.id,
344
+ type: "function",
345
+ function: { name: evt.content_block.name, arguments: "" },
346
+ }],
347
+ }))}\n\n`;
348
+ }
349
+ break;
350
+ case "content_block_delta":
351
+ if (evt.delta?.type === "text_delta") {
352
+ yield `data: ${JSON.stringify(chunk({ content: evt.delta.text }))}\n\n`;
353
+ } else if (evt.delta?.type === "input_json_delta") {
354
+ yield `data: ${JSON.stringify(chunk({
355
+ tool_calls: [{ index: toolOrdinal, function: { arguments: evt.delta.partial_json } }],
356
+ }))}\n\n`;
357
+ }
358
+ break;
359
+ case "message_delta":
360
+ if (evt.delta?.stop_reason) stopReason = evt.delta.stop_reason;
361
+ if (evt.usage?.output_tokens != null) {
362
+ usage = usage || { prompt_tokens: 0 };
363
+ usage.completion_tokens = evt.usage.output_tokens;
364
+ usage.total_tokens = (usage.prompt_tokens || 0) + evt.usage.output_tokens;
365
+ }
366
+ break;
367
+ case "message_stop": {
368
+ const finish = stopReason === "tool_use" ? "tool_calls" : stopReason === "max_tokens" ? "length" : "stop";
369
+ yield `data: ${JSON.stringify(chunk({}, finish))}\n\n`;
370
+ yield "data: [DONE]\n\n";
371
+ break;
372
+ }
373
+ default:
374
+ break; // ping, content_block_stop — no OpenAI equivalent needed
375
+ }
376
+ }
377
+ }
378
+
379
+ function anthropicToOpenaiSSE(upstream, opts = {}) {
380
+ const generator = _anthropicToOpenaiEvents(upstream, opts);
381
+ const encoder = new TextEncoder();
382
+ return new ReadableStream({
383
+ async pull(controller) {
384
+ try {
385
+ const { value, done } = await generator.next();
386
+ if (done) {
387
+ controller.close();
388
+ return;
389
+ }
390
+ controller.enqueue(encoder.encode(value));
391
+ } catch (err) {
392
+ controller.error(err);
393
+ }
394
+ },
395
+ async cancel() {
396
+ try { await generator.return(); } catch { /* already finished */ }
397
+ },
398
+ });
399
+ }
400
+
401
+ module.exports = {
402
+ shouldTransform,
403
+ openaiToAnthropicSSE,
404
+ anthropicToOpenaiSSE,
405
+ // Exported for unit tests.
406
+ _openaiToAnthropicEvents,
407
+ _anthropicToOpenaiEvents,
408
+ };
@@ -49,6 +49,13 @@ function _db() {
49
49
  if (!cols.has("score")) {
50
50
  db.exec("ALTER TABLE session_pins ADD COLUMN score REAL");
51
51
  }
52
+ // Additive migration for the side-channel detector's Signal 2 —
53
+ // once a session has ever carried tool_use/tool_result blocks, this
54
+ // flag stays 1 for the pin's lifetime. Payloads that arrive later
55
+ // without tool blocks in a flagged session are side-channel replays.
56
+ if (!cols.has("has_tool_history")) {
57
+ db.exec("ALTER TABLE session_pins ADD COLUMN has_tool_history INTEGER DEFAULT 0");
58
+ }
52
59
  schemaEnsured = true;
53
60
  } catch (err) {
54
61
  degradation.record("feedback", err);
@@ -81,7 +88,7 @@ function load(sessionId, ttlMs) {
81
88
  const row = _stmt(
82
89
  db,
83
90
  "load",
84
- "SELECT provider, model, tier, score, message_count, prompt_tokens_est, ts FROM session_pins WHERE session_id = ?"
91
+ "SELECT provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts FROM session_pins WHERE session_id = ?"
85
92
  ).get(sessionId);
86
93
  if (!row) return null;
87
94
  if (ttlMs && Date.now() - row.ts > ttlMs) {
@@ -98,6 +105,7 @@ function load(sessionId, ttlMs) {
98
105
  score: row.score,
99
106
  messageCount: row.message_count,
100
107
  promptTokensEst: row.prompt_tokens_est,
108
+ hasToolHistory: !!row.has_tool_history,
101
109
  ts: row.ts,
102
110
  };
103
111
  } catch (err) {
@@ -121,8 +129,12 @@ function save(sessionId, pin) {
121
129
  _stmt(
122
130
  db,
123
131
  "upsert",
124
- `INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, ts)
125
- VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @ts)
132
+ // has_tool_history is sticky-true: once the session has ever carried
133
+ // tool blocks it stays flagged for the pin's lifetime. Use MAX so an
134
+ // update from a tool-less request (e.g. compaction refresh) can never
135
+ // clear the flag once set.
136
+ `INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts)
137
+ VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @has_tool_history, @ts)
126
138
  ON CONFLICT(session_id) DO UPDATE SET
127
139
  provider = excluded.provider,
128
140
  model = excluded.model,
@@ -130,6 +142,7 @@ function save(sessionId, pin) {
130
142
  score = excluded.score,
131
143
  message_count = excluded.message_count,
132
144
  prompt_tokens_est = excluded.prompt_tokens_est,
145
+ has_tool_history = MAX(has_tool_history, excluded.has_tool_history),
133
146
  ts = excluded.ts`
134
147
  ).run({
135
148
  session_id: sessionId,
@@ -139,6 +152,7 @@ function save(sessionId, pin) {
139
152
  score: typeof pin.score === 'number' ? pin.score : null,
140
153
  message_count: pin.messageCount ?? null,
141
154
  prompt_tokens_est: pin.promptTokensEst ?? null,
155
+ has_tool_history: pin.hasToolHistory ? 1 : 0,
142
156
  ts: pin.ts ?? Date.now(),
143
157
  });
144
158
  } catch (err) {
@@ -44,23 +44,54 @@ const CLASSIFIER_MODEL = 'qwen2.5:3b';
44
44
 
45
45
  const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
46
46
 
47
- // One-shot classification prompt. Kept in a const so drift is diffable.
47
+ // One-shot classification prompt (v2). Kept in a const so drift is diffable.
48
48
  // Difficulty framing (not intent) — matches config B routing goals.
49
- const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the user prompt below into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
49
+ //
50
+ // v2 (2026-07-21): added the follow-up rule, negative examples under
51
+ // REASONING, and casual-question SIMPLE examples after a live incident:
52
+ // qwen2.5:3b read "Who kills him ?" (a Doctor Doom plot question) as
53
+ // REASONING conf 1.0 — v1's REASONING examples were all formal-methods
54
+ // flavored and nothing said surface vocabulary isn't the signal. Baseline
55
+ // on data/difficulty-eval-followups.jsonl: 60% overall, 33% on SIMPLE,
56
+ // 3 SIMPLE→REASONING criticals.
57
+ const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the CURRENT user prompt into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
50
58
 
51
59
  Tiers:
52
- - SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups. Any tiny model handles.
53
- examples: "hi", "ok thanks", "yes continue", "what time is it"
60
+ - SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups, and short conversational follow-up questions about people, stories, events, or everyday facts. Any tiny model handles.
61
+ examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?"
54
62
  - MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
55
- examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block"
63
+ examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block", "verify the file exists before reading it"
56
64
  - COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
57
65
  examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
58
66
  - REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
59
67
  examples: "prove the correctness of this lock-free queue", "security audit the auth middleware", "formally verify this state machine never deadlocks", "derive the optimal eviction policy and prove its competitive ratio"
68
+ NOT reasoning: casual questions that merely contain words like "prove", "kill", "verify", "audit" — "who kills him?" is SIMPLE, "prove me wrong lol" is SIMPLE, "can you verify the score?" is SIMPLE, "verify the file exists" is MEDIUM.
69
+
70
+ Rules:
71
+ - Judge the TASK the model must perform, not the vocabulary. Words like kill/prove/verify/audit/security do not make a prompt REASONING unless it demands formal or expert-level analysis.
72
+ - A short follow-up question (pronouns like he/she/it/that referring to the earlier conversation) about a casual topic is SIMPLE. A follow-up that extends a technical task inherits the difficulty of that task.
60
73
 
61
74
  Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
75
+ `;
76
+
77
+ // Short prompts are where context matters: "Who kills him ?" is
78
+ // unclassifiable in isolation but trivially SIMPLE next to its conversation.
79
+ // Long prompts self-describe, and contextualizing them would only shrink the
80
+ // LRU hit rate and grow latency.
81
+ const CONTEXT_MAX_TEXT_LENGTH = 40;
82
+ const CONTEXT_MAX_CHARS = 300;
83
+
84
+ function _buildPrompt(text, context) {
85
+ if (context) {
86
+ return `${CLASSIFY_PROMPT}
87
+ Conversation so far (context only — classify the CURRENT prompt, inheriting topic difficulty per the rules):
88
+ ${context}
62
89
 
63
- User prompt: `;
90
+ CURRENT user prompt: """${text}"""`;
91
+ }
92
+ return `${CLASSIFY_PROMPT}
93
+ User prompt: """${text}"""`;
94
+ }
64
95
 
65
96
  // --- LRU cache --------------------------------------------------------------
66
97
 
@@ -95,7 +126,7 @@ function _cacheKey(text) {
95
126
 
96
127
  // --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
97
128
 
98
- async function _callOllama(text, opts) {
129
+ async function _callOllama(text, context, opts) {
99
130
  const config = require('../config');
100
131
 
101
132
  // First cut supports ollama-family classifier only. Other providers can
@@ -109,7 +140,7 @@ async function _callOllama(text, opts) {
109
140
  const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
110
141
  const body = {
111
142
  model: CLASSIFIER_MODEL,
112
- messages: [{ role: 'user', content: CLASSIFY_PROMPT + '"""' + text + '"""' }],
143
+ messages: [{ role: 'user', content: _buildPrompt(text, context) }],
113
144
  stream: false,
114
145
  format: 'json',
115
146
  // num_predict generous because thinking models (minimax-m2.5) burn
@@ -171,6 +202,9 @@ function _parseResult(raw) {
171
202
  * @param {number} [opts.timeoutMs] — override default 2500ms
172
203
  * @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
173
204
  * @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
205
+ * @param {string} [opts.context] — condensed prior conversation ("user asked:
206
+ * ... → assistant replied about: ..."); used only for short prompts, where
207
+ * a bare follow-up is unclassifiable in isolation
174
208
  * @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
175
209
  * null when: disabled, skipped, model failure, parse failure, timeout.
176
210
  */
@@ -182,11 +216,18 @@ async function classifyDifficulty(text, opts = {}) {
182
216
  if (opts.forceMatched) return null;
183
217
  if (opts.riskLevel === 'high') return null;
184
218
 
185
- const key = _cacheKey(trimmed);
219
+ const context =
220
+ typeof opts.context === 'string' && opts.context.trim() && trimmed.length <= CONTEXT_MAX_TEXT_LENGTH
221
+ ? opts.context.trim().slice(0, CONTEXT_MAX_CHARS)
222
+ : null;
223
+
224
+ // Context participates in the cache key: the same follow-up text means
225
+ // different things in different conversations.
226
+ const key = _cacheKey(context ? `${trimmed}${context}` : trimmed);
186
227
  const cached = _cache.get(key);
187
228
  if (cached) return { ...cached, source: 'cache' };
188
229
 
189
- const raw = await _callOllama(trimmed, opts);
230
+ const raw = await _callOllama(trimmed, context, opts);
190
231
  const parsed = _parseResult(raw);
191
232
  if (!parsed) return null;
192
233
  _cache.set(key, parsed);
@@ -214,6 +255,7 @@ module.exports = {
214
255
  // internals for tests only
215
256
  _parseResult,
216
257
  _cacheKey,
258
+ _buildPrompt,
217
259
  _clearCacheForTests,
218
260
  _getCacheStats,
219
261
  };
@@ -584,6 +584,8 @@ function checkSessionPin(payload, options = {}) {
584
584
  // the new_conversation guard for everyone after it.
585
585
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
586
586
  promptTokensEst: pin.promptTokensEst,
587
+ // We're inside payloadHasToolHistory(payload) — session has committed.
588
+ hasToolHistory: true,
587
589
  });
588
590
  }
589
591
  return { serve: true, pin, reason: 'tool_history', sessionId };
@@ -599,11 +601,30 @@ function checkSessionPin(payload, options = {}) {
599
601
  sessionAffinity.setPin(sessionId, pin, {
600
602
  messageCount: Math.max(messageCount, pin.messageCount ?? 0),
601
603
  promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
604
+ // setPin OR-merges with the existing pin's flag, so a plain-text turn
605
+ // between tool exchanges keeps the sticky-true property.
606
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
602
607
  });
603
608
  }
604
609
  return { serve: true, pin, reason: 'guards_passed', sessionId };
605
610
  }
606
611
 
612
+ /** Whole-payload scan for tool_use/tool_result blocks. Unlike
613
+ * sessionAffinity.payloadHasToolHistory (last message only), this checks
614
+ * every message so we correctly flag sessions where the last frame is a
615
+ * plain text turn but earlier turns carried tools. */
616
+ function _payloadHasAnyToolBlocks(payload) {
617
+ const msgs = payload?.messages;
618
+ if (!Array.isArray(msgs)) return false;
619
+ for (const m of msgs) {
620
+ if (!Array.isArray(m?.content)) continue;
621
+ for (const b of m.content) {
622
+ if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
623
+ }
624
+ }
625
+ return false;
626
+ }
627
+
607
628
  /**
608
629
  * Write-through helper: persist a fresh routing decision as the session's
609
630
  * new pin. No-op when sessionId is missing or the decision has no provider.
@@ -642,7 +663,11 @@ function writeSessionPin(sessionId, decision, payload) {
642
663
  return;
643
664
  }
644
665
  const promptTokensEst = _tryCountTokens(payload, decision.model);
645
- sessionAffinity.setPin(sessionId, decision, { messageCount, promptTokensEst });
666
+ sessionAffinity.setPin(sessionId, decision, {
667
+ messageCount,
668
+ promptTokensEst,
669
+ hasToolHistory: _payloadHasAnyToolBlocks(payload),
670
+ });
646
671
  }
647
672
 
648
673
  async function _determineProviderSmartInner(payload, options = {}) {
@@ -1088,10 +1113,17 @@ async function _determineProviderSmartInner(payload, options = {}) {
1088
1113
 
1089
1114
  // Phase 1.2 — cost-optimizer override.
1090
1115
  // Only kick in when:
1091
- // - feature flag enabled (default true, disable with LYNKR_COST_OPTIMIZE=false)
1116
+ // - feature flag EXPLICITLY enabled with LYNKR_COST_OPTIMIZE=true (default OFF)
1092
1117
  // - risk level is not high (high-risk keeps the explicitly-configured model)
1093
1118
  // - the optimizer finds a meaningfully cheaper qualifying model
1094
- const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE !== 'false'
1119
+ //
1120
+ // Default changed to OFF (2026-07-20): the optimizer silently overrides
1121
+ // explicit TIER_* config with the cheapest model in the tier pool, which
1122
+ // surprised operators who expected their tier settings to be authoritative
1123
+ // (e.g. TIER_REASONING=azure-anthropic:claude-opus-4.7 was being swapped
1124
+ // for openrouter:deepseek/deepseek-reasoner — which was also a typo that
1125
+ // 400'd upstream). Opt-in only.
1126
+ const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE === 'true'
1095
1127
  && config.routing?.costOptimize !== false;
1096
1128
  if (costOptimizeEnabled && risk?.level !== 'high') {
1097
1129
  try {