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.
- package/README.md +92 -23
- package/bin/cli.js +13 -7
- package/bin/lynkr-init.js +34 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/scripts/build-eval-set.js +256 -0
- package/scripts/mine-difficulty-anchors.js +288 -0
- package/scripts/validate-difficulty-classifier.js +144 -0
- package/scripts/validate-intent-anchors.js +186 -0
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +292 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/context/tool-result-compressor.js +883 -40
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/classifier-setup.js +207 -0
- package/src/routing/complexity-analyzer.js +40 -4
- package/src/routing/difficulty-classifier.js +261 -0
- package/src/routing/index.js +70 -7
- package/src/routing/intent-score.js +132 -12
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +20 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- 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
|
-
|
|
125
|
-
|
|
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) {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifier bootstrap — verifies ollama is installed and the classifier
|
|
3
|
+
* model is pulled + warm. Called from:
|
|
4
|
+
* - `lynkr init` (interactive, prompts user for install, blocks on completion)
|
|
5
|
+
* - server boot (non-blocking, logs warnings, never errors out)
|
|
6
|
+
*
|
|
7
|
+
* Ollama install is intentionally NOT auto-executed. Silent `curl | sh`
|
|
8
|
+
* during npm install is a supply-chain footgun; we detect and instruct
|
|
9
|
+
* instead.
|
|
10
|
+
*
|
|
11
|
+
* "Later" work per user directive:
|
|
12
|
+
* - Fine-tune qwen2.5:3b on labeled classification data (needs LoRA infra)
|
|
13
|
+
* - Canary verification against known-good prompts on every startup
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { spawn } = require('child_process');
|
|
17
|
+
const os = require('os');
|
|
18
|
+
|
|
19
|
+
// Reads the classifier model constant from difficulty-classifier.js so this
|
|
20
|
+
// module and the classifier stay in lock-step.
|
|
21
|
+
const { CLASSIFIER_MODEL_INFO } = require('./difficulty-classifier');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether the `ollama` CLI is on PATH.
|
|
25
|
+
* @returns {Promise<{installed: boolean, version?: string}>}
|
|
26
|
+
*/
|
|
27
|
+
function detectOllama() {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const proc = spawn('ollama', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
30
|
+
let out = '';
|
|
31
|
+
proc.stdout?.on('data', (d) => { out += d.toString(); });
|
|
32
|
+
proc.on('error', () => resolve({ installed: false }));
|
|
33
|
+
proc.on('close', (code) => {
|
|
34
|
+
if (code === 0) {
|
|
35
|
+
resolve({ installed: true, version: out.trim() });
|
|
36
|
+
} else {
|
|
37
|
+
resolve({ installed: false });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check whether a specific ollama model is present locally.
|
|
45
|
+
* @param {string} model
|
|
46
|
+
* @returns {Promise<boolean>}
|
|
47
|
+
*/
|
|
48
|
+
function isModelPulled(model) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const proc = spawn('ollama', ['list'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
51
|
+
let out = '';
|
|
52
|
+
proc.stdout?.on('data', (d) => { out += d.toString(); });
|
|
53
|
+
proc.on('error', () => resolve(false));
|
|
54
|
+
proc.on('close', () => {
|
|
55
|
+
// ollama list prints "NAME ID SIZE MODIFIED" — grep by exact model name
|
|
56
|
+
const rows = out.split('\n').slice(1);
|
|
57
|
+
resolve(rows.some((line) => line.startsWith(model + ' ') || line.split(/\s+/)[0] === model));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Pull a model, streaming progress to stdout.
|
|
64
|
+
* @param {string} model
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
function pullModel(model) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const proc = spawn('ollama', ['pull', model], { stdio: 'inherit' });
|
|
70
|
+
proc.on('error', reject);
|
|
71
|
+
proc.on('close', (code) => {
|
|
72
|
+
if (code === 0) resolve();
|
|
73
|
+
else reject(new Error(`ollama pull exited with code ${code}`));
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Warm the model — one dummy inference so first real call isn't cold.
|
|
80
|
+
* Best-effort; failure doesn't block.
|
|
81
|
+
* @param {string} model
|
|
82
|
+
*/
|
|
83
|
+
async function warmModel(model, endpoint = 'http://localhost:11434') {
|
|
84
|
+
try {
|
|
85
|
+
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timer = setTimeout(() => controller.abort(), 60000);
|
|
88
|
+
const res = await fetch(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({
|
|
92
|
+
model,
|
|
93
|
+
messages: [{ role: 'user', content: 'ok' }],
|
|
94
|
+
stream: false,
|
|
95
|
+
options: { num_predict: 3 },
|
|
96
|
+
}),
|
|
97
|
+
signal: controller.signal,
|
|
98
|
+
});
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
return res.ok;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Instructions to print when ollama is missing. */
|
|
107
|
+
function installInstructions() {
|
|
108
|
+
const plat = os.platform();
|
|
109
|
+
const lines = ['Ollama is required for the difficulty classifier and SIMPLE-tier serving.'];
|
|
110
|
+
if (plat === 'darwin') {
|
|
111
|
+
lines.push('Install on macOS:');
|
|
112
|
+
lines.push(' brew install ollama');
|
|
113
|
+
lines.push(' # or download: https://ollama.com/download');
|
|
114
|
+
} else if (plat === 'linux') {
|
|
115
|
+
lines.push('Install on Linux:');
|
|
116
|
+
lines.push(' curl -fsSL https://ollama.com/install.sh | sh');
|
|
117
|
+
} else if (plat === 'win32') {
|
|
118
|
+
lines.push('Install on Windows:');
|
|
119
|
+
lines.push(' Download the installer from https://ollama.com/download/windows');
|
|
120
|
+
} else {
|
|
121
|
+
lines.push('Install from https://ollama.com/download');
|
|
122
|
+
}
|
|
123
|
+
lines.push('After installing, re-run `lynkr init` (or restart the server).');
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Full bootstrap flow with prompts.
|
|
129
|
+
* @param {object} opts
|
|
130
|
+
* @param {'interactive'|'boot'} opts.mode — interactive blocks on failure, boot warns and continues
|
|
131
|
+
* @param {function} [opts.log] — logger function (defaults to console)
|
|
132
|
+
* @param {function} [opts.warn] — warn function
|
|
133
|
+
* @param {function} [opts.prompt] — async prompt(text)→string for interactive install/pull confirmations
|
|
134
|
+
* @returns {Promise<{ready: boolean, ollama: boolean, model: boolean, reason?: string}>}
|
|
135
|
+
*/
|
|
136
|
+
async function ensureClassifierReady(opts = {}) {
|
|
137
|
+
const mode = opts.mode || 'boot';
|
|
138
|
+
const log = opts.log || ((msg) => console.log(msg));
|
|
139
|
+
const warn = opts.warn || ((msg) => console.warn(msg));
|
|
140
|
+
const { provider, model, endpoint } = CLASSIFIER_MODEL_INFO;
|
|
141
|
+
|
|
142
|
+
if (provider !== 'ollama') {
|
|
143
|
+
// Non-ollama providers not supported today — bail cleanly.
|
|
144
|
+
warn(`Classifier provider "${provider}" is not yet auto-provisioned. Ensure ${model} is reachable manually.`);
|
|
145
|
+
return { ready: false, ollama: false, model: false, reason: 'non_ollama_provider' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 1. Ollama detection
|
|
149
|
+
const ollama = await detectOllama();
|
|
150
|
+
if (!ollama.installed) {
|
|
151
|
+
const msg = installInstructions();
|
|
152
|
+
if (mode === 'interactive') {
|
|
153
|
+
log('');
|
|
154
|
+
log('⚠ Ollama not found on PATH.');
|
|
155
|
+
log('');
|
|
156
|
+
log(msg);
|
|
157
|
+
log('');
|
|
158
|
+
} else {
|
|
159
|
+
warn(`[classifier-setup] Ollama not installed — classifier disabled. ${installInstructions().split('\n')[0]}`);
|
|
160
|
+
}
|
|
161
|
+
return { ready: false, ollama: false, model: false, reason: 'ollama_missing' };
|
|
162
|
+
}
|
|
163
|
+
if (mode === 'interactive') log(`✓ Ollama detected (${ollama.version || 'unknown version'}).`);
|
|
164
|
+
|
|
165
|
+
// 2. Model pull
|
|
166
|
+
const hasModel = await isModelPulled(model);
|
|
167
|
+
if (!hasModel) {
|
|
168
|
+
if (mode === 'interactive') {
|
|
169
|
+
log('');
|
|
170
|
+
log(`Classifier model "${model}" not present locally.`);
|
|
171
|
+
const yes = opts.prompt ? await opts.prompt(`Pull ${model} now? [Y/n] `) : 'y';
|
|
172
|
+
if (yes.trim().toLowerCase().startsWith('n')) {
|
|
173
|
+
warn(`Skipped pull — classifier will be disabled until you run: ollama pull ${model}`);
|
|
174
|
+
return { ready: false, ollama: true, model: false, reason: 'pull_declined' };
|
|
175
|
+
}
|
|
176
|
+
log(`Pulling ${model} (this can take a few minutes on first run)...`);
|
|
177
|
+
try {
|
|
178
|
+
await pullModel(model);
|
|
179
|
+
log(`✓ Model ${model} pulled.`);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
warn(`✗ Pull failed: ${err.message}`);
|
|
182
|
+
return { ready: false, ollama: true, model: false, reason: 'pull_failed' };
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// Boot mode — don't auto-pull. Log a clear instruction and continue.
|
|
186
|
+
warn(`[classifier-setup] Classifier model ${model} not pulled. Run: ollama pull ${model}. Classifier will fall back to anchor-only scoring until then.`);
|
|
187
|
+
return { ready: false, ollama: true, model: false, reason: 'model_missing' };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 3. Warm-up
|
|
192
|
+
const warmed = await warmModel(model, endpoint);
|
|
193
|
+
if (mode === 'interactive') {
|
|
194
|
+
log(warmed ? `✓ Model warmed (first classification will be fast).` : `⚠ Warm-up call failed — the first classification may be slow.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { ready: true, ollama: true, model: true, warmed };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
detectOllama,
|
|
202
|
+
isModelPulled,
|
|
203
|
+
pullModel,
|
|
204
|
+
warmModel,
|
|
205
|
+
installInstructions,
|
|
206
|
+
ensureClassifierReady,
|
|
207
|
+
};
|