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,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) {
@@ -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
+ };