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
@@ -54,7 +54,7 @@ function detectClient(headers) {
54
54
  const clientHeader = (headers?.["x-client"] || headers?.["x-client-name"] || "").toLowerCase();
55
55
 
56
56
  // Check user-agent and custom headers
57
- if (userAgent.includes("codex") || clientHeader.includes("codex")) {
57
+ if (userAgent.includes("codex") || clientHeader.includes("codex") || userAgent.includes("openai-codex")) {
58
58
  return "codex";
59
59
  }
60
60
  // Kilo Code is a fork of Cline - check for both
@@ -77,9 +77,9 @@ function detectClient(headers) {
77
77
  */
78
78
  const CLIENT_TOOL_MAPPINGS = {
79
79
  // ============== CODEX CLI ==============
80
- // Confirmed tools: shell, apply_patch, read_file, write_file, list_dir, glob_file_search,
81
- // rg, web_search, update_plan, view_image, memory
82
- // NOT supported: spawn_agent/spawn_thread (Task has no Codex equivalent)
80
+ // Codex v0.121.0 only recognises "shell" and "apply_patch" as built-in
81
+ // tools. All other operations (read, list, grep, etc.) must go through
82
+ // shell commands the model handles this naturally.
83
83
  codex: {
84
84
  "Bash": {
85
85
  name: "shell",
@@ -87,21 +87,6 @@ const CLIENT_TOOL_MAPPINGS = {
87
87
  command: ["bash", "-c", a.command || ""]
88
88
  })
89
89
  },
90
- "Read": {
91
- name: "read_file",
92
- mapArgs: (a) => ({
93
- path: a.file_path || a.path || "",
94
- offset: a.offset,
95
- limit: a.limit
96
- })
97
- },
98
- "Write": {
99
- name: "write_file",
100
- mapArgs: (a) => ({
101
- path: a.file_path || a.path || "",
102
- content: a.content || ""
103
- })
104
- },
105
90
  "Edit": {
106
91
  name: "apply_patch",
107
92
  mapArgs: (a) => ({
@@ -109,47 +94,6 @@ const CLIENT_TOOL_MAPPINGS = {
109
94
  old_string: a.old_string || "",
110
95
  new_string: a.new_string || ""
111
96
  })
112
- },
113
- "Glob": {
114
- name: "glob_file_search",
115
- mapArgs: (a) => ({
116
- pattern: a.pattern || "",
117
- path: a.path
118
- })
119
- },
120
- "Grep": {
121
- name: "rg",
122
- mapArgs: (a) => ({
123
- pattern: a.pattern || "",
124
- path: a.path,
125
- include: a.glob || a.include,
126
- type: a.type
127
- })
128
- },
129
- "ListDir": {
130
- name: "list_dir",
131
- mapArgs: (a) => ({
132
- path: a.path || a.directory
133
- })
134
- },
135
- "TodoWrite": {
136
- name: "update_plan",
137
- mapArgs: (a) => ({
138
- todos: a.todos || []
139
- })
140
- },
141
- "WebSearch": {
142
- name: "web_search",
143
- mapArgs: (a) => ({
144
- query: a.query || ""
145
- })
146
- },
147
- "WebAgent": {
148
- name: "web_agent",
149
- mapArgs: (a) => ({
150
- url: a.url || "",
151
- goal: a.goal || ""
152
- })
153
97
  }
154
98
  },
155
99
 
@@ -422,7 +366,7 @@ router.post("/chat/completions", async (req, res) => {
422
366
  role: m.role,
423
367
  contentPreview: typeof m.content === 'string'
424
368
  ? m.content.substring(0, 200)
425
- : JSON.stringify(m.content).substring(0, 200)
369
+ : (m.content == null ? null : (JSON.stringify(m.content) ?? '').substring(0, 200))
426
370
  }));
427
371
 
428
372
  logger.debug({
@@ -456,7 +400,7 @@ router.post("/chat/completions", async (req, res) => {
456
400
  const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
457
401
  if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
458
402
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
459
- const clientTools = clientMappings
403
+ let clientTools = clientMappings
460
404
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
461
405
  : IDE_SAFE_TOOLS;
462
406
  anthropicRequest.tools = clientTools;
@@ -1474,6 +1418,29 @@ router.post("/responses", async (req, res) => {
1474
1418
  fullRequestBodyKeys: Object.keys(req.body)
1475
1419
  }, "=== RESPONSES API REQUEST ===");
1476
1420
 
1421
+ // Resolve previous_response_id for session continuity
1422
+ if (req.body.previous_response_id) {
1423
+ const responseStore = require("../stores/response-store");
1424
+ const prev = responseStore.getResponse(req.body.previous_response_id);
1425
+ if (prev && Array.isArray(prev.messages)) {
1426
+ const prevContext = [...prev.messages];
1427
+ if (prev.assistantContent) {
1428
+ prevContext.push({ role: "assistant", content: prev.assistantContent });
1429
+ }
1430
+ if (Array.isArray(req.body.input)) {
1431
+ req.body.input = [...prevContext, ...req.body.input];
1432
+ } else if (typeof req.body.input === "string") {
1433
+ req.body.input = [...prevContext, { role: "user", content: req.body.input }];
1434
+ }
1435
+ logger.debug({
1436
+ previousId: req.body.previous_response_id,
1437
+ prependedMessages: prevContext.length,
1438
+ }, "Resolved previous_response_id");
1439
+ } else {
1440
+ logger.warn({ previousId: req.body.previous_response_id }, "previous_response_id not found");
1441
+ }
1442
+ }
1443
+
1477
1444
  // Convert Responses API to Chat Completions format
1478
1445
  const chatRequest = convertResponsesToChat(req.body);
1479
1446
 
@@ -1489,6 +1456,28 @@ router.post("/responses", async (req, res) => {
1489
1456
  // Convert to Anthropic format
1490
1457
  const anthropicRequest = convertOpenAIToAnthropic(chatRequest);
1491
1458
 
1459
+ // Normalize tool_use names in conversation history to client format.
1460
+ // Tool definitions are injected with client names (e.g., "shell", "read_file"),
1461
+ // so tool_use blocks must also use client names to satisfy the Anthropic API
1462
+ // requirement that tool_use names match tool definitions.
1463
+ const clientType = detectClient(req.headers);
1464
+ const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
1465
+ if (clientMappings && Array.isArray(anthropicRequest.messages)) {
1466
+ const lynkrToClient = {};
1467
+ for (const [lynkrName, mapping] of Object.entries(clientMappings)) {
1468
+ lynkrToClient[lynkrName] = mapping.name;
1469
+ }
1470
+
1471
+ for (const msg of anthropicRequest.messages) {
1472
+ if (!Array.isArray(msg.content)) continue;
1473
+ for (const block of msg.content) {
1474
+ if (block.type === 'tool_use' && lynkrToClient[block.name]) {
1475
+ block.name = lynkrToClient[block.name];
1476
+ }
1477
+ }
1478
+ }
1479
+ }
1480
+
1492
1481
  logger.debug({
1493
1482
  anthropicMessageCount: anthropicRequest.messages?.length,
1494
1483
  anthropicMessages: anthropicRequest.messages?.map(m => ({
@@ -1497,26 +1486,73 @@ router.post("/responses", async (req, res) => {
1497
1486
  }))
1498
1487
  }, "After Chat→Anthropic conversion");
1499
1488
 
1500
- // Inject tools if client didn't send any (same two-layer filtering as chat/completions).
1501
- // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
1502
- const clientType = detectClient(req.headers);
1503
- const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
1504
- if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
1489
+ // Inject tools if the Anthropic request has none.
1490
+ // The client may have sent tools in Responses API format (top-level name)
1491
+ // which convertOpenAIToAnthropic silently drops because it expects Chat
1492
+ // Completions format ({function: {name}}). Always check the CONVERTED
1493
+ // result, not the raw request.
1494
+ const clientDisabledToolChoice = req.body.tool_choice === "none";
1495
+ if (!clientDisabledToolChoice && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
1496
+ // Exclude server-side-only tools (Task, web_search, etc.) from the
1497
+ // Responses endpoint — they can't be executed by external clients like
1498
+ // Codex and would be converted to broken shell echo commands.
1499
+ const RESPONSES_EXCLUDED_TOOLS = new Set(["Task", "AskUserQuestion", "TodoWrite", "WebSearch", "WebFetch", "WebAgent"]);
1505
1500
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
1506
- const clientTools = clientMappings
1501
+ let clientTools = (clientMappings
1507
1502
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
1508
- : IDE_SAFE_TOOLS;
1503
+ : IDE_SAFE_TOOLS
1504
+ ).filter(t => !RESPONSES_EXCLUDED_TOOLS.has(t.name));
1505
+
1506
+ // Rename tools to client-expected names so the model uses the right names
1507
+ // e.g., for Codex: "Read" → "read_file", "Bash" → "shell"
1508
+ // The lynkrToClient map above normalizes any stale Lynkr names in history
1509
+ if (clientMappings) {
1510
+ clientTools = clientTools.map(t => {
1511
+ const mapping = clientMappings[t.name];
1512
+ if (!mapping) return t;
1513
+ return {
1514
+ ...t,
1515
+ name: mapping.name,
1516
+ description: t.description || '',
1517
+ };
1518
+ });
1519
+ }
1520
+
1509
1521
  anthropicRequest.tools = clientTools;
1510
- logger.debug({
1522
+ logger.info({
1511
1523
  clientType,
1512
1524
  injectedToolCount: clientTools.length,
1513
1525
  injectedToolNames: clientTools.map(t => t.name),
1514
1526
  reason: clientMappings
1515
- ? `Known client '${clientType}' — filtered to mapped tools only`
1527
+ ? `Known client '${clientType}' — tools renamed to client conventions`
1516
1528
  : "Unknown client — injecting full IDE_SAFE_TOOLS"
1517
1529
  }, "=== INJECTING TOOLS (responses) ===");
1530
+ } else {
1531
+ logger.info({
1532
+ clientType,
1533
+ clientDisabledToolChoice,
1534
+ hasTools: !!anthropicRequest.tools,
1535
+ toolCount: anthropicRequest.tools?.length || 0,
1536
+ toolNames: anthropicRequest.tools?.map(t => t.name)?.slice(0, 10),
1537
+ reqToolChoice: req.body.tool_choice,
1538
+ reqToolsIsArray: Array.isArray(req.body.tools),
1539
+ reqToolsLength: req.body.tools?.length,
1540
+ }, "=== TOOLS NOT INJECTED (responses) ===");
1518
1541
  }
1519
1542
 
1543
+ // ALWAYS strip server-side-only tools from the Responses endpoint.
1544
+ // These can't be executed by external clients (Codex, etc.) and cause
1545
+ // infinite retry loops when the model keeps calling them.
1546
+ const RESPONSES_EXCLUDED = new Set(["Task", "AskUserQuestion", "TodoWrite", "WebSearch", "WebFetch", "WebAgent"]);
1547
+ if (Array.isArray(anthropicRequest.tools)) {
1548
+ anthropicRequest.tools = anthropicRequest.tools.filter(t => !RESPONSES_EXCLUDED.has(t.name));
1549
+ }
1550
+
1551
+ // Snapshot tool names before the orchestrator can mutate them
1552
+ const injectedToolNames = new Set(
1553
+ (anthropicRequest.tools || []).map(t => t.name)
1554
+ );
1555
+
1520
1556
  // Get session
1521
1557
  const session = getSession(sessionId);
1522
1558
 
@@ -1526,294 +1562,282 @@ router.post("/responses", async (req, res) => {
1526
1562
  res.setHeader("Content-Type", "text/event-stream");
1527
1563
  res.setHeader("Cache-Control", "no-cache");
1528
1564
  res.setHeader("Connection", "keep-alive");
1529
- res.setHeader("X-Accel-Buffering", "no"); // Prevent nginx buffering
1530
- res.flushHeaders(); // Ensure headers are sent immediately
1565
+ res.setHeader("X-Accel-Buffering", "no");
1566
+ res.flushHeaders();
1531
1567
 
1532
1568
  try {
1533
- // Force non-streaming from orchestrator
1534
1569
  anthropicRequest.stream = false;
1535
1570
 
1536
- const result = await orchestrator.processMessage({
1537
- payload: anthropicRequest,
1538
- headers: req.headers,
1539
- session: session,
1540
- options: {
1541
- maxSteps: req.body?.max_steps
1542
- }
1543
- });
1571
+ // SSE comment keepalive (spec-compliant, ignored by all clients)
1572
+ const keepalive = setInterval(() => {
1573
+ try { res.write(`: keepalive\n\n`); } catch {}
1574
+ }, 2000);
1575
+
1576
+ let result;
1577
+ try {
1578
+ result = await orchestrator.processMessage({
1579
+ payload: anthropicRequest,
1580
+ headers: req.headers,
1581
+ session: session,
1582
+ options: {
1583
+ maxSteps: req.body?.max_steps
1584
+ }
1585
+ });
1586
+ } finally {
1587
+ clearInterval(keepalive);
1588
+ }
1544
1589
 
1545
- // Debug: Log what orchestrator returned
1546
1590
  logger.debug({
1547
1591
  hasResult: !!result,
1548
1592
  hasBody: !!result?.body,
1549
- bodyKeys: result?.body ? Object.keys(result.body) : null,
1550
- bodyContent: result?.body?.content ? JSON.stringify(result.body.content).substring(0, 200) : null,
1551
1593
  bodyContentLength: result?.body?.content?.length || 0,
1552
1594
  terminationReason: result?.terminationReason
1553
1595
  }, "=== ORCHESTRATOR RESULT FOR RESPONSES API ===");
1554
1596
 
1555
1597
  // Convert back: Anthropic → OpenAI → Responses
1556
1598
  const responsesModel = resolveResponseModel(result.body, req.body.model);
1557
- const chatResponse = convertAnthropicToOpenAI(result.body, responsesModel);
1558
1599
 
1559
- logger.debug({
1560
- chatContent: chatResponse.choices?.[0]?.message?.content?.substring(0, 200),
1561
- chatContentLength: chatResponse.choices?.[0]?.message?.content?.length || 0,
1562
- hasToolCalls: !!chatResponse.choices?.[0]?.message?.tool_calls,
1563
- toolCallCount: chatResponse.choices?.[0]?.message?.tool_calls?.length || 0
1564
- }, "=== CHAT RESPONSE FOR RESPONSES API ===");
1600
+ // Guard: if orchestrator returned an error body, surface it as text
1601
+ if (result.body?.error || result.status >= 400) {
1602
+ const errMsg = result.body?.error?.message || result.body?.error || JSON.stringify(result.body);
1603
+ logger.warn({ status: result.status, error: errMsg }, "Orchestrator returned error for Responses API");
1604
+ const errChunks = [];
1605
+ const errSse = (ev, d) => { errChunks.push(`event: ${ev}\ndata: ${JSON.stringify(d)}\n\n`); };
1606
+ const errId = `resp_err_${Date.now()}`;
1607
+ errSse("response.created", { type: "response.created", response: { id: errId, object: "response", status: "in_progress", output: [], usage: null }, sequence_number: 0 });
1608
+ errSse("response.output_item.added", { type: "response.output_item.added", output_index: 0, item: { id: `msg_${Date.now()}`, type: "message", status: "in_progress", role: "assistant", content: [] }, sequence_number: 1 });
1609
+ errSse("response.output_text.delta", { type: "response.output_text.delta", item_id: `msg_${Date.now()}`, output_index: 0, content_index: 0, delta: `Error: ${errMsg}`, sequence_number: 2 });
1610
+ errSse("response.completed", { type: "response.completed", response: { id: errId, object: "response", status: "completed", output: [{ type: "message", role: "assistant", status: "completed", content: [{ type: "output_text", text: `Error: ${errMsg}` }] }], usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 } }, sequence_number: 3 });
1611
+ res.end(errChunks.join(""));
1612
+ return;
1613
+ }
1565
1614
 
1615
+ const chatResponse = convertAnthropicToOpenAI(result.body, responsesModel);
1566
1616
  const responsesResponse = convertChatToResponses(chatResponse);
1567
1617
 
1568
- // Get content and tool calls
1569
- const content = responsesResponse.content || "";
1618
+ // Clean up tool result artifacts in content
1619
+ let content = responsesResponse.content || "";
1620
+ content = content.replace(/\{"output":"((?:[^"\\]|\\.)*)"\s*(?:,"metadata":\{[^}]*\})?\}/g, (match, output) => {
1621
+ try { return JSON.parse(`"${output}"`); } catch { return output; }
1622
+ });
1623
+ content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
1570
1624
  let toolCalls = chatResponse.choices?.[0]?.message?.tool_calls || [];
1625
+
1571
1626
  const responseId = responsesResponse.id || `resp_${Date.now()}`;
1572
1627
  const messageId = `msg_${Date.now()}`;
1573
1628
  const createdAt = Math.floor(Date.now() / 1000);
1574
1629
  let sequenceNumber = 0;
1575
1630
  let outputIndex = 0;
1576
1631
 
1577
- // Check if client is a known AI coding tool and map tool names accordingly
1578
- const clientType = detectClient(req.headers);
1579
- if (clientType !== "unknown" && toolCalls.length > 0) {
1580
- logger.debug({
1581
- originalTools: toolCalls.map(t => t.function?.name),
1582
- clientType,
1583
- userAgent: req.headers["user-agent"]
1584
- }, `${clientType} client detected - mapping tool names`);
1632
+ // Fallback: if model returned empty text AND no tool calls,
1633
+ // the model may have failed to produce output. Surface what we have.
1634
+ if (!content && toolCalls.length === 0) {
1635
+ const body = result.body;
1636
+ if (Array.isArray(body?.content)) {
1637
+ content = body.content
1638
+ .filter(b => b?.type === "text" && b?.text)
1639
+ .map(b => b.text)
1640
+ .join("\n") || "(The model returned an empty response.)";
1641
+ } else {
1642
+ content = "(The model returned an empty response.)";
1643
+ }
1644
+ }
1585
1645
 
1586
- // Map Lynkr tools to client-specific equivalents
1587
- toolCalls = toolCalls.map(tc => {
1588
- const mapped = mapToolForClient(tc.function?.name || "", tc.function?.arguments || "{}", clientType);
1589
- return {
1590
- ...tc,
1591
- function: {
1592
- name: mapped.name,
1593
- arguments: mapped.arguments
1646
+ // Universal tool→shell converter: ensures every tool call becomes
1647
+ // a "shell" command that Codex (or any client) can execute.
1648
+ // Server-side tools (Task, WebSearch, etc.) are dropped entirely.
1649
+ if (toolCalls.length > 0) {
1650
+ const SERVER_TOOLS = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent", "askuserquestion", "todowrite"]);
1651
+ const converted = [];
1652
+ for (const tc of toolCalls) {
1653
+ const name = tc.function?.name || "";
1654
+ const ln = name.toLowerCase();
1655
+
1656
+ // Convert server-side tools to useful shell equivalents
1657
+ if (SERVER_TOOLS.has(ln)) {
1658
+ let exploreCmd = "ls -la && head -100 README.md 2>/dev/null && cat package.json 2>/dev/null && ls src/ 2>/dev/null";
1659
+ if (ln === "task") {
1660
+ try {
1661
+ const taskArgs = JSON.parse(tc.function?.arguments || "{}");
1662
+ if (taskArgs.prompt && taskArgs.prompt.toLowerCase().includes("read")) {
1663
+ exploreCmd = "cat README.md 2>/dev/null && cat package.json 2>/dev/null";
1664
+ }
1665
+ } catch {}
1594
1666
  }
1595
- };
1596
- });
1667
+ converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", exploreCmd] }) } });
1668
+ continue;
1669
+ }
1597
1670
 
1598
- logger.debug({
1599
- mappedTools: toolCalls.map(t => t.function?.name)
1600
- }, `Tool names mapped for ${clientType}`);
1671
+ let args = {};
1672
+ try { args = JSON.parse(tc.function?.arguments || "{}"); } catch {}
1673
+
1674
+ // Already a shell command — normalise to array format
1675
+ if (ln === "shell" || ln === "bash") {
1676
+ let cmd = args.command || "";
1677
+ // Handle string-encoded arrays: '["bash","-c","ls"]'
1678
+ if (typeof cmd === "string" && cmd.trim().startsWith("[")) {
1679
+ try { cmd = JSON.parse(cmd); } catch {}
1680
+ }
1681
+ // Extract the actual command from ["bash", "-c", "actual command"]
1682
+ if (Array.isArray(cmd) && cmd.length >= 3 && cmd[0] === "bash" && cmd[1] === "-c") {
1683
+ converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: cmd }) } });
1684
+ } else if (Array.isArray(cmd)) {
1685
+ converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", cmd.join(" ")] }) } });
1686
+ } else {
1687
+ converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", String(cmd)] }) } });
1688
+ }
1689
+ continue;
1690
+ }
1691
+
1692
+ // Convert known tools to equivalent shell commands
1693
+ let shellCmd = "";
1694
+ if (ln === "read" || ln === "read_file" || ln === "cat") {
1695
+ shellCmd = `cat ${args.file_path || args.path || args.file || ""}`;
1696
+ } else if (ln === "list_dir" || ln === "listdir" || ln === "ls") {
1697
+ shellCmd = `ls -la ${args.path || args.directory || "."}`;
1698
+ } else if (ln === "grep" || ln === "rg" || ln === "search" || ln === "search_files") {
1699
+ shellCmd = `grep -rn '${(args.pattern || args.query || "").replace(/'/g, "'\\''")}' ${args.path || "."}`;
1700
+ } else if (ln === "glob" || ln === "glob_file_search" || ln === "find") {
1701
+ shellCmd = `find ${args.path || "."} -name '${(args.pattern || "*").replace(/'/g, "'\\''")}'`;
1702
+ } else if (ln === "write" || ln === "write_file" || ln === "create_file") {
1703
+ const p = args.file_path || args.path || args.file || "/dev/null";
1704
+ shellCmd = `cat > '${p}' << 'LYNKR_EOF'\n${args.content || ""}\nLYNKR_EOF`;
1705
+ } else if (ln === "edit" || ln === "apply_patch" || ln === "replace_in_file") {
1706
+ converted.push({ ...tc, function: { name: "apply_patch", arguments: tc.function?.arguments || "{}" } });
1707
+ continue;
1708
+ } else {
1709
+ // Truly unknown tool — safe echo
1710
+ shellCmd = `echo 'Unknown tool: ${ln}'`;
1711
+ }
1712
+
1713
+ converted.push({ ...tc, function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", shellCmd] }) } });
1714
+ }
1715
+ toolCalls = converted;
1716
+
1717
+ // If somehow all tools were dropped with nothing left, provide a default exploration
1718
+ if (toolCalls.length === 0 && !content) {
1719
+ toolCalls.push({
1720
+ id: `call_${Date.now()}`,
1721
+ type: "function",
1722
+ function: { name: "shell", arguments: JSON.stringify({ command: ["bash", "-c", "ls -la && head -80 README.md 2>/dev/null"] }) }
1723
+ });
1724
+ }
1601
1725
  }
1602
1726
 
1603
- logger.debug({
1604
- content: content.substring(0, 100),
1605
- contentLength: content.length,
1606
- toolCallCount: toolCalls.length,
1607
- toolCallNames: toolCalls.map(t => t.function?.name),
1608
- clientType
1609
- }, "=== RESPONSES API STREAMING DATA ===");
1727
+ // Build the entire SSE payload as a single buffer so the client
1728
+ // receives all events atomically (prevents premature disconnect).
1729
+ const chunks = [];
1730
+ const sse = (event, data) => {
1731
+ chunks.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1732
+ };
1610
1733
 
1611
- // 1. Send response.created event
1612
- const createdEvent = {
1734
+ // response.created
1735
+ sse("response.created", {
1613
1736
  type: "response.created",
1614
1737
  response: {
1615
- id: responseId,
1616
- object: "response",
1617
- status: "in_progress",
1618
- created_at: createdAt,
1619
- model: responsesModel,
1620
- output: [],
1621
- usage: null
1738
+ id: responseId, object: "response", status: "in_progress",
1739
+ created_at: createdAt, model: responsesModel, output: [], usage: null
1622
1740
  },
1623
1741
  sequence_number: sequenceNumber++
1624
- };
1625
- res.write(`event: response.created\n`);
1626
- res.write(`data: ${JSON.stringify(createdEvent)}\n\n`);
1742
+ });
1627
1743
 
1628
- // 2. Send response.in_progress event
1629
- const inProgressEvent = {
1744
+ // response.in_progress
1745
+ sse("response.in_progress", {
1630
1746
  type: "response.in_progress",
1631
1747
  response: {
1632
- id: responseId,
1633
- object: "response",
1634
- status: "in_progress",
1635
- created_at: createdAt,
1636
- model: responsesModel,
1637
- output: [],
1638
- usage: null
1748
+ id: responseId, object: "response", status: "in_progress",
1749
+ created_at: createdAt, model: responsesModel, output: [], usage: null
1639
1750
  },
1640
1751
  sequence_number: sequenceNumber++
1641
- };
1642
- res.write(`event: response.in_progress\n`);
1643
- res.write(`data: ${JSON.stringify(inProgressEvent)}\n\n`);
1752
+ });
1644
1753
 
1645
- // Build output array for the final response
1646
1754
  const outputItems = [];
1647
1755
 
1648
- // Handle tool calls first (if any)
1756
+ // Function call events
1649
1757
  for (const toolCall of toolCalls) {
1650
1758
  const toolCallId = toolCall.id || `call_${Date.now()}_${outputIndex}`;
1651
1759
  const functionName = toolCall.function?.name || "unknown";
1652
1760
  const functionArgs = toolCall.function?.arguments || "{}";
1653
1761
 
1654
- // Send function_call output item added
1655
1762
  const functionCallItem = {
1656
- id: toolCallId,
1657
- type: "function_call",
1658
- status: "completed",
1659
- name: functionName,
1660
- arguments: functionArgs,
1661
- call_id: toolCallId
1763
+ id: toolCallId, type: "function_call", status: "completed",
1764
+ name: functionName, arguments: functionArgs, call_id: toolCallId
1662
1765
  };
1663
1766
 
1664
- res.write(`event: response.output_item.added\n`);
1665
- res.write(`data: ${JSON.stringify({
1666
- type: "response.output_item.added",
1667
- output_index: outputIndex,
1668
- item: functionCallItem,
1669
- sequence_number: sequenceNumber++
1670
- })}\n\n`);
1671
-
1672
- // Send function call arguments delta
1673
- res.write(`event: response.function_call_arguments.delta\n`);
1674
- res.write(`data: ${JSON.stringify({
1675
- type: "response.function_call_arguments.delta",
1676
- item_id: toolCallId,
1677
- output_index: outputIndex,
1678
- delta: functionArgs,
1679
- sequence_number: sequenceNumber++
1680
- })}\n\n`);
1681
-
1682
- // Send function call arguments done
1683
- res.write(`event: response.function_call_arguments.done\n`);
1684
- res.write(`data: ${JSON.stringify({
1685
- type: "response.function_call_arguments.done",
1686
- item_id: toolCallId,
1687
- output_index: outputIndex,
1688
- arguments: functionArgs,
1689
- sequence_number: sequenceNumber++
1690
- })}\n\n`);
1691
-
1692
- // Send output item done
1693
- res.write(`event: response.output_item.done\n`);
1694
- res.write(`data: ${JSON.stringify({
1695
- type: "response.output_item.done",
1696
- output_index: outputIndex,
1697
- item: functionCallItem,
1698
- sequence_number: sequenceNumber++
1699
- })}\n\n`);
1767
+ sse("response.output_item.added", {
1768
+ type: "response.output_item.added", output_index: outputIndex,
1769
+ item: functionCallItem, sequence_number: sequenceNumber++
1770
+ });
1771
+ sse("response.function_call_arguments.delta", {
1772
+ type: "response.function_call_arguments.delta", item_id: toolCallId,
1773
+ output_index: outputIndex, delta: functionArgs, sequence_number: sequenceNumber++
1774
+ });
1775
+ sse("response.function_call_arguments.done", {
1776
+ type: "response.function_call_arguments.done", item_id: toolCallId,
1777
+ output_index: outputIndex, arguments: functionArgs, sequence_number: sequenceNumber++
1778
+ });
1779
+ sse("response.output_item.done", {
1780
+ type: "response.output_item.done", output_index: outputIndex,
1781
+ item: functionCallItem, sequence_number: sequenceNumber++
1782
+ });
1700
1783
 
1701
1784
  outputItems.push(functionCallItem);
1702
1785
  outputIndex++;
1703
1786
  }
1704
1787
 
1705
- // Handle text content (if any)
1788
+ // Text content events
1706
1789
  if (content) {
1707
- // 3. Send response.output_item.added event for message
1708
- const outputItemAddedEvent = {
1709
- type: "response.output_item.added",
1710
- output_index: outputIndex,
1711
- item: {
1712
- id: messageId,
1713
- type: "message",
1714
- status: "in_progress",
1715
- role: "assistant",
1716
- content: []
1717
- },
1790
+ sse("response.output_item.added", {
1791
+ type: "response.output_item.added", output_index: outputIndex,
1792
+ item: { id: messageId, type: "message", status: "in_progress", role: "assistant", content: [] },
1718
1793
  sequence_number: sequenceNumber++
1719
- };
1720
- res.write(`event: response.output_item.added\n`);
1721
- res.write(`data: ${JSON.stringify(outputItemAddedEvent)}\n\n`);
1722
-
1723
- // 4. Send response.content_part.added event
1724
- const contentPartAddedEvent = {
1725
- type: "response.content_part.added",
1726
- item_id: messageId,
1727
- output_index: outputIndex,
1728
- content_index: 0,
1729
- part: {
1730
- type: "output_text",
1731
- text: ""
1732
- },
1733
- sequence_number: sequenceNumber++
1734
- };
1735
- res.write(`event: response.content_part.added\n`);
1736
- res.write(`data: ${JSON.stringify(contentPartAddedEvent)}\n\n`);
1794
+ });
1795
+ sse("response.content_part.added", {
1796
+ type: "response.content_part.added", item_id: messageId,
1797
+ output_index: outputIndex, content_index: 0,
1798
+ part: { type: "output_text", text: "" }, sequence_number: sequenceNumber++
1799
+ });
1737
1800
 
1738
- // 5. Send content in word chunks using response.output_text.delta
1739
1801
  const words = content.split(" ");
1740
1802
  for (let i = 0; i < words.length; i++) {
1741
1803
  const word = words[i] + (i < words.length - 1 ? " " : "");
1742
- const deltaEvent = {
1743
- type: "response.output_text.delta",
1744
- item_id: messageId,
1745
- output_index: outputIndex,
1746
- content_index: 0,
1747
- delta: word,
1748
- sequence_number: sequenceNumber++
1749
- };
1750
- res.write(`event: response.output_text.delta\n`);
1751
- res.write(`data: ${JSON.stringify(deltaEvent)}\n\n`);
1804
+ sse("response.output_text.delta", {
1805
+ type: "response.output_text.delta", item_id: messageId,
1806
+ output_index: outputIndex, content_index: 0,
1807
+ delta: word, sequence_number: sequenceNumber++
1808
+ });
1752
1809
  }
1753
1810
 
1754
- // 6. Send response.output_text.done event
1755
- const textDoneEvent = {
1756
- type: "response.output_text.done",
1757
- item_id: messageId,
1758
- output_index: outputIndex,
1759
- content_index: 0,
1760
- text: content,
1761
- sequence_number: sequenceNumber++
1762
- };
1763
- res.write(`event: response.output_text.done\n`);
1764
- res.write(`data: ${JSON.stringify(textDoneEvent)}\n\n`);
1765
-
1766
- // 7. Send response.content_part.done event
1767
- const contentPartDoneEvent = {
1768
- type: "response.content_part.done",
1769
- item_id: messageId,
1770
- output_index: outputIndex,
1771
- content_index: 0,
1772
- part: {
1773
- type: "output_text",
1774
- text: content
1775
- },
1776
- sequence_number: sequenceNumber++
1777
- };
1778
- res.write(`event: response.content_part.done\n`);
1779
- res.write(`data: ${JSON.stringify(contentPartDoneEvent)}\n\n`);
1811
+ sse("response.output_text.done", {
1812
+ type: "response.output_text.done", item_id: messageId,
1813
+ output_index: outputIndex, content_index: 0,
1814
+ text: content, sequence_number: sequenceNumber++
1815
+ });
1816
+ sse("response.content_part.done", {
1817
+ type: "response.content_part.done", item_id: messageId,
1818
+ output_index: outputIndex, content_index: 0,
1819
+ part: { type: "output_text", text: content }, sequence_number: sequenceNumber++
1820
+ });
1780
1821
 
1781
- // 8. Send response.output_item.done event for message
1782
1822
  const messageItem = {
1783
- id: messageId,
1784
- type: "message",
1785
- status: "completed",
1786
- role: "assistant",
1787
- content: [
1788
- {
1789
- type: "output_text",
1790
- text: content
1791
- }
1792
- ]
1793
- };
1794
- const outputItemDoneEvent = {
1795
- type: "response.output_item.done",
1796
- output_index: outputIndex,
1797
- item: messageItem,
1798
- sequence_number: sequenceNumber++
1823
+ id: messageId, type: "message", status: "completed", role: "assistant",
1824
+ content: [{ type: "output_text", text: content }]
1799
1825
  };
1800
- res.write(`event: response.output_item.done\n`);
1801
- res.write(`data: ${JSON.stringify(outputItemDoneEvent)}\n\n`);
1826
+ sse("response.output_item.done", {
1827
+ type: "response.output_item.done", output_index: outputIndex,
1828
+ item: messageItem, sequence_number: sequenceNumber++
1829
+ });
1802
1830
 
1803
1831
  outputItems.push(messageItem);
1804
1832
  outputIndex++;
1805
1833
  }
1806
1834
 
1807
- // 9. Send response.completed event (OpenAI Responses API format)
1808
- const completedEvent = {
1835
+ // response.completed (always last)
1836
+ sse("response.completed", {
1809
1837
  type: "response.completed",
1810
1838
  response: {
1811
- id: responseId,
1812
- object: "response",
1813
- status: "completed",
1814
- created_at: createdAt,
1815
- model: responsesModel,
1816
- output: outputItems,
1839
+ id: responseId, object: "response", status: "completed",
1840
+ created_at: createdAt, model: responsesModel, output: outputItems,
1817
1841
  usage: {
1818
1842
  input_tokens: responsesResponse.usage?.prompt_tokens || 0,
1819
1843
  output_tokens: responsesResponse.usage?.completion_tokens || 0,
@@ -1821,34 +1845,52 @@ router.post("/responses", async (req, res) => {
1821
1845
  }
1822
1846
  },
1823
1847
  sequence_number: sequenceNumber++
1824
- };
1825
- res.write(`event: response.completed\n`);
1826
- res.write(`data: ${JSON.stringify(completedEvent)}\n\n`);
1848
+ });
1827
1849
 
1828
- res.end();
1850
+ // Write entire payload and close in one call
1851
+ const payload = chunks.join("");
1852
+ res.end(payload);
1853
+
1854
+ // Store response for previous_response_id continuity
1855
+ try {
1856
+ const responseStore = require("../stores/response-store");
1857
+ responseStore.storeResponse(responseId, {
1858
+ messages: chatRequest?.messages || [],
1859
+ assistantContent: content || null,
1860
+ });
1861
+ } catch {}
1829
1862
 
1830
1863
  logger.info({
1831
1864
  duration: Date.now() - startTime,
1832
1865
  mode: "streaming",
1833
1866
  contentLength: content.length,
1834
1867
  toolCallCount: toolCalls.length,
1835
- sequenceNumber: sequenceNumber
1868
+ payloadBytes: payload.length
1836
1869
  }, "=== RESPONSES API STREAMING COMPLETE ===");
1837
1870
 
1838
1871
  } catch (streamError) {
1839
1872
  logger.error({ error: streamError.message, stack: streamError.stack }, "Responses API streaming error");
1840
1873
 
1841
- // Send error via SSE
1842
- res.write(`event: error\n`);
1843
- res.write(`data: ${JSON.stringify({
1844
- type: "error",
1845
- error: {
1846
- message: streamError.message || "Internal server error",
1847
- type: "server_error",
1848
- code: "internal_error"
1849
- }
1850
- })}\n\n`);
1851
- res.end();
1874
+ // Build error as a complete SSE payload with response.completed
1875
+ const errorResponseId = `resp_err_${Date.now()}`;
1876
+ const errorPayload = [
1877
+ `event: response.created\ndata: ${JSON.stringify({
1878
+ type: "response.created",
1879
+ response: { id: errorResponseId, object: "response", status: "in_progress", output: [], usage: null },
1880
+ sequence_number: 0
1881
+ })}\n\n`,
1882
+ `event: response.completed\ndata: ${JSON.stringify({
1883
+ type: "response.completed",
1884
+ response: {
1885
+ id: errorResponseId, object: "response", status: "failed",
1886
+ output: [{ type: "message", role: "assistant", status: "completed",
1887
+ content: [{ type: "output_text", text: `Error: ${streamError.message || "Internal server error"}` }] }],
1888
+ usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
1889
+ },
1890
+ sequence_number: 1
1891
+ })}\n\n`
1892
+ ].join("");
1893
+ try { res.end(errorPayload); } catch { try { res.end(); } catch {} }
1852
1894
  }
1853
1895
 
1854
1896
  } else {
@@ -1868,6 +1910,16 @@ router.post("/responses", async (req, res) => {
1868
1910
  const chatResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
1869
1911
  const responsesResponse = convertChatToResponses(chatResponse);
1870
1912
 
1913
+ // Clean up tool result artifacts in content
1914
+ if (responsesResponse.content) {
1915
+ responsesResponse.content = responsesResponse.content
1916
+ .replace(/\{"output":"((?:[^"\\]|\\.)*)"\s*(?:,"metadata":\{[^}]*\})?\}/g, (match, output) => {
1917
+ try { return JSON.parse(`"${output}"`); } catch { return output; }
1918
+ })
1919
+ .replace(/\\n/g, '\n')
1920
+ .replace(/\\t/g, '\t');
1921
+ }
1922
+
1871
1923
  logger.info({
1872
1924
  duration: Date.now() - startTime,
1873
1925
  contentLength: responsesResponse.content?.length || 0,