lynkr 9.0.1 → 9.1.2

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 (58) hide show
  1. package/README.md +70 -21
  2. package/bin/cli.js +34 -4
  3. package/bin/lynkr-trajectory.js +136 -0
  4. package/bin/lynkr-usage.js +219 -0
  5. package/funding.json +110 -0
  6. package/index.js +7 -3
  7. package/install.sh +3 -3
  8. package/lynkr-skill.tar.gz +0 -0
  9. package/native/Cargo.toml +26 -0
  10. package/native/index.js +29 -0
  11. package/native/lynkr-native.node +0 -0
  12. package/native/src/lib.rs +321 -0
  13. package/package.json +6 -5
  14. package/public/dashboard.html +665 -0
  15. package/src/api/files-multipart.js +30 -0
  16. package/src/api/files-router.js +81 -0
  17. package/src/api/middleware/budget.js +19 -1
  18. package/src/api/middleware/load-shedding.js +17 -0
  19. package/src/api/openai-router.js +353 -301
  20. package/src/api/router.js +275 -40
  21. package/src/cache/prompt.js +13 -0
  22. package/src/clients/databricks.js +42 -18
  23. package/src/clients/ollama-utils.js +21 -17
  24. package/src/clients/openai-format.js +50 -10
  25. package/src/clients/openrouter-utils.js +42 -37
  26. package/src/clients/prompt-cache-injection.js +140 -0
  27. package/src/clients/provider-capabilities.js +41 -0
  28. package/src/clients/responses-format.js +8 -7
  29. package/src/clients/standard-tools.js +1 -1
  30. package/src/clients/xml-tool-extractor.js +307 -0
  31. package/src/cluster.js +82 -0
  32. package/src/config/index.js +16 -0
  33. package/src/context/distill.js +15 -0
  34. package/src/context/tool-result-compressor.js +563 -0
  35. package/src/dashboard/api.js +170 -0
  36. package/src/dashboard/router.js +13 -0
  37. package/src/headroom/client.js +3 -109
  38. package/src/headroom/index.js +0 -14
  39. package/src/memory/extractor.js +22 -0
  40. package/src/memory/search.js +0 -50
  41. package/src/orchestrator/index.js +163 -204
  42. package/src/orchestrator/preflight.js +188 -0
  43. package/src/routing/index.js +64 -32
  44. package/src/routing/interaction.js +183 -0
  45. package/src/routing/risk-analyzer.js +194 -0
  46. package/src/routing/telemetry.js +47 -2
  47. package/src/server.js +15 -0
  48. package/src/stores/file-store.js +104 -0
  49. package/src/stores/response-store.js +25 -0
  50. package/src/tools/index.js +1 -1
  51. package/src/tools/smart-selection.js +11 -2
  52. package/src/tools/web.js +1 -1
  53. package/src/training/trajectory-compressor.js +266 -0
  54. package/src/usage/aggregator.js +206 -0
  55. package/src/utils/markdown-ansi.js +146 -0
  56. package/.lynkr/telemetry.db +0 -0
  57. package/.lynkr/telemetry.db-shm +0 -0
  58. package/.lynkr/telemetry.db-wal +0 -0
@@ -60,13 +60,16 @@ function convertOpenAIToAnthropic(openaiRequest) {
60
60
  if (part.type === "text") {
61
61
  return { type: "text", text: part.text };
62
62
  } else if (part.type === "image_url") {
63
- return {
64
- type: "image",
65
- source: {
66
- type: "url",
67
- url: part.image_url.url
63
+ const url = part.image_url?.url || "";
64
+ if (url.startsWith("data:")) {
65
+ const match = url.match(/^data:(image\/[^;]+);base64,(.+)$/);
66
+ if (match) {
67
+ return { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } };
68
68
  }
69
- };
69
+ }
70
+ return { type: "image", source: { type: "url", url } };
71
+ } else if (part.type === "document" || part.type === "image") {
72
+ return part;
70
73
  }
71
74
  return part;
72
75
  });
@@ -200,18 +203,37 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
200
203
 
201
204
  const { id, content, stop_reason, usage } = anthropicResponse;
202
205
 
203
- // Validate required fields
204
- if (!content || !Array.isArray(content)) {
205
- throw new Error(`convertAnthropicToOpenAI: invalid content field (got ${typeof content})`);
206
+ // Tolerant fallback: providers sometimes return reasoning-only responses
207
+ // (Minimax/DeepSeek), error envelopes, or empty bodies. Treat missing/invalid
208
+ // content as an empty turn so jcode/Pi/Codex don't crash on the response.
209
+ const safeContent = Array.isArray(content) ? content : [];
210
+ if (safeContent.length === 0) {
211
+ logger.warn({
212
+ hasContent: content !== undefined,
213
+ contentType: typeof content,
214
+ stop_reason,
215
+ responseKeys: Object.keys(anthropicResponse),
216
+ hasError: !!anthropicResponse.error,
217
+ errorMessage: anthropicResponse.error?.message,
218
+ }, "convertAnthropicToOpenAI: empty/missing content, returning empty assistant message");
206
219
  }
207
220
 
208
221
  // Convert content blocks to OpenAI format
209
222
  let messageContent = "";
223
+ let reasoningContent = "";
210
224
  const toolCalls = [];
225
+ let citations = [];
211
226
 
212
- for (const block of content) {
227
+ for (const block of safeContent) {
213
228
  if (block.type === "text") {
214
229
  messageContent += block.text;
230
+ if (Array.isArray(block.citations)) {
231
+ citations.push(...block.citations);
232
+ }
233
+ } else if (block.type === "thinking") {
234
+ // Preserve reasoning text so reasoning-only models (Minimax, DeepSeek-R1)
235
+ // surface visible output to OpenAI clients that don't render thinking blocks
236
+ reasoningContent += (block.thinking || "");
215
237
  } else if (block.type === "tool_use") {
216
238
  toolCalls.push({
217
239
  id: block.id,
@@ -224,6 +246,12 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
224
246
  }
225
247
  }
226
248
 
249
+ // Fallback: if the model returned only reasoning (no visible text and no tools),
250
+ // promote reasoning into the visible content so jcode/Pi/Codex see something
251
+ if (!messageContent && !toolCalls.length && reasoningContent) {
252
+ messageContent = reasoningContent;
253
+ }
254
+
227
255
  // Build OpenAI response
228
256
  // Ensure ID has the chatcmpl- prefix that OpenAI clients expect
229
257
  const responseId = id && id.startsWith("chatcmpl-") ? id : `chatcmpl-${Date.now()}`;
@@ -249,6 +277,18 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
249
277
  }
250
278
  };
251
279
 
280
+ // Add citations if present
281
+ if (citations.length > 0) {
282
+ openaiResponse.citations = citations;
283
+ }
284
+
285
+ // Add reasoning_content as a side-channel field so clients that render
286
+ // thinking (e.g. some jcode / OpenRouter setups) can show it without losing
287
+ // it from the visible content fallback above
288
+ if (reasoningContent && reasoningContent !== messageContent) {
289
+ openaiResponse.choices[0].message.reasoning_content = reasoningContent;
290
+ }
291
+
252
292
  // Add tool_calls if present
253
293
  if (toolCalls.length > 0) {
254
294
  openaiResponse.choices[0].message.tool_calls = toolCalls;
@@ -89,12 +89,12 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
89
89
  tool_calls
90
90
  };
91
91
 
92
- // Only add content if there's actual text, otherwise omit the field entirely
93
- // Some providers require content to be present, so use empty string as fallback
92
+ // Moonshot/Kimi and some OpenAI-compatible APIs require content to
93
+ // be null (not empty string) when tool_calls are present.
94
94
  if (textContent && textContent.trim()) {
95
95
  message.content = textContent;
96
96
  } else {
97
- message.content = '';
97
+ message.content = null;
98
98
  }
99
99
 
100
100
  converted.push(message);
@@ -146,37 +146,32 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
146
146
  }
147
147
  }
148
148
 
149
- // Validate message sequence: tool messages must follow assistant messages with tool_calls
149
+ // Fix tool_call_id mismatches: ensure every tool message's tool_call_id
150
+ // matches the id in the preceding assistant's tool_calls array.
151
+ // IDs can drift when multiple conversion layers (Anthropic↔OpenAI) each
152
+ // generate their own IDs.
150
153
  for (let i = 0; i < converted.length; i++) {
151
154
  const msg = converted[i];
152
- if (msg.role === 'tool') {
153
- // Find the preceding assistant message with tool_calls
154
- let foundMatchingToolCall = false;
155
- for (let j = i - 1; j >= 0; j--) {
156
- const prevMsg = converted[j];
157
- if (prevMsg.role === 'assistant' && Array.isArray(prevMsg.tool_calls)) {
158
- // Check if this tool result matches any of the tool calls
159
- if (prevMsg.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
160
- foundMatchingToolCall = true;
161
- break;
155
+ if (msg.role !== 'tool') continue;
156
+
157
+ // Find the nearest preceding assistant with tool_calls
158
+ for (let j = i - 1; j >= 0; j--) {
159
+ const prev = converted[j];
160
+ if (prev.role === 'user') break;
161
+ if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
162
+ if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
163
+ // Mismatch — pick the first unmatched tool_call id
164
+ const usedIds = new Set();
165
+ for (let k = j + 1; k < converted.length; k++) {
166
+ if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id);
167
+ }
168
+ const available = prev.tool_calls.find(tc => !usedIds.has(tc.id));
169
+ if (available) {
170
+ logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch");
171
+ msg.tool_call_id = available.id;
162
172
  }
163
173
  }
164
- // Stop if we hit another user message
165
- if (prevMsg.role === 'user') break;
166
- }
167
-
168
- if (!foundMatchingToolCall) {
169
- // Log but DON'T remove - the tool result may be valid but IDs mismatched due to format conversion
170
- logger.debug({
171
- messageIndex: i,
172
- toolCallId: msg.tool_call_id,
173
- precedingMessages: converted.slice(Math.max(0, i - 3), i).map(m => ({
174
- role: m.role,
175
- hasToolCalls: !!m.tool_calls,
176
- toolCallIds: m.tool_calls?.map(tc => tc.id)
177
- }))
178
- }, "Tool message without matching tool_call - keeping for API to validate");
179
- // Don't remove - let the API handle validation
174
+ break;
180
175
  }
181
176
  }
182
177
  }
@@ -242,6 +237,17 @@ function convertOpenRouterResponseToAnthropic(openRouterResponse, requestedModel
242
237
  const message = choice.message || {};
243
238
  const contentBlocks = [];
244
239
 
240
+ // Extract tool calls embedded as XML/text in content (Minimax, Qwen, GLM, etc.)
241
+ if (!message.tool_calls?.length && typeof message.content === "string" && message.content.trim()) {
242
+ const { extractToolCallsFromText } = require("./xml-tool-extractor");
243
+ const extracted = extractToolCallsFromText(message.content);
244
+ if (extracted.toolCalls.length > 0) {
245
+ message.tool_calls = extracted.toolCalls;
246
+ message.content = extracted.cleanedText;
247
+ choice.finish_reason = "tool_calls";
248
+ }
249
+ }
250
+
245
251
  // Check if there are tool calls present
246
252
  const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
247
253
 
@@ -262,14 +268,13 @@ function convertOpenRouterResponseToAnthropic(openRouterResponse, requestedModel
262
268
  trimmed.includes('"arguments"'));
263
269
  };
264
270
 
265
- // Handle reasoning_content from thinking models (e.g., Kimi, o1)
271
+ // Emit reasoning_content as a thinking block (not as fallback text)
266
272
  let textContent = message.content || "";
267
- if (!textContent.trim() && message.reasoning_content) {
268
- logger.info({
269
- hasReasoningContent: true,
270
- reasoningLength: message.reasoning_content.length
271
- }, "Using reasoning_content as primary content (thinking model detected)");
272
- textContent = message.reasoning_content;
273
+ if (message.reasoning_content && typeof message.reasoning_content === "string") {
274
+ contentBlocks.push({ type: "thinking", thinking: message.reasoning_content });
275
+ }
276
+ if (!textContent.trim() && !message.reasoning_content) {
277
+ // No content at all will be handled below
273
278
  }
274
279
 
275
280
  // Add text content if present, but skip if it's a duplicate/malformed tool call JSON
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Provider-Side Prompt Cache Injection
3
+ *
4
+ * Injects `cache_control` breakpoints into requests for providers
5
+ * that support explicit prompt caching (Anthropic, Bedrock, Vertex/Gemini).
6
+ *
7
+ * Strategy: "system_and_3" — places up to 4 breakpoints:
8
+ * 1. System prompt (stable across turns — highest cache hit rate)
9
+ * 2-4. Last 3 non-system messages (rolling window)
10
+ *
11
+ * Providers with automatic caching (OpenAI, DeepSeek) need no injection.
12
+ *
13
+ * @module clients/prompt-cache-injection
14
+ */
15
+
16
+ const logger = require('../logger');
17
+
18
+ const CACHE_MARKER = { type: 'ephemeral' };
19
+ const MAX_BREAKPOINTS = 4;
20
+
21
+ /**
22
+ * Inject cache_control breakpoints into an Anthropic-format request body.
23
+ * Mutates the body in-place for zero-copy performance.
24
+ *
25
+ * @param {Object} body - Request body with system and messages
26
+ * @returns {number} Number of breakpoints injected
27
+ */
28
+ function injectAnthropicCacheBreakpoints(body) {
29
+ if (!body) return 0;
30
+
31
+ let injected = 0;
32
+
33
+ // Breakpoint 1: System prompt
34
+ if (body.system) {
35
+ if (typeof body.system === 'string') {
36
+ // Convert string system to array format for cache_control support
37
+ body.system = [{
38
+ type: 'text',
39
+ text: body.system,
40
+ cache_control: CACHE_MARKER,
41
+ }];
42
+ injected++;
43
+ } else if (Array.isArray(body.system) && body.system.length > 0) {
44
+ // Mark the last system block
45
+ const lastBlock = body.system[body.system.length - 1];
46
+ if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
47
+ lastBlock.cache_control = CACHE_MARKER;
48
+ injected++;
49
+ }
50
+ }
51
+ }
52
+
53
+ // Breakpoints 2-4: Last 3 non-system messages
54
+ if (Array.isArray(body.messages) && body.messages.length > 0) {
55
+ const remaining = MAX_BREAKPOINTS - injected;
56
+ const messagesToMark = Math.min(remaining, 3, body.messages.length);
57
+
58
+ for (let i = 0; i < messagesToMark; i++) {
59
+ const msgIdx = body.messages.length - 1 - i;
60
+ const msg = body.messages[msgIdx];
61
+ if (!msg) continue;
62
+
63
+ if (typeof msg.content === 'string') {
64
+ // Convert string content to array for cache_control
65
+ msg.content = [{
66
+ type: 'text',
67
+ text: msg.content,
68
+ cache_control: CACHE_MARKER,
69
+ }];
70
+ injected++;
71
+ } else if (Array.isArray(msg.content) && msg.content.length > 0) {
72
+ // Mark the last content block in this message
73
+ const lastBlock = msg.content[msg.content.length - 1];
74
+ if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
75
+ lastBlock.cache_control = CACHE_MARKER;
76
+ injected++;
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ if (injected > 0) {
83
+ logger.debug({ breakpoints: injected }, '[prompt-cache] Injected cache_control breakpoints');
84
+ }
85
+
86
+ return injected;
87
+ }
88
+
89
+ /**
90
+ * Inject cache_control for Gemini/Vertex explicit caching.
91
+ * Uses the same cache_control format — Gemini accepts it via LiteLLM/OpenRouter.
92
+ *
93
+ * @param {Object} body - Request body with system and messages (Anthropic format, pre-conversion)
94
+ * @returns {number} Number of breakpoints injected
95
+ */
96
+ function injectGeminiCacheBreakpoints(body) {
97
+ // Gemini uses the same cache_control format when going through
98
+ // OpenRouter or LiteLLM. For direct Gemini API, implicit caching
99
+ // is automatic — no injection needed.
100
+ // We inject anyway for OpenRouter/proxy paths that forward cache_control.
101
+ return injectAnthropicCacheBreakpoints(body);
102
+ }
103
+
104
+ /**
105
+ * Determine if a provider benefits from cache_control injection.
106
+ *
107
+ * @param {string} provider - Provider name
108
+ * @returns {boolean}
109
+ */
110
+ function needsCacheInjection(provider) {
111
+ // These providers support explicit cache_control breakpoints
112
+ const EXPLICIT_CACHE_PROVIDERS = new Set([
113
+ 'azure-anthropic',
114
+ 'bedrock',
115
+ 'databricks', // Databricks routes to Claude which supports caching
116
+ 'openrouter', // OpenRouter forwards cache_control to underlying provider
117
+ ]);
118
+
119
+ return EXPLICIT_CACHE_PROVIDERS.has(provider);
120
+ }
121
+
122
+ /**
123
+ * Inject provider-side prompt caching into the request body.
124
+ * Call this before sending to the provider.
125
+ *
126
+ * @param {Object} body - Request body (Anthropic format)
127
+ * @param {string} provider - Provider name
128
+ * @returns {number} Number of breakpoints injected
129
+ */
130
+ function injectPromptCaching(body, provider) {
131
+ if (!needsCacheInjection(provider)) return 0;
132
+ return injectAnthropicCacheBreakpoints(body);
133
+ }
134
+
135
+ module.exports = {
136
+ injectPromptCaching,
137
+ injectAnthropicCacheBreakpoints,
138
+ injectGeminiCacheBreakpoints,
139
+ needsCacheInjection,
140
+ };
@@ -0,0 +1,41 @@
1
+ const config = require("../config");
2
+
3
+ const NATIVE_THINKING_PROVIDERS = new Set(["azure-anthropic", "databricks"]);
4
+
5
+ const NATIVE_THINKING_BEDROCK_MODELS = [
6
+ "anthropic.claude",
7
+ "claude-3",
8
+ "claude-4",
9
+ "claude-sonnet",
10
+ "claude-opus",
11
+ "claude-haiku",
12
+ ];
13
+
14
+ const REASONING_CONTENT_PROVIDERS = new Set(["moonshot", "openrouter", "openai", "azure-openai"]);
15
+
16
+ function supportsNativeThinking(providerType, model) {
17
+ if (NATIVE_THINKING_PROVIDERS.has(providerType)) return true;
18
+ if (providerType === "bedrock" && model) {
19
+ return NATIVE_THINKING_BEDROCK_MODELS.some((prefix) => model.toLowerCase().includes(prefix));
20
+ }
21
+ if (providerType === "vertex" && model) {
22
+ return model.toLowerCase().includes("claude");
23
+ }
24
+ return false;
25
+ }
26
+
27
+ function supportsReasoningContent(providerType) {
28
+ return REASONING_CONTENT_PROVIDERS.has(providerType);
29
+ }
30
+
31
+ function getThinkingBehavior(providerType, model) {
32
+ if (supportsNativeThinking(providerType, model)) return "native";
33
+ if (supportsReasoningContent(providerType)) return "reasoning_content";
34
+ return "none";
35
+ }
36
+
37
+ module.exports = {
38
+ supportsNativeThinking,
39
+ supportsReasoningContent,
40
+ getThinkingBehavior,
41
+ };
@@ -19,6 +19,7 @@ const logger = require("../logger");
19
19
  function mapClientToolToLynkr(clientToolName) {
20
20
  const reverseMapping = {
21
21
  // ============== CODEX CLI ==============
22
+ "shell": "Bash",
22
23
  "shell_command": "Bash",
23
24
  "read_file": "Read",
24
25
  "write_file": "Write",
@@ -140,13 +141,13 @@ function convertResponsesToChat(responsesRequest) {
140
141
 
141
142
  // Handle function_call (tool calls - convert to assistant with tool_calls)
142
143
  if (msg.type === 'function_call') {
143
- // Map client tool names back to Lynkr names for model consistency
144
- // Supports Codex CLI, Cline, Continue.dev
145
- const lynkrToolName = mapClientToolToLynkr(msg.name);
144
+ // Keep the client's original tool name (e.g., "shell", "read_file")
145
+ // so it matches the tool definitions injected in the Responses endpoint.
146
+ // Mapping to Lynkr names here would cause a mismatch with
147
+ // client-named tool definitions sent to the model.
146
148
  logger.debug({
147
- originalName: msg.name,
148
- mappedName: lynkrToolName
149
- }, "Mapping client tool name to Lynkr");
149
+ toolName: msg.name
150
+ }, "Preserving client tool name in function_call");
150
151
 
151
152
  return {
152
153
  role: 'assistant',
@@ -155,7 +156,7 @@ function convertResponsesToChat(responsesRequest) {
155
156
  id: msg.call_id || msg.id,
156
157
  type: 'function',
157
158
  function: {
158
- name: lynkrToolName,
159
+ name: msg.name,
159
160
  arguments: typeof msg.arguments === 'string' ? msg.arguments : JSON.stringify(msg.arguments || {})
160
161
  }
161
162
  }]
@@ -275,7 +275,7 @@ EXAMPLE: User says "explore this project" → Call Task with subagent_type="Expl
275
275
  description: "Optional model override. Default is appropriate for each agent type."
276
276
  }
277
277
  },
278
- required: ["description", "prompt", "subagent_type"]
278
+ required: ["prompt"]
279
279
  }
280
280
  },
281
281
  {