lynkr 9.9.1 → 9.11.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 (93) hide show
  1. package/README.md +50 -9
  2. package/bin/cli.js +2 -0
  3. package/bin/lynkr-init.js +11 -12
  4. package/bin/lynkr-reset.js +71 -0
  5. package/config/model-tiers.json +199 -52
  6. package/package.json +3 -3
  7. package/public/dashboard.html +29 -0
  8. package/scripts/validate-difficulty-classifier.js +27 -6
  9. package/src/api/providers-handler.js +0 -1
  10. package/src/api/router.js +309 -160
  11. package/src/clients/databricks.js +215 -71
  12. package/src/clients/gpt-utils.js +8 -1
  13. package/src/clients/ollama-utils.js +19 -10
  14. package/src/clients/prompt-cache-injection.js +93 -30
  15. package/src/config/index.js +82 -43
  16. package/src/context/compression.js +27 -8
  17. package/src/context/distill.js +2 -1
  18. package/src/dashboard/api.js +5 -1
  19. package/src/memory/distiller.js +350 -0
  20. package/src/memory/extractor.js +0 -8
  21. package/src/memory/format.js +17 -2
  22. package/src/memory/index.js +10 -0
  23. package/src/memory/retriever.js +7 -2
  24. package/src/memory/skills-cache.js +180 -0
  25. package/src/memory/tencentdb-launcher.js +459 -0
  26. package/src/memory/wiki.js +171 -0
  27. package/src/orchestrator/index.js +202 -1237
  28. package/src/orchestrator/passthrough-stream.js +382 -0
  29. package/src/orchestrator/sse-transformer.js +408 -0
  30. package/src/prompts/system.js +20 -6
  31. package/src/routing/affinity-store.js +85 -4
  32. package/src/routing/agentic-detector.js +9 -1
  33. package/src/routing/cache-economics.js +123 -0
  34. package/src/routing/cache-switch-cost.js +194 -0
  35. package/src/routing/calibration.js +8 -1
  36. package/src/routing/complexity-analyzer.js +7 -1
  37. package/src/routing/context-compressor.js +170 -0
  38. package/src/routing/difficulty-classifier.js +161 -17
  39. package/src/routing/index.js +196 -56
  40. package/src/routing/intent-score.js +20 -2
  41. package/src/routing/model-registry.js +35 -10
  42. package/src/routing/session-affinity.js +68 -1
  43. package/src/routing/side-channel-detector.js +103 -0
  44. package/src/routing/telemetry.js +139 -2
  45. package/src/routing/verifier.js +41 -2
  46. package/src/server.js +36 -49
  47. package/src/tools/code-graph.js +6 -4
  48. package/src/agents/context-manager.js +0 -236
  49. package/src/agents/decomposition/dispatcher.js +0 -185
  50. package/src/agents/decomposition/gate.js +0 -136
  51. package/src/agents/decomposition/index.js +0 -183
  52. package/src/agents/decomposition/model-call.js +0 -75
  53. package/src/agents/decomposition/planner.js +0 -223
  54. package/src/agents/decomposition/synthesizer.js +0 -89
  55. package/src/agents/decomposition/telemetry.js +0 -55
  56. package/src/agents/definitions/loader.js +0 -653
  57. package/src/agents/executor.js +0 -457
  58. package/src/agents/index.js +0 -165
  59. package/src/agents/parallel-coordinator.js +0 -68
  60. package/src/agents/reflector.js +0 -331
  61. package/src/agents/skillbook.js +0 -331
  62. package/src/agents/store.js +0 -259
  63. package/src/edits/index.js +0 -171
  64. package/src/indexer/babel-parser.js +0 -213
  65. package/src/indexer/index.js +0 -1629
  66. package/src/indexer/navigation/index.js +0 -32
  67. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  68. package/src/indexer/parser.js +0 -443
  69. package/src/tasks/store.js +0 -349
  70. package/src/tests/coverage.js +0 -173
  71. package/src/tests/index.js +0 -171
  72. package/src/tests/store.js +0 -213
  73. package/src/tools/agent-task.js +0 -145
  74. package/src/tools/code-mode.js +0 -304
  75. package/src/tools/decompose.js +0 -91
  76. package/src/tools/edits.js +0 -94
  77. package/src/tools/execution.js +0 -171
  78. package/src/tools/git.js +0 -1346
  79. package/src/tools/index.js +0 -306
  80. package/src/tools/indexer.js +0 -360
  81. package/src/tools/lazy-loader.js +0 -366
  82. package/src/tools/mcp-remote.js +0 -88
  83. package/src/tools/mcp.js +0 -116
  84. package/src/tools/process.js +0 -167
  85. package/src/tools/smart-selection.js +0 -180
  86. package/src/tools/stubs.js +0 -55
  87. package/src/tools/tasks.js +0 -260
  88. package/src/tools/tests.js +0 -132
  89. package/src/tools/tinyfish.js +0 -358
  90. package/src/tools/truncate.js +0 -106
  91. package/src/tools/web-client.js +0 -71
  92. package/src/tools/web.js +0 -415
  93. 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
+ };
@@ -70,13 +70,27 @@ function compressToolDescriptions(tools, mode = null) {
70
70
  return tools; // Return unmodified if not in minimal mode
71
71
  }
72
72
 
73
+ // Normalize to Anthropic shape: OpenAI-format callers (OpenWorker, aisuite,
74
+ // any OpenAI SDK) send {type:"function", function:{name, description, parameters}}.
75
+ // Unwrap once here so the compression logic below can assume input_schema exists.
76
+ tools = tools.map(tool =>
77
+ tool?.type === "function" && tool.function
78
+ ? {
79
+ name: tool.function.name,
80
+ description: tool.function.description,
81
+ input_schema: tool.function.parameters || { type: "object", properties: {} },
82
+ }
83
+ : tool
84
+ );
85
+
73
86
  return tools.map(tool => {
87
+ const input_schema = tool.input_schema || { type: "object", properties: {} };
74
88
  const compressed = {
75
89
  name: tool.name,
76
90
  input_schema: {
77
- type: tool.input_schema.type,
91
+ type: input_schema.type || "object",
78
92
  properties: {},
79
- required: tool.input_schema.required || [],
93
+ required: input_schema.required || [],
80
94
  }
81
95
  };
82
96
 
@@ -86,8 +100,8 @@ function compressToolDescriptions(tools, mode = null) {
86
100
  }
87
101
 
88
102
  // Compress property descriptions
89
- if (tool.input_schema.properties) {
90
- for (const [key, value] of Object.entries(tool.input_schema.properties)) {
103
+ if (input_schema.properties) {
104
+ for (const [key, value] of Object.entries(input_schema.properties)) {
91
105
  compressed.input_schema.properties[key] = {
92
106
  type: value.type,
93
107
  };
@@ -108,8 +122,8 @@ function compressToolDescriptions(tools, mode = null) {
108
122
  }
109
123
 
110
124
  // Preserve additionalProperties if set
111
- if (tool.input_schema.additionalProperties !== undefined) {
112
- compressed.input_schema.additionalProperties = tool.input_schema.additionalProperties;
125
+ if (input_schema.additionalProperties !== undefined) {
126
+ compressed.input_schema.additionalProperties = input_schema.additionalProperties;
113
127
  }
114
128
 
115
129
  return compressed;
@@ -49,6 +49,19 @@ 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
+ }
59
+ // Additive migration for cache-aware routing (Phase 1): JSON blob
60
+ // holding {warmPrefixTokens, provider, model, lastRequestAt, ttlMs},
61
+ // updated after every upstream response that reports cache usage.
62
+ if (!cols.has("cache_state")) {
63
+ db.exec("ALTER TABLE session_pins ADD COLUMN cache_state TEXT");
64
+ }
52
65
  schemaEnsured = true;
53
66
  } catch (err) {
54
67
  degradation.record("feedback", err);
@@ -81,7 +94,7 @@ function load(sessionId, ttlMs) {
81
94
  const row = _stmt(
82
95
  db,
83
96
  "load",
84
- "SELECT provider, model, tier, score, message_count, prompt_tokens_est, ts FROM session_pins WHERE session_id = ?"
97
+ "SELECT provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts FROM session_pins WHERE session_id = ?"
85
98
  ).get(sessionId);
86
99
  if (!row) return null;
87
100
  if (ttlMs && Date.now() - row.ts > ttlMs) {
@@ -98,6 +111,7 @@ function load(sessionId, ttlMs) {
98
111
  score: row.score,
99
112
  messageCount: row.message_count,
100
113
  promptTokensEst: row.prompt_tokens_est,
114
+ hasToolHistory: !!row.has_tool_history,
101
115
  ts: row.ts,
102
116
  };
103
117
  } catch (err) {
@@ -121,8 +135,12 @@ function save(sessionId, pin) {
121
135
  _stmt(
122
136
  db,
123
137
  "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)
138
+ // has_tool_history is sticky-true: once the session has ever carried
139
+ // tool blocks it stays flagged for the pin's lifetime. Use MAX so an
140
+ // update from a tool-less request (e.g. compaction refresh) can never
141
+ // clear the flag once set.
142
+ `INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts)
143
+ VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @has_tool_history, @ts)
126
144
  ON CONFLICT(session_id) DO UPDATE SET
127
145
  provider = excluded.provider,
128
146
  model = excluded.model,
@@ -130,6 +148,7 @@ function save(sessionId, pin) {
130
148
  score = excluded.score,
131
149
  message_count = excluded.message_count,
132
150
  prompt_tokens_est = excluded.prompt_tokens_est,
151
+ has_tool_history = MAX(has_tool_history, excluded.has_tool_history),
133
152
  ts = excluded.ts`
134
153
  ).run({
135
154
  session_id: sessionId,
@@ -139,6 +158,7 @@ function save(sessionId, pin) {
139
158
  score: typeof pin.score === 'number' ? pin.score : null,
140
159
  message_count: pin.messageCount ?? null,
141
160
  prompt_tokens_est: pin.promptTokensEst ?? null,
161
+ has_tool_history: pin.hasToolHistory ? 1 : 0,
142
162
  ts: pin.ts ?? Date.now(),
143
163
  });
144
164
  } catch (err) {
@@ -146,6 +166,67 @@ function save(sessionId, pin) {
146
166
  }
147
167
  }
148
168
 
169
+ /**
170
+ * Persist per-session prompt-cache state (Phase 1, cache-aware routing).
171
+ * Piggybacks on the session_pins row; creates a minimal row when the session
172
+ * has no pin yet (possible when sticky sessions are disabled but tracking
173
+ * is on). Best-effort like everything else in this module.
174
+ *
175
+ * @param {string} sessionId
176
+ * @param {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}} state
177
+ */
178
+ function saveCacheState(sessionId, state) {
179
+ if (!sessionId || !state?.provider) return;
180
+ const db = _db();
181
+ if (!db) return;
182
+ try {
183
+ const json = JSON.stringify(state);
184
+ const res = _stmt(
185
+ db,
186
+ "cache_state_update",
187
+ "UPDATE session_pins SET cache_state = ? WHERE session_id = ?"
188
+ ).run(json, sessionId);
189
+ if (res.changes === 0) {
190
+ _stmt(
191
+ db,
192
+ "cache_state_insert",
193
+ `INSERT INTO session_pins (session_id, provider, model, ts, cache_state)
194
+ VALUES (?, ?, ?, ?, ?)
195
+ ON CONFLICT(session_id) DO UPDATE SET cache_state = excluded.cache_state`
196
+ ).run(sessionId, state.provider, state.model ?? null, Date.now(), json);
197
+ }
198
+ } catch (err) {
199
+ degradation.record("feedback", err);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Load per-session cache state. Returns null when absent, unparsable, or the
205
+ * DB is unavailable — callers treat null as "no cache signal for this
206
+ * session" (provider doesn't report cache usage, or no response seen yet).
207
+ *
208
+ * @param {string} sessionId
209
+ * @returns {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}|null}
210
+ */
211
+ function loadCacheState(sessionId) {
212
+ if (!sessionId) return null;
213
+ const db = _db();
214
+ if (!db) return null;
215
+ try {
216
+ const row = _stmt(
217
+ db,
218
+ "cache_state_load",
219
+ "SELECT cache_state FROM session_pins WHERE session_id = ?"
220
+ ).get(sessionId);
221
+ if (!row?.cache_state) return null;
222
+ const parsed = JSON.parse(row.cache_state);
223
+ return parsed && typeof parsed === "object" ? parsed : null;
224
+ } catch (err) {
225
+ degradation.record("feedback", err);
226
+ return null;
227
+ }
228
+ }
229
+
149
230
  /**
150
231
  * Remove a pin.
151
232
  * @param {string} sessionId
@@ -191,4 +272,4 @@ function _clear() {
191
272
  } catch { /* best-effort */ }
192
273
  }
193
274
 
194
- module.exports = { load, save, remove, cleanup, _clear };
275
+ module.exports = { load, save, remove, cleanup, saveCacheState, loadCacheState, _clear };
@@ -224,13 +224,21 @@ class AgenticDetector {
224
224
  const agentType = this._classifyAgentType(score, signals);
225
225
  const isAgentic = score >= 25;
226
226
 
227
+ // AGENTIC_DISABLE_FLOOR=1 bypasses the per-agent-type minTier — the raw
228
+ // complexity score decides the tier without the tools-based bump. Useful
229
+ // for demos/tests where tool-heavy clients (OpenWorker, aisuite) send 25+
230
+ // tools on every request and otherwise floor everything to MEDIUM+.
231
+ const flooredMinTier = process.env.AGENTIC_DISABLE_FLOOR === '1'
232
+ ? 'SIMPLE'
233
+ : AGENT_TYPES[agentType].minTier;
234
+
227
235
  const result = {
228
236
  isAgentic,
229
237
  agentType,
230
238
  confidence: Math.min(score / 100, 1),
231
239
  score,
232
240
  signals,
233
- minTier: AGENT_TYPES[agentType].minTier,
241
+ minTier: flooredMinTier,
234
242
  scoreBoost: AGENT_TYPES[agentType].scoreBoost,
235
243
  description: AGENT_TYPES[agentType].description,
236
244
  clientProfile: profile?.name ?? null,