karajan-code 1.58.2 → 1.59.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/agents/aider-agent.js +7 -0
  3. package/src/agents/claude-agent.js +3 -0
  4. package/src/agents/codex-agent.js +4 -0
  5. package/src/agents/gemini-agent.js +4 -0
  6. package/src/agents/opencode-agent.js +4 -0
  7. package/src/bootstrap.js +20 -9
  8. package/src/commands/doctor.js +19 -0
  9. package/src/commands/report.js +13 -0
  10. package/src/commands/status.js +7 -0
  11. package/src/config.js +35 -0
  12. package/src/git/automation.js +32 -4
  13. package/src/guards/policy-guard.js +90 -0
  14. package/src/mcp/handlers/run-handler.js +20 -3
  15. package/src/mcp/shared-helpers.js +4 -2
  16. package/src/orchestrator.js +67 -20
  17. package/src/prompts/coder.js +5 -4
  18. package/src/prompts/reviewer.js +5 -4
  19. package/src/prompts/rtk-snippet.js +12 -0
  20. package/src/proxy/adapters/anthropic.js +131 -0
  21. package/src/proxy/adapters/gemini.js +106 -0
  22. package/src/proxy/adapters/index.js +51 -0
  23. package/src/proxy/adapters/openai.js +71 -0
  24. package/src/proxy/compression/ai-compressor.js +209 -0
  25. package/src/proxy/compression/dedup-cache.js +100 -0
  26. package/src/proxy/compression/deterministic/bash-build.js +60 -0
  27. package/src/proxy/compression/deterministic/bash-git.js +101 -0
  28. package/src/proxy/compression/deterministic/bash-infra.js +64 -0
  29. package/src/proxy/compression/deterministic/bash-misc.js +53 -0
  30. package/src/proxy/compression/deterministic/bash-pkg.js +49 -0
  31. package/src/proxy/compression/deterministic/bash-test.js +61 -0
  32. package/src/proxy/compression/deterministic/glob.js +26 -0
  33. package/src/proxy/compression/deterministic/grep.js +53 -0
  34. package/src/proxy/compression/deterministic/index.js +58 -0
  35. package/src/proxy/compression/deterministic/read.js +22 -0
  36. package/src/proxy/compression/deterministic/utils.js +33 -0
  37. package/src/proxy/compression/pipeline.js +270 -0
  38. package/src/proxy/middleware/prompt-injector.js +165 -0
  39. package/src/proxy/middleware/response-interceptor.js +223 -0
  40. package/src/proxy/progress-monitor.js +98 -0
  41. package/src/proxy/proxy-lifecycle.js +198 -0
  42. package/src/proxy/proxy-server.js +239 -0
  43. package/src/proxy/security.js +45 -0
  44. package/src/review/diff-generator.js +10 -8
  45. package/src/roles/coder-role.js +1 -0
  46. package/src/roles/reviewer-role.js +6 -4
  47. package/src/session-cleanup.js +39 -15
  48. package/src/sonar/cloud-scanner.js +6 -3
  49. package/src/sonar/scanner.js +12 -7
  50. package/src/utils/display.js +44 -4
  51. package/src/utils/git.js +5 -0
@@ -47,6 +47,7 @@ import { setRunner as setDiffRunner } from "./review/diff-generator.js";
47
47
  import { setRunner as setGitRunner } from "./utils/git.js";
48
48
  import { detectNeededSkills, autoInstallSkills, cleanupAutoInstalledSkills } from "./skills/skill-detector.js";
49
49
  import { isOpenSkillsAvailable } from "./skills/openskills-client.js";
50
+ import { startProxy, stopProxy } from "./proxy/proxy-lifecycle.js";
50
51
 
51
52
 
52
53
  // --- Product Context loader ---
@@ -104,8 +105,8 @@ async function handleDryRun({ task, config, flags, emitter, pipelineFlags }) {
104
105
  const projectDir = config.projectDir || process.cwd();
105
106
  const { rules: reviewRules } = await resolveReviewProfile({ mode: config.review_mode, projectDir });
106
107
  const coderRules = await loadFirstExisting(resolveRoleMdPath("coder", projectDir));
107
- const coderPrompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
108
- const reviewerPrompt = await buildReviewerPrompt({ task, diff: "(dry-run: no diff)", reviewRules, mode: config.review_mode, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), productContext: config.productContext || null });
108
+ const coderPrompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), proxyEnabled: Boolean(config.proxy?.enabled), productContext: config.productContext || null });
109
+ const reviewerPrompt = await buildReviewerPrompt({ task, diff: "(dry-run: no diff)", reviewRules, mode: config.review_mode, serenaEnabled: Boolean(config.serena?.enabled), rtkAvailable: Boolean(config.rtk?.available), proxyEnabled: Boolean(config.proxy?.enabled), productContext: config.productContext || null });
109
110
 
110
111
  const summary = {
111
112
  dry_run: true,
@@ -380,6 +381,24 @@ function takeCheckpointSnapshot(session) {
380
381
  };
381
382
  }
382
383
 
384
+ /**
385
+ * Determine if checkpoint should auto-continue without asking the user.
386
+ * Exported for testing.
387
+ */
388
+ export function shouldAutoContinueCheckpoint(session, hasProgress) {
389
+ if (hasProgress) {
390
+ session._checkpoint_stall_count = 0;
391
+ return { autoContinue: true, reason: "progress_detected" };
392
+ }
393
+ const wasRateLimited = (session.standby_retry_count || 0) > 0;
394
+ const consecutiveStalls = (session._checkpoint_stall_count || 0) + 1;
395
+ session._checkpoint_stall_count = consecutiveStalls;
396
+ if (wasRateLimited && consecutiveStalls < 3) {
397
+ return { autoContinue: true, reason: "recoverable_stall" };
398
+ }
399
+ return { autoContinue: false, reason: consecutiveStalls >= 3 ? "max_stalls_reached" : "no_progress" };
400
+ }
401
+
383
402
  async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpointAt, checkpointIntervalMs, elapsedMinutes, i, config, budgetTracker, stageResults, emitter, eventBase, session, budgetSummary, lastCheckpointSnapshot }) {
384
403
  if (checkpointDisabled || !askQuestion || (Date.now() - lastCheckpointAt) < checkpointIntervalMs) {
385
404
  return { action: "continue_loop", checkpointDisabled, lastCheckpointAt, lastCheckpointSnapshot };
@@ -388,22 +407,23 @@ async function handleCheckpoint({ checkpointDisabled, askQuestion, lastCheckpoin
388
407
  const elapsedStr = elapsedMinutes.toFixed(1);
389
408
  const stagesCompleted = Object.keys(stageResults).join(", ") || "none";
390
409
 
391
- // Auto-continue if progress detected since last checkpoint
410
+ // Decide whether to auto-continue or ask the user
392
411
  const hasProgress = detectCheckpointProgress(session, lastCheckpointSnapshot);
393
412
  const newSnapshot = takeCheckpointSnapshot(session);
413
+ const decision = shouldAutoContinueCheckpoint(session, hasProgress);
394
414
 
395
- if (hasProgress) {
415
+ if (decision.autoContinue) {
396
416
  emitProgress(
397
417
  emitter,
398
418
  makeEvent("session:checkpoint", { ...eventBase, iteration: i, stage: "checkpoint" }, {
399
- message: `Checkpoint: progress detected, continuing (${elapsedStr} min elapsed)`,
400
- detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: true, executorType: "system" }
419
+ message: `Checkpoint: ${decision.reason === "progress_detected" ? "progress detected" : "stall caused by rate limit/cooldown"}, auto-continuing (${elapsedStr} min elapsed)`,
420
+ detail: { elapsed_minutes: Number(elapsedStr), iterations_done: i - 1, stages: stagesCompleted, auto_continued: true, reason: decision.reason }
401
421
  })
402
422
  );
403
423
  return { action: "continue_loop", checkpointDisabled, lastCheckpointAt: Date.now(), lastCheckpointSnapshot: newSnapshot };
404
424
  }
405
425
 
406
- // No progress — ask human
426
+ // No progress and not recoverable — ask human
407
427
  const iterInfo = `${i - 1}/${config.max_iterations} iterations completed`;
408
428
  const budgetInfo = budgetTracker.total().cost_usd > 0 ? ` | Budget: $${budgetTracker.total().cost_usd.toFixed(2)}` : "";
409
429
  const checkpointMsg = `Checkpoint — ${elapsedStr} min elapsed | ${iterInfo}${budgetInfo} | Stages completed: ${stagesCompleted}. No progress since last checkpoint. What would you like to do?`;
@@ -1310,19 +1330,23 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
1310
1330
  ctx.trackBudget = trackBudget;
1311
1331
 
1312
1332
  // --- RTK detection ---
1313
- const rtkResult = await detectRtk();
1314
- if (rtkResult.available) {
1315
- config = { ...config, rtk: { available: true, version: rtkResult.version } };
1316
- const rtkTracker = new RtkSavingsTracker();
1317
- const rtkRunner = createRtkRunner(true, rtkTracker);
1318
- setDiffRunner(rtkRunner);
1319
- setGitRunner(rtkRunner);
1320
- ctx.rtkTracker = rtkTracker;
1321
- logger.info(`RTK detected (${rtkResult.version}) — wrapping internal git/diff commands with rtk`);
1322
- emitProgress(emitter, makeEvent("rtk:detected", ctx.eventBase, {
1323
- message: "RTK detected — internal commands wrapped for token optimization",
1324
- detail: { version: rtkResult.version, executorType: "local" }
1325
- }));
1333
+ if (config.proxy?.enabled) {
1334
+ logger.info("Proxy compression active, RTK not needed");
1335
+ } else {
1336
+ const rtkResult = await detectRtk();
1337
+ if (rtkResult.available) {
1338
+ config = { ...config, rtk: { available: true, version: rtkResult.version } };
1339
+ const rtkTracker = new RtkSavingsTracker();
1340
+ const rtkRunner = createRtkRunner(true, rtkTracker);
1341
+ setDiffRunner(rtkRunner);
1342
+ setGitRunner(rtkRunner);
1343
+ ctx.rtkTracker = rtkTracker;
1344
+ logger.info(`RTK detected (${rtkResult.version}) — wrapping internal git/diff commands with rtk`);
1345
+ emitProgress(emitter, makeEvent("rtk:detected", ctx.eventBase, {
1346
+ message: "RTK detected — internal commands wrapped for token optimization",
1347
+ detail: { version: rtkResult.version, executorType: "local" }
1348
+ }));
1349
+ }
1326
1350
  }
1327
1351
 
1328
1352
  // --- HU Board auto-start ---
@@ -1348,6 +1372,21 @@ async function initFlowContext({ task, config, logger, emitter, askQuestion, pgT
1348
1372
  ctx.pgCard = pgAdapterResult.pgCard;
1349
1373
  ctx.session.pg_card = ctx.pgCard || null;
1350
1374
 
1375
+ // --- Proxy startup ---
1376
+ if (config.proxy?.enabled !== false) {
1377
+ try {
1378
+ const proxyResult = await startProxy({ config: config.proxy || {}, sessionId: ctx.session.id });
1379
+ ctx.proxyPort = proxyResult.port;
1380
+ logger.info(`Proxy started on 127.0.0.1:${proxyResult.port}`);
1381
+ emitProgress(emitter, makeEvent("proxy:started", ctx.eventBase, {
1382
+ message: `Proxy started on port ${proxyResult.port}`,
1383
+ detail: { port: proxyResult.port, baseUrls: proxyResult.baseUrls }
1384
+ }));
1385
+ } catch (err) {
1386
+ logger.warn(`Proxy startup failed (non-blocking): ${err.message}`);
1387
+ }
1388
+ }
1389
+
1351
1390
  emitProgress(
1352
1391
  emitter,
1353
1392
  makeEvent("session:start", ctx.eventBase, {
@@ -1618,6 +1657,14 @@ export async function runFlow({ task, config, logger, flags = {}, emitter = null
1618
1657
  await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
1619
1658
  return result;
1620
1659
  } finally {
1660
+ // --- Proxy shutdown ---
1661
+ try {
1662
+ await stopProxy();
1663
+ if (ctx.proxyPort) {
1664
+ logger.info("Proxy stopped");
1665
+ }
1666
+ } catch { /* non-blocking */ }
1667
+
1621
1668
  // --- Cleanup auto-installed skills ---
1622
1669
  const autoSkills = ctx.session?.autoInstalledSkills;
1623
1670
  if (autoSkills && autoSkills.length > 0) {
@@ -1,4 +1,4 @@
1
- import { RTK_INSTRUCTIONS } from "./rtk-snippet.js";
1
+ import { buildRtkInstructions } from "./rtk-snippet.js";
2
2
  import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
 
@@ -33,7 +33,7 @@ const SERENA_INSTRUCTIONS = [
33
33
  "Fall back to reading files only when Serena tools are not sufficient."
34
34
  ].join("\n");
35
35
 
36
- export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en" }) {
36
+ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, proxyEnabled = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en" }) {
37
37
  const langInstruction = getLanguageInstruction(language);
38
38
  const sections = [
39
39
  serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
@@ -48,8 +48,9 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
48
48
  sections.push(SERENA_INSTRUCTIONS);
49
49
  }
50
50
 
51
- if (rtkAvailable) {
52
- sections.push(RTK_INSTRUCTIONS);
51
+ const rtkSnippet = buildRtkInstructions({ rtkAvailable, proxyEnabled });
52
+ if (rtkSnippet) {
53
+ sections.push(rtkSnippet);
53
54
  }
54
55
 
55
56
  if (productContext) {
@@ -1,4 +1,4 @@
1
- import { RTK_INSTRUCTIONS } from "./rtk-snippet.js";
1
+ import { buildRtkInstructions } from "./rtk-snippet.js";
2
2
  import { loadAvailableSkills, buildSkillSection } from "../skills/skill-loader.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
 
@@ -24,7 +24,7 @@ const SERENA_INSTRUCTIONS = [
24
24
  "Fall back to reading files only when Serena tools are not sufficient."
25
25
  ].join("\n");
26
26
 
27
- export async function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, productContext = null, domainContext = null, projectDir = null, language = "en" }) {
27
+ export async function buildReviewerPrompt({ task, diff, reviewRules, mode, serenaEnabled = false, rtkAvailable = false, proxyEnabled = false, productContext = null, domainContext = null, projectDir = null, language = "en" }) {
28
28
  const truncatedDiff = diff.length > 12000 ? `${diff.slice(0, 12000)}\n\n[TRUNCATED]` : diff;
29
29
 
30
30
  const langInstruction = getLanguageInstruction(language);
@@ -43,8 +43,9 @@ export async function buildReviewerPrompt({ task, diff, reviewRules, mode, seren
43
43
  sections.push(SERENA_INSTRUCTIONS);
44
44
  }
45
45
 
46
- if (rtkAvailable) {
47
- sections.push(RTK_INSTRUCTIONS);
46
+ const rtkSnippet = buildRtkInstructions({ rtkAvailable, proxyEnabled });
47
+ if (rtkSnippet) {
48
+ sections.push(rtkSnippet);
48
49
  }
49
50
 
50
51
  if (productContext) {
@@ -13,3 +13,15 @@ export const RTK_INSTRUCTIONS = [
13
13
  "- Use `rtk cat` instead of `cat`",
14
14
  "This does NOT apply to non-Bash tools (Read, Write, Edit, Glob, Grep)."
15
15
  ].join("\n");
16
+
17
+ /**
18
+ * Build RTK instructions, returning empty string when proxy compression
19
+ * handles token optimization at the HTTP layer.
20
+ * @param {{ rtkAvailable?: boolean, proxyEnabled?: boolean }} options
21
+ * @returns {string}
22
+ */
23
+ export function buildRtkInstructions({ rtkAvailable = false, proxyEnabled = false } = {}) {
24
+ if (proxyEnabled) return "";
25
+ if (!rtkAvailable) return "";
26
+ return RTK_INSTRUCTIONS;
27
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Anthropic message format adapter.
3
+ *
4
+ * Anthropic messages API uses an array of messages where each message has a
5
+ * `content` array. Tool results appear as content blocks with
6
+ * `type: "tool_result"`. Each tool_result block contains an `id` (matching
7
+ * the prior tool_use), optional `tool_use_id`, and nested `content` that
8
+ * can be a string or an array of content blocks (text, image, etc.).
9
+ *
10
+ * @module
11
+ */
12
+
13
+ /**
14
+ * Extract tool results from Anthropic-format messages.
15
+ *
16
+ * @param {Array<object>} messages - Anthropic messages array
17
+ * @returns {Array<{id: string, toolName: string, text: string, turnIndex: number}>}
18
+ */
19
+ export function extractToolResults(messages) {
20
+ const results = [];
21
+
22
+ for (let i = 0; i < messages.length; i++) {
23
+ const msg = messages[i];
24
+ if (!msg || !Array.isArray(msg.content)) continue;
25
+
26
+ // Collect tool_use names from assistant messages so we can map ids to names
27
+ const toolNameMap = buildToolNameMap(messages, i);
28
+
29
+ for (const block of msg.content) {
30
+ if (block.type !== "tool_result") continue;
31
+
32
+ const id = block.tool_use_id || block.id || "";
33
+ const text = extractText(block);
34
+ const toolName = toolNameMap.get(id) || "unknown";
35
+
36
+ results.push({ id, toolName, text, turnIndex: i });
37
+ }
38
+ }
39
+
40
+ return results;
41
+ }
42
+
43
+ /**
44
+ * Rebuild messages replacing tool_result text with compressed versions.
45
+ *
46
+ * @param {Array<object>} messages - Original Anthropic messages array
47
+ * @param {Map<string, string>|Record<string, string>} compressedMap - id → compressed text
48
+ * @returns {Array<object>} Deep-cloned messages with replacements applied
49
+ */
50
+ export function rebuildMessages(messages, compressedMap) {
51
+ const map = compressedMap instanceof Map ? compressedMap : new Map(Object.entries(compressedMap));
52
+ return messages.map((msg) => {
53
+ if (!msg || !Array.isArray(msg.content)) return msg;
54
+
55
+ const newContent = msg.content.map((block) => {
56
+ if (block.type !== "tool_result") return block;
57
+
58
+ const id = block.tool_use_id || block.id || "";
59
+ if (!map.has(id)) return block;
60
+
61
+ const compressed = map.get(id);
62
+ return replaceText(block, compressed);
63
+ });
64
+
65
+ return { ...msg, content: newContent };
66
+ });
67
+ }
68
+
69
+ // ── helpers ──────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Build a map of tool_use id → tool name by scanning assistant messages
73
+ * that precede or are at the given turn index.
74
+ */
75
+ function buildToolNameMap(messages, upToIndex) {
76
+ const map = new Map();
77
+ for (let i = 0; i <= upToIndex; i++) {
78
+ const msg = messages[i];
79
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
80
+ for (const block of msg.content) {
81
+ if (block.type === "tool_use" && block.id) {
82
+ map.set(block.id, block.name || "unknown");
83
+ }
84
+ }
85
+ }
86
+ return map;
87
+ }
88
+
89
+ /**
90
+ * Extract plain text from a tool_result block.
91
+ * Content can be a string, an array of {type:"text", text} blocks, or absent.
92
+ */
93
+ function extractText(block) {
94
+ if (typeof block.content === "string") return block.content;
95
+ if (Array.isArray(block.content)) {
96
+ return block.content
97
+ .filter((c) => c.type === "text")
98
+ .map((c) => c.text)
99
+ .join("\n");
100
+ }
101
+ return "";
102
+ }
103
+
104
+ /**
105
+ * Return a new tool_result block with text replaced.
106
+ * Preserves the content shape (string vs array).
107
+ */
108
+ function replaceText(block, newText) {
109
+ if (typeof block.content === "string") {
110
+ return { ...block, content: newText };
111
+ }
112
+ if (Array.isArray(block.content)) {
113
+ // Replace only text blocks, keep images etc.
114
+ const newContent = block.content.map((c) =>
115
+ c.type === "text" ? { ...c, text: newText } : c,
116
+ );
117
+ // If there were text blocks, replace the first and remove the rest
118
+ let seenText = false;
119
+ const filtered = newContent.filter((c) => {
120
+ if (c.type !== "text") return true;
121
+ if (!seenText) {
122
+ seenText = true;
123
+ return true;
124
+ }
125
+ return false;
126
+ });
127
+ return { ...block, content: filtered };
128
+ }
129
+ // No content field — add it as string
130
+ return { ...block, content: newText };
131
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Gemini message format adapter.
3
+ *
4
+ * Gemini API uses `contents[]` where each content has `parts[]`.
5
+ * Function responses appear as parts with a `functionResponse` field:
6
+ * `{ functionResponse: { name: "...", response: { content: "..." } } }`
7
+ *
8
+ * Gemini does not use explicit IDs for function calls/responses. Instead,
9
+ * the function name serves as the correlation key. When multiple calls to
10
+ * the same function exist, we generate synthetic IDs combining turnIndex
11
+ * and part index for disambiguation.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /**
17
+ * Extract tool results from Gemini-format messages (contents array).
18
+ *
19
+ * @param {Array<object>} messages - Gemini contents array
20
+ * @returns {Array<{id: string, toolName: string, text: string, turnIndex: number}>}
21
+ */
22
+ export function extractToolResults(messages) {
23
+ const results = [];
24
+
25
+ for (let i = 0; i < messages.length; i++) {
26
+ const content = messages[i];
27
+ if (!content || !Array.isArray(content.parts)) continue;
28
+
29
+ for (let p = 0; p < content.parts.length; p++) {
30
+ const part = content.parts[p];
31
+ if (!part.functionResponse) continue;
32
+
33
+ const fr = part.functionResponse;
34
+ const toolName = fr.name || "unknown";
35
+ const text = extractResponseText(fr.response);
36
+ // Synthetic ID: turn index + part index (Gemini has no explicit IDs)
37
+ const id = `gemini-${i}-${p}`;
38
+
39
+ results.push({ id, toolName, text, turnIndex: i });
40
+ }
41
+ }
42
+
43
+ return results;
44
+ }
45
+
46
+ /**
47
+ * Rebuild messages replacing functionResponse content with compressed versions.
48
+ *
49
+ * @param {Array<object>} messages - Original Gemini contents array
50
+ * @param {Map<string, string>|Record<string, string>} compressedMap - id → compressed text
51
+ * @returns {Array<object>} Deep-cloned messages with replacements applied
52
+ */
53
+ export function rebuildMessages(messages, compressedMap) {
54
+ const map = compressedMap instanceof Map ? compressedMap : new Map(Object.entries(compressedMap));
55
+
56
+ return messages.map((content, i) => {
57
+ if (!content || !Array.isArray(content.parts)) return content;
58
+
59
+ const newParts = content.parts.map((part, p) => {
60
+ if (!part.functionResponse) return part;
61
+
62
+ const id = `gemini-${i}-${p}`;
63
+ if (!map.has(id)) return part;
64
+
65
+ const compressed = map.get(id);
66
+ return {
67
+ ...part,
68
+ functionResponse: {
69
+ ...part.functionResponse,
70
+ response: replaceResponseText(part.functionResponse.response, compressed),
71
+ },
72
+ };
73
+ });
74
+
75
+ return { ...content, parts: newParts };
76
+ });
77
+ }
78
+
79
+ // ── helpers ──────────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Extract text from a Gemini function response object.
83
+ * The response can have `content` (string), `result` (string),
84
+ * or be a nested object that we JSON-serialize.
85
+ */
86
+ function extractResponseText(response) {
87
+ if (!response) return "";
88
+ if (typeof response === "string") return response;
89
+ if (typeof response.content === "string") return response.content;
90
+ if (typeof response.result === "string") return response.result;
91
+ // For structured responses, serialize to JSON
92
+ return JSON.stringify(response);
93
+ }
94
+
95
+ /**
96
+ * Replace text in a Gemini function response object.
97
+ * Preserves the original shape (content field, result field, etc.).
98
+ */
99
+ function replaceResponseText(response, newText) {
100
+ if (!response) return { content: newText };
101
+ if (typeof response === "string") return newText;
102
+ if (typeof response.content === "string") return { ...response, content: newText };
103
+ if (typeof response.result === "string") return { ...response, result: newText };
104
+ // Default: replace with content field
105
+ return { content: newText };
106
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Provider adapter registry.
3
+ *
4
+ * Each adapter implements the contract:
5
+ * extractToolResults(messages) → [{id, toolName, text, turnIndex}]
6
+ * rebuildMessages(messages, compressedMap) → messages
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import * as anthropic from "./anthropic.js";
12
+ import * as openai from "./openai.js";
13
+ import * as gemini from "./gemini.js";
14
+
15
+ const adapters = {
16
+ anthropic,
17
+ openai,
18
+ gemini,
19
+ };
20
+
21
+ /**
22
+ * Hostname → provider mapping.
23
+ * @type {Record<string, string>}
24
+ */
25
+ const HOST_MAP = {
26
+ "api.anthropic.com": "anthropic",
27
+ "api.openai.com": "openai",
28
+ "generativelanguage.googleapis.com": "gemini",
29
+ };
30
+
31
+ /**
32
+ * Get the adapter for a given provider name.
33
+ *
34
+ * @param {string} provider - "anthropic" | "openai" | "gemini"
35
+ * @returns {object|null} Adapter with extractToolResults and rebuildMessages, or null
36
+ */
37
+ export function getAdapter(provider) {
38
+ return adapters[provider] || null;
39
+ }
40
+
41
+ /**
42
+ * Detect the provider from a hostname.
43
+ *
44
+ * @param {string} hostname - e.g. "api.anthropic.com", "api.openai.com:443"
45
+ * @returns {string} Provider name or "unknown"
46
+ */
47
+ export function detectProvider(hostname) {
48
+ // Strip port if present
49
+ const host = (hostname || "").split(":")[0];
50
+ return HOST_MAP[host] || "unknown";
51
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * OpenAI message format adapter.
3
+ *
4
+ * OpenAI Chat Completions API uses messages with `role: "tool"`.
5
+ * Each tool message has a `tool_call_id` linking back to the assistant's
6
+ * `tool_calls` array, and a `content` string with the tool output.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ /**
12
+ * Extract tool results from OpenAI-format messages.
13
+ *
14
+ * @param {Array<object>} messages - OpenAI messages array
15
+ * @returns {Array<{id: string, toolName: string, text: string, turnIndex: number}>}
16
+ */
17
+ export function extractToolResults(messages) {
18
+ const results = [];
19
+ const toolNameMap = buildToolNameMap(messages);
20
+
21
+ for (let i = 0; i < messages.length; i++) {
22
+ const msg = messages[i];
23
+ if (!msg || msg.role !== "tool") continue;
24
+
25
+ const id = msg.tool_call_id || "";
26
+ const text = typeof msg.content === "string" ? msg.content : "";
27
+ const toolName = toolNameMap.get(id) || "unknown";
28
+
29
+ results.push({ id, toolName, text, turnIndex: i });
30
+ }
31
+
32
+ return results;
33
+ }
34
+
35
+ /**
36
+ * Rebuild messages replacing tool message content with compressed versions.
37
+ *
38
+ * @param {Array<object>} messages - Original OpenAI messages array
39
+ * @param {Map<string, string>|Record<string, string>} compressedMap - id → compressed text
40
+ * @returns {Array<object>} Deep-cloned messages with replacements applied
41
+ */
42
+ export function rebuildMessages(messages, compressedMap) {
43
+ const map = compressedMap instanceof Map ? compressedMap : new Map(Object.entries(compressedMap));
44
+ return messages.map((msg) => {
45
+ if (!msg || msg.role !== "tool") return msg;
46
+
47
+ const id = msg.tool_call_id || "";
48
+ if (!map.has(id)) return msg;
49
+
50
+ return { ...msg, content: map.get(id) };
51
+ });
52
+ }
53
+
54
+ // ── helpers ──────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Build a map of tool_call id → function name by scanning assistant messages.
58
+ * Assistant messages contain `tool_calls: [{id, function: {name, arguments}}]`.
59
+ */
60
+ function buildToolNameMap(messages) {
61
+ const map = new Map();
62
+ for (const msg of messages) {
63
+ if (!msg || msg.role !== "assistant" || !Array.isArray(msg.tool_calls)) continue;
64
+ for (const call of msg.tool_calls) {
65
+ if (call.id && call.function) {
66
+ map.set(call.id, call.function.name || "unknown");
67
+ }
68
+ }
69
+ }
70
+ return map;
71
+ }