lynkr 9.9.0 → 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 (73) hide show
  1. package/README.md +92 -23
  2. package/bin/cli.js +13 -7
  3. package/bin/lynkr-init.js +34 -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/scripts/build-eval-set.js +256 -0
  8. package/scripts/mine-difficulty-anchors.js +288 -0
  9. package/scripts/validate-difficulty-classifier.js +144 -0
  10. package/scripts/validate-intent-anchors.js +186 -0
  11. package/src/api/providers-handler.js +0 -1
  12. package/src/api/router.js +292 -160
  13. package/src/clients/databricks.js +95 -6
  14. package/src/config/index.js +3 -43
  15. package/src/context/tool-result-compressor.js +883 -40
  16. package/src/orchestrator/index.js +117 -1235
  17. package/src/orchestrator/passthrough-stream.js +382 -0
  18. package/src/orchestrator/sse-transformer.js +408 -0
  19. package/src/routing/affinity-store.js +17 -3
  20. package/src/routing/classifier-setup.js +207 -0
  21. package/src/routing/complexity-analyzer.js +40 -4
  22. package/src/routing/difficulty-classifier.js +261 -0
  23. package/src/routing/index.js +70 -7
  24. package/src/routing/intent-score.js +132 -12
  25. package/src/routing/session-affinity.js +8 -1
  26. package/src/routing/side-channel-detector.js +103 -0
  27. package/src/server.js +20 -46
  28. package/src/agents/context-manager.js +0 -236
  29. package/src/agents/decomposition/dispatcher.js +0 -185
  30. package/src/agents/decomposition/gate.js +0 -136
  31. package/src/agents/decomposition/index.js +0 -183
  32. package/src/agents/decomposition/model-call.js +0 -75
  33. package/src/agents/decomposition/planner.js +0 -223
  34. package/src/agents/decomposition/synthesizer.js +0 -89
  35. package/src/agents/decomposition/telemetry.js +0 -55
  36. package/src/agents/definitions/loader.js +0 -653
  37. package/src/agents/executor.js +0 -457
  38. package/src/agents/index.js +0 -165
  39. package/src/agents/parallel-coordinator.js +0 -68
  40. package/src/agents/reflector.js +0 -331
  41. package/src/agents/skillbook.js +0 -331
  42. package/src/agents/store.js +0 -259
  43. package/src/edits/index.js +0 -171
  44. package/src/indexer/babel-parser.js +0 -213
  45. package/src/indexer/index.js +0 -1629
  46. package/src/indexer/navigation/index.js +0 -32
  47. package/src/indexer/navigation/providers/treeSitter.js +0 -36
  48. package/src/indexer/parser.js +0 -443
  49. package/src/tasks/store.js +0 -349
  50. package/src/tests/coverage.js +0 -173
  51. package/src/tests/index.js +0 -171
  52. package/src/tests/store.js +0 -213
  53. package/src/tools/agent-task.js +0 -145
  54. package/src/tools/code-mode.js +0 -304
  55. package/src/tools/decompose.js +0 -91
  56. package/src/tools/edits.js +0 -94
  57. package/src/tools/execution.js +0 -171
  58. package/src/tools/git.js +0 -1346
  59. package/src/tools/index.js +0 -306
  60. package/src/tools/indexer.js +0 -360
  61. package/src/tools/lazy-loader.js +0 -366
  62. package/src/tools/mcp-remote.js +0 -88
  63. package/src/tools/mcp.js +0 -116
  64. package/src/tools/process.js +0 -167
  65. package/src/tools/smart-selection.js +0 -180
  66. package/src/tools/stubs.js +0 -55
  67. package/src/tools/tasks.js +0 -260
  68. package/src/tools/tests.js +0 -132
  69. package/src/tools/tinyfish.js +0 -358
  70. package/src/tools/truncate.js +0 -106
  71. package/src/tools/web-client.js +0 -71
  72. package/src/tools/web.js +0 -415
  73. package/src/tools/workspace.js +0 -204
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Phase 2a — native-format streaming passthrough.
3
+ *
4
+ * When the client speaks Anthropic and the tier-resolved upstream is an
5
+ * Anthropic-family provider, the buffered orchestrator adds nothing but
6
+ * latency: the response needs no format conversion and (post server-mode
7
+ * removal) no tool execution. This module connects to the upstream with
8
+ * stream:true and pipes the SSE bytes to the client as they arrive, so
9
+ * Claude Code sees first tokens as soon as the model emits them.
10
+ *
11
+ * Fallback contract: before the first byte reaches the client we can still
12
+ * fall back to the buffered orchestrator path — handleNativeStream returns
13
+ * false and the router continues its normal flow. After the first byte the
14
+ * stream is the response; upstream failures surface as SSE error events,
15
+ * never retries.
16
+ *
17
+ * Kill switch: LYNKR_NATIVE_PASSTHROUGH=false reverts every request to the
18
+ * buffered path.
19
+ */
20
+ const config = require("../config");
21
+ const logger = require("../logger");
22
+
23
+ // Providers whose wire format is Anthropic Messages SSE end-to-end. vertex is
24
+ // deliberately absent: this deployment's vertex client speaks Gemini.
25
+ // - zai qualifies only when its endpoint is the Anthropic-format one
26
+ // (api.z.ai/api/anthropic/...), not /chat/completions.
27
+ // - ollama qualifies only when LYNKR_OLLAMA_BUFFER_RESPONSES=false: buffering
28
+ // is the default because Ollama Cloud thinking models (MiniMax M2.5) can
29
+ // leak raw <think> tags into text blocks, and the buffered path is where
30
+ // _liftLeakedThinkingBlocks repairs that. Streaming skips the repair.
31
+ // The daemon must also expose the Anthropic Messages API (v0.14+) — that
32
+ // check is async and happens in handleNativeStream, falling back to the
33
+ // buffered path when absent.
34
+ // Tier configs write the z.ai provider as "z-ai"; the config key and
35
+ // invokeModel dispatch use "zai". Accept both.
36
+ function _canonicalProvider(provider) {
37
+ return provider === "z-ai" ? "zai" : provider;
38
+ }
39
+
40
+ function _anthropicNative(provider) {
41
+ switch (_canonicalProvider(provider)) {
42
+ case "azure-anthropic":
43
+ return true;
44
+ case "zai":
45
+ return !!config.zai?.apiKey && !String(config.zai?.endpoint || "").includes("/chat/completions");
46
+ case "ollama":
47
+ return process.env.LYNKR_OLLAMA_BUFFER_RESPONSES === "false" && !!config.ollama?.endpoint;
48
+ default:
49
+ return false;
50
+ }
51
+ }
52
+
53
+ const IDLE_TIMEOUT_MS = Number.parseInt(process.env.LYNKR_STREAM_IDLE_TIMEOUT_MS ?? "60000", 10);
54
+
55
+ // Parseable Anthropic error event for mid-stream failures — clients fail fast
56
+ // and retry instead of hanging on a truncated stream.
57
+ const SSE_ERROR_EVENT = (message) =>
58
+ `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "overloaded_error", message } })}\n\n`;
59
+
60
+ function _formatsMatch(clientFormat, provider) {
61
+ return clientFormat === "anthropic" && _anthropicNative(provider);
62
+ }
63
+
64
+ /**
65
+ * Response rewrites the passthrough CANNOT reproduce force the buffered
66
+ * path. Only ANSI markdown rendering remains here: it rewrites whole text
67
+ * blocks, which is impossible on a byte pipe. The routing badge is NOT a
68
+ * disqualifier — it's injected as a synthetic SSE content block before the
69
+ * upstream bytes (same trick the OAuth subscription passthrough ships).
70
+ */
71
+ function _needsInflightRewrite() {
72
+ try {
73
+ const { enabled: ansiEnabled } = require("../utils/markdown-ansi");
74
+ if (ansiEnabled) return true;
75
+ } catch { /* renderer unavailable — nothing rewrites */ }
76
+ return false;
77
+ }
78
+
79
+ function canPassthrough(body, tier) {
80
+ if (process.env.LYNKR_NATIVE_PASSTHROUGH === "false") return false;
81
+ if (body?.stream !== true) return false;
82
+ if (!_formatsMatch("anthropic", tier?.provider)) return false;
83
+ if (_needsInflightRewrite(body)) return false;
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Synthetic badge block frames (no trailing separator — the frame processor
89
+ * joins). NEVER emitted before message_start: Anthropic's SSE contract opens
90
+ * every stream with message_start, and Claude Code's incremental parser
91
+ * hard-rejects content_block events that precede it (zero events parsed,
92
+ * instant retry — live incident 2026-07-21).
93
+ */
94
+ function _badgeFrames(badgeText) {
95
+ return [
96
+ `event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}`,
97
+ `event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: badgeText } })}`,
98
+ `event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`,
99
+ ];
100
+ }
101
+
102
+ function _readWithIdleTimeout(reader, ms) {
103
+ let timer;
104
+ const timeout = new Promise((_, reject) => {
105
+ timer = setTimeout(() => reject(new Error(`upstream idle > ${ms}ms`)), ms);
106
+ });
107
+ return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
108
+ }
109
+
110
+ /**
111
+ * Usage arrives in the FINAL message_delta event, so keep a tail buffer
112
+ * rather than a head capture. Best-effort — returns nulls on any miss.
113
+ */
114
+ function _extractUsageFromTail(tail) {
115
+ let inputTokens = null;
116
+ let outputTokens = null;
117
+ for (const line of tail.split("\n")) {
118
+ if (!line.startsWith("data:")) continue;
119
+ try {
120
+ const evt = JSON.parse(line.slice(5).trim());
121
+ if (evt?.type === "message_start" && evt.message?.usage?.input_tokens != null) {
122
+ inputTokens = evt.message.usage.input_tokens;
123
+ }
124
+ if (evt?.type === "message_delta" && evt.usage) {
125
+ if (evt.usage.output_tokens != null) outputTokens = evt.usage.output_tokens;
126
+ if (evt.usage.input_tokens != null) inputTokens = evt.usage.input_tokens;
127
+ }
128
+ } catch { /* partial event in tail window */ }
129
+ }
130
+ return { inputTokens, outputTokens };
131
+ }
132
+
133
+ /**
134
+ * Frame-level processor for piped Anthropic SSE. Two jobs:
135
+ *
136
+ * - badge injection (all providers): the LYNKR_VISIBLE_ROUTING badge block
137
+ * is emitted immediately AFTER the upstream's message_start frame — never
138
+ * before it (protocol contract; see _badgeFrames).
139
+ * - ollama repairs (ollamaRepairs: true), both learned from live Claude
140
+ * Code 2.1.216 aborting the raw stream in <100ms:
141
+ * · drop thinking blocks: Ollama emits them UNSIGNED (no
142
+ * signature_delta), unlike api.anthropic.com, and CC rejects the
143
+ * stream. The buffered path solved this with
144
+ * _liftLeakedThinkingBlocks; streams strip instead.
145
+ * · normalize message_start: Ollama omits stop_reason/stop_sequence.
146
+ *
147
+ * Frames are "event: X\ndata: {...}\n\n" units; anything unparseable passes
148
+ * through untouched.
149
+ */
150
+ function _createFrameProcessor({ badgeText = null, ollamaRepairs = false } = {}) {
151
+ let buffer = "";
152
+ let badgePending = !!badgeText;
153
+ const droppedIndices = new Set();
154
+
155
+ // Returns null (drop), a string, or an array of frames (injection).
156
+ const processFrame = (frame) => {
157
+ const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
158
+ if (!dataLine) return frame;
159
+ let evt;
160
+ try { evt = JSON.parse(dataLine.slice(5).trim()); } catch { return frame; }
161
+
162
+ if (evt.type === "message_start" && evt.message) {
163
+ let out = frame;
164
+ if (ollamaRepairs) {
165
+ if (!("stop_reason" in evt.message)) evt.message.stop_reason = null;
166
+ if (!("stop_sequence" in evt.message)) evt.message.stop_sequence = null;
167
+ out = `event: message_start\ndata: ${JSON.stringify(evt)}`;
168
+ }
169
+ if (badgePending) {
170
+ badgePending = false;
171
+ return [out, ..._badgeFrames(badgeText)];
172
+ }
173
+ return out;
174
+ }
175
+ if (!ollamaRepairs) return frame;
176
+ if (evt.type === "content_block_start" && evt.content_block?.type === "thinking") {
177
+ droppedIndices.add(evt.index);
178
+ return null;
179
+ }
180
+ if (evt.type === "content_block_delta" && droppedIndices.has(evt.index)) return null;
181
+ if (evt.type === "content_block_stop" && droppedIndices.has(evt.index)) {
182
+ droppedIndices.delete(evt.index);
183
+ return null;
184
+ }
185
+ return frame;
186
+ };
187
+
188
+ return (chunkText, flush = false) => {
189
+ buffer += chunkText;
190
+ const frames = buffer.split("\n\n");
191
+ buffer = flush ? "" : (frames.pop() ?? "");
192
+ const out = frames.flatMap((f) => {
193
+ const r = processFrame(f);
194
+ return r === null ? [] : Array.isArray(r) ? r : [r];
195
+ });
196
+ if (flush && buffer) out.push(buffer);
197
+ return out.length ? out.join("\n\n") + "\n\n" : "";
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Connect to the upstream with stream:true and pipe bytes to the client.
203
+ * Returns true when the response was handled (success OR mid-stream error
204
+ * already surfaced to the client), false when the router should fall back
205
+ * to the buffered path (no bytes have been written).
206
+ */
207
+ async function handleNativeStream(req, res, opts = {}) {
208
+ const tier = opts.tier || {};
209
+ const startedAt = Date.now();
210
+
211
+ const body = { ...req.body, stream: true };
212
+ if (tier.model && !body._tierModel) body._tierModel = tier.model;
213
+
214
+ let upstream;
215
+ try {
216
+ // Reuse the provider clients: they own the endpoint, auth headers, model
217
+ // override, internal-field stripping, and body repair.
218
+ const databricks = require("../clients/databricks");
219
+ const provider = _canonicalProvider(tier.provider);
220
+ if (provider === "zai") {
221
+ upstream = await databricks.invokeZai(body, req.headers);
222
+ } else if (provider === "ollama") {
223
+ // The daemon must speak the Anthropic Messages API (v0.14+); on older
224
+ // daemons invokeOllama takes the legacy /api/chat path, which returns
225
+ // its own format — fall back to the buffered converter.
226
+ const { hasAnthropicEndpoint } = require("../clients/ollama-utils");
227
+ if (!(await hasAnthropicEndpoint(config.ollama.endpoint))) {
228
+ logger.debug("[NativeStream] Ollama daemon lacks Anthropic API — falling back to buffered path");
229
+ return false;
230
+ }
231
+ upstream = await databricks.invokeOllama(body, req.headers);
232
+ } else {
233
+ upstream = await databricks.invokeAzureAnthropic(body, req.headers);
234
+ }
235
+ } catch (err) {
236
+ logger.warn({ err: err.message }, "[NativeStream] Upstream connect failed — falling back to buffered path");
237
+ return false;
238
+ }
239
+
240
+ if (!upstream?.ok || !upstream.stream) {
241
+ logger.warn({ status: upstream?.status }, "[NativeStream] Upstream non-200 before first byte — falling back to buffered path");
242
+ return false;
243
+ }
244
+
245
+ const contentType = upstream.contentType || "";
246
+ if (!contentType.includes("text/event-stream")) {
247
+ // Provider answered buffered JSON despite stream:true. Rare; hand the
248
+ // request back to the buffered path (costs a duplicate upstream call).
249
+ logger.warn({ contentType }, "[NativeStream] Upstream ignored stream:true — falling back to buffered path");
250
+ try { upstream.stream.cancel?.()?.catch?.(() => { /* already drained */ }); } catch { /* already drained */ }
251
+ return false;
252
+ }
253
+
254
+ // ── Past the point of no return: headers + bytes go to the client. ──
255
+ res.status(200);
256
+ // Never mirror upstream Content-Length — this response is chunked.
257
+ res.set({
258
+ "Content-Type": contentType,
259
+ "Cache-Control": "no-cache",
260
+ Connection: "keep-alive",
261
+ });
262
+ res.set("X-Lynkr-Provider", tier.provider || "azure-anthropic");
263
+ if (tier.tier) res.set("X-Lynkr-Tier", tier.tier);
264
+ if (tier.model || req.body?.model) res.set("X-Lynkr-Model", tier.model || req.body.model);
265
+ res.set("X-Lynkr-Routing-Method", "native-passthrough-stream");
266
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
267
+
268
+ const reader = upstream.stream.getReader();
269
+ const decoder = new TextDecoder();
270
+ // Frame processing is needed when injecting the badge (which must land
271
+ // AFTER the upstream's message_start) and for ollama's repairs. With
272
+ // neither, the stream stays a pure byte pipe.
273
+ const _wantsBadge = !!(config.routing?.visibleInteraction && opts.badgeText);
274
+ const _isOllama = _canonicalProvider(tier.provider) === "ollama";
275
+ const frameFilter = (_wantsBadge || _isOllama)
276
+ ? _createFrameProcessor({ badgeText: _wantsBadge ? opts.badgeText : null, ollamaRepairs: _isOllama })
277
+ : null;
278
+ let tail = ""; // last ~16KB, for the usage-bearing final events
279
+ let streamError = null;
280
+ let firstByteAt = null;
281
+
282
+ const writeWithBackpressure = async (data) => {
283
+ if (!data || data.length === 0) return;
284
+ // Honor backpressure: if the client reads slowly, wait for drain
285
+ // instead of buffering the upstream into memory.
286
+ if (!res.write(data)) {
287
+ await new Promise((resolve) => res.once("drain", resolve));
288
+ }
289
+ };
290
+
291
+ try {
292
+ while (true) {
293
+ const { value, done } = await _readWithIdleTimeout(reader, IDLE_TIMEOUT_MS);
294
+ if (done) break;
295
+ if (firstByteAt === null) firstByteAt = Date.now();
296
+
297
+ let outText = null;
298
+ if (frameFilter) {
299
+ outText = frameFilter(decoder.decode(value, { stream: true }));
300
+ await writeWithBackpressure(outText);
301
+ } else {
302
+ await writeWithBackpressure(Buffer.from(value));
303
+ outText = decoder.decode(value, { stream: true });
304
+ }
305
+
306
+ tail += outText;
307
+ if (tail.length > 16384) tail = tail.slice(-16384);
308
+ }
309
+ if (frameFilter) {
310
+ // Flush the decoder and any partial frame held by the filter.
311
+ const flushed = frameFilter(decoder.decode(), true);
312
+ await writeWithBackpressure(flushed);
313
+ tail += flushed;
314
+ }
315
+ } catch (err) {
316
+ streamError = err;
317
+ logger.warn({ err: err.message }, "[NativeStream] Upstream stream failed mid-flight — surfacing SSE error");
318
+ // cancel() rejects ASYNCHRONOUSLY on a dead socket — a sync try/catch
319
+ // would leak an unhandled rejection.
320
+ try { reader.cancel().catch(() => { /* already dead */ }); } catch { /* already dead */ }
321
+ try { res.write(SSE_ERROR_EVENT(`Lynkr: upstream stream failed — retry (${err.message})`)); } catch { /* client gone */ }
322
+ } finally {
323
+ try { reader.releaseLock(); } catch { /* already released */ }
324
+ }
325
+ res.end();
326
+
327
+ // ── Response finalizer: telemetry runs on stream close, never blocks. ──
328
+ setImmediate(() => {
329
+ try {
330
+ const latencyMs = Date.now() - startedAt;
331
+ const ttftMs = firstByteAt ? firstByteAt - startedAt : null;
332
+ const { inputTokens, outputTokens } = _extractUsageFromTail(tail);
333
+ logger.info({
334
+ provider: tier.provider || "azure-anthropic",
335
+ ttftMs,
336
+ latencyMs,
337
+ outputTokens,
338
+ error: streamError ? streamError.message : null,
339
+ }, "[NativeStream] Stream closed");
340
+
341
+ const { getMetricsCollector } = require("../observability/metrics");
342
+ const mc = getMetricsCollector();
343
+ if (streamError) mc.recordProviderFailure(tier.provider || "azure-anthropic");
344
+ else mc.recordProviderSuccess(tier.provider || "azure-anthropic", latencyMs);
345
+ if (inputTokens || outputTokens) mc.recordTokens(inputTokens || 0, outputTokens || 0);
346
+
347
+ const telemetry = require("../routing/telemetry");
348
+ telemetry.record({
349
+ // request_id is NOT NULL in the telemetry schema — a null id silently
350
+ // drops the whole row.
351
+ request_id: req.headers["request-id"] || req.headers["x-request-id"] || require("crypto").randomUUID(),
352
+ session_id: req.body?._sessionId || req.sessionId || null,
353
+ timestamp: startedAt,
354
+ tier: tier.tier || null,
355
+ provider: tier.provider || "azure-anthropic",
356
+ model: tier.model || req.body?.model || null,
357
+ routing_method: "native-passthrough-stream",
358
+ status_code: streamError ? 599 : 200,
359
+ latency_ms: latencyMs,
360
+ input_tokens: inputTokens,
361
+ output_tokens: outputTokens,
362
+ message_count: req.body?.messages?.length || null,
363
+ tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
364
+ was_fallback: false,
365
+ error_type: streamError ? "stream_error" : null,
366
+ });
367
+ } catch (err) {
368
+ logger.debug({ err: err.message }, "[NativeStream] Telemetry finalizer failed (non-fatal)");
369
+ }
370
+ });
371
+
372
+ return true;
373
+ }
374
+
375
+ module.exports = {
376
+ canPassthrough,
377
+ handleNativeStream,
378
+ _createFrameProcessor,
379
+ // Exported for unit tests.
380
+ _formatsMatch,
381
+ _extractUsageFromTail,
382
+ };