@viniciosrab/pi-claude-bridge 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/assets/claude-bridge1.png +0 -0
- package/assets/claude-bridge2.png +0 -0
- package/package.json +66 -0
- package/src/agents-md.ts +14 -0
- package/src/askclaude-schema.ts +92 -0
- package/src/askclaude-ui.ts +90 -0
- package/src/attachments.ts +135 -0
- package/src/config.ts +101 -0
- package/src/convert.ts +222 -0
- package/src/extract-tool-results.ts +47 -0
- package/src/index.ts +2706 -0
- package/src/mcp-server.ts +86 -0
- package/src/models.ts +159 -0
- package/src/prompt-capture.ts +388 -0
- package/src/prompt-stream.ts +102 -0
- package/src/query-state.ts +112 -0
- package/src/session-verify.ts +38 -0
- package/src/skills.ts +20 -0
- package/src/transcript.ts +74 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// User-facing extension config. Loaded once at extension registration from
|
|
2
|
+
// the global agent dir (getAgentDir(), e.g. ~/.pi/agent/claude-bridge.json)
|
|
3
|
+
// and the project Pi config directory, project overriding global. Missing or
|
|
4
|
+
// unparseable files are ignored (error to console.error, empty object
|
|
5
|
+
// returned) so the extension always starts.
|
|
6
|
+
|
|
7
|
+
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
+
import { dirname, join } from "path";
|
|
10
|
+
|
|
11
|
+
export interface Config {
|
|
12
|
+
/** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
|
|
13
|
+
startupNoticeShown?: string;
|
|
14
|
+
askClaude?: {
|
|
15
|
+
enabled?: boolean;
|
|
16
|
+
name?: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
defaultMode?: "full" | "read" | "none";
|
|
20
|
+
defaultIsolated?: boolean;
|
|
21
|
+
allowFullMode?: boolean;
|
|
22
|
+
appendSkills?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** Low-level Claude Agent SDK plumbing. Most users won't need these. */
|
|
25
|
+
provider?: {
|
|
26
|
+
strictMcpConfig?: boolean;
|
|
27
|
+
autoMemoryEnabled?: boolean;
|
|
28
|
+
// Set to false to stop the provider's Claude Code subprocess from loading
|
|
29
|
+
// user/project/local settings (hooks, plugins, env, apiKeyHelper).
|
|
30
|
+
loadClaudeSettings?: boolean;
|
|
31
|
+
pathToClaudeCodeExecutable?: string;
|
|
32
|
+
// Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
|
|
33
|
+
plan?: "pro" | "max";
|
|
34
|
+
// Set to true to opt into metered 1M context usage ("extra usage" in
|
|
35
|
+
// Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
|
|
36
|
+
// [1m] on Pro.
|
|
37
|
+
longContextExtraUsage?: boolean;
|
|
38
|
+
// Model ids (e.g. "claude-future-9") whose declared 1M context Claude Code
|
|
39
|
+
// does not actually serve; pins them to the bare id at 200K.
|
|
40
|
+
forceTwoHundredK?: string[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tryParseJson(path: string): Partial<Config> {
|
|
45
|
+
if (!existsSync(path)) return {};
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error(`claude-bridge: failed to parse ${path}: ${e}`);
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
|
|
55
|
+
return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Provider-path `settingSources` override. Absent keeps the SDK default (all sources);
|
|
59
|
+
* `loadClaudeSettings: false` passes an empty list so no settings files are loaded. */
|
|
60
|
+
export function providerSettingSourcesOption(provider: Config["provider"] = {}): { settingSources?: [] } {
|
|
61
|
+
return provider.loadClaudeSettings === false ? { settingSources: [] } : {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function globalConfigPath(): string {
|
|
65
|
+
return join(getAgentDir(), "claude-bridge.json");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Record today's date in the global config so the startup notice shows once, preserving every
|
|
69
|
+
* other field. Returns the config path for display either way.
|
|
70
|
+
*
|
|
71
|
+
* Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
|
|
72
|
+
* spreading that would replace a user's whole config with just this marker the first time they
|
|
73
|
+
* leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
|
|
74
|
+
* skipped and the notice simply shows again next session. */
|
|
75
|
+
export function markStartupNoticeShown(): string {
|
|
76
|
+
const path = globalConfigPath();
|
|
77
|
+
let existing: Partial<Config> = {};
|
|
78
|
+
if (existsSync(path)) {
|
|
79
|
+
try {
|
|
80
|
+
existing = JSON.parse(readFileSync(path, "utf-8"));
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
|
|
87
|
+
const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
89
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function loadConfig(cwd: string): Config {
|
|
94
|
+
const global = tryParseJson(globalConfigPath());
|
|
95
|
+
const project = tryParseJson(join(cwd, CONFIG_DIR_NAME, "claude-bridge.json"));
|
|
96
|
+
return {
|
|
97
|
+
startupNoticeShown: project.startupNoticeShown ?? global.startupNoticeShown,
|
|
98
|
+
askClaude: { ...global.askClaude, ...project.askClaude },
|
|
99
|
+
provider: { ...global.provider, ...project.provider },
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Pure pi→Anthropic message conversion helpers.
|
|
2
|
+
// Extracted so they can be tested without pulling in the full extension runtime.
|
|
3
|
+
|
|
4
|
+
import type { Message as PiMessage } from "@earendil-works/pi-ai";
|
|
5
|
+
import type { Message as SessionMessage } from "cc-session-io";
|
|
6
|
+
import { pascalCase } from "change-case";
|
|
7
|
+
import { MCP_TOOL_PREFIX } from "./skills.js";
|
|
8
|
+
|
|
9
|
+
export const PROVIDER_ID = "claude-bridge";
|
|
10
|
+
|
|
11
|
+
// Pi tool names under Claude Code's builtin names. Only ever correct on the
|
|
12
|
+
// AskClaude path, where CC runs its own tools — see mapPiToolNameToSdk.
|
|
13
|
+
export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
|
|
14
|
+
read: "Read", write: "Write", edit: "Edit", bash: "Bash",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function sanitizeToolId(id: string, cache: Map<string, string>): string {
|
|
18
|
+
const existing = cache.get(id);
|
|
19
|
+
if (existing) return existing;
|
|
20
|
+
const clean = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
21
|
+
cache.set(id, clean);
|
|
22
|
+
return clean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A pi tool name as the name a rebuilt transcript has to call it by.
|
|
26
|
+
*
|
|
27
|
+
* Whether a map is passed is what distinguishes the two query shapes, because
|
|
28
|
+
* they need opposite answers:
|
|
29
|
+
*
|
|
30
|
+
* - **With a map — the provider path.** The query runs `tools: []`, so every
|
|
31
|
+
* tool Claude can call is a pi tool served over MCP, and its name is
|
|
32
|
+
* `mcp__custom-tools__<pi name>` by construction (resolveMcpTools). The map
|
|
33
|
+
* is consulted first only because it carries the served tool's exact casing.
|
|
34
|
+
* A name it lacks is a tool pi ran that we do not serve now — AskClaude,
|
|
35
|
+
* excluded on purpose, or an extension since disabled — and naming that after
|
|
36
|
+
* a Claude Code builtin would tell the model a builtin it cannot call is
|
|
37
|
+
* available and was already used. That is the prompt condition behind the
|
|
38
|
+
* phantom-call deadlock fixed in 122914dd, and the read direction refuses the
|
|
39
|
+
* same names for the same reason (piToolNameFor in index.ts).
|
|
40
|
+
* - **Without a map — the AskClaude path.** CC runs its own tools there, so
|
|
41
|
+
* builtin names are real, matching mapToolName in the other direction.
|
|
42
|
+
*/
|
|
43
|
+
export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
|
|
44
|
+
if (!name) return "";
|
|
45
|
+
const normalized = name.toLowerCase();
|
|
46
|
+
// Pi history holds pi tool names. Our own SDK prefix can only reach here by
|
|
47
|
+
// feeding already-converted names back through the conversion, and prefixing
|
|
48
|
+
// twice invents a tool nobody serves.
|
|
49
|
+
if (normalized.startsWith(MCP_TOOL_PREFIX)) {
|
|
50
|
+
throw new Error(`mapPiToolNameToSdk: "${name}" is already an SDK tool name — pi history holds pi tool names`);
|
|
51
|
+
}
|
|
52
|
+
if (!customToolNameToSdk) return PI_TO_SDK_TOOL_NAME[normalized] ?? pascalCase(name);
|
|
53
|
+
return customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized) ?? `${MCP_TOOL_PREFIX}${name}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function messageContentToText(
|
|
57
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
58
|
+
): string {
|
|
59
|
+
if (typeof content === "string") return content;
|
|
60
|
+
if (!Array.isArray(content)) return "";
|
|
61
|
+
const parts = [];
|
|
62
|
+
let hasText = false;
|
|
63
|
+
for (const block of content) {
|
|
64
|
+
if (block.type === "text" && block.text) { parts.push(block.text); hasText = true; }
|
|
65
|
+
else if (block.type !== "text" && block.type !== "image") { parts.push(`[${block.type}]`); }
|
|
66
|
+
}
|
|
67
|
+
return hasText ? parts.join("\n") : "";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tool results are flattened to text, which is how Claude Code stores most of
|
|
71
|
+
// them. Images are the exception: they have no text form, so a result carrying
|
|
72
|
+
// one keeps the block array shape instead (also what CC writes for screenshots).
|
|
73
|
+
function toolResultContent(
|
|
74
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
75
|
+
): string | Array<Record<string, unknown>> {
|
|
76
|
+
if (typeof content === "string" || !Array.isArray(content)) return messageContentToText(content) || "";
|
|
77
|
+
const images = content.filter((b) => b.type === "image" && b.data && b.mimeType);
|
|
78
|
+
if (!images.length) return messageContentToText(content) || "";
|
|
79
|
+
const blocks: Array<Record<string, unknown>> = [];
|
|
80
|
+
for (const block of content) {
|
|
81
|
+
if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
|
|
82
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
83
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
84
|
+
} else if (block.type !== "text" && block.type !== "image") {
|
|
85
|
+
// Same marker messageContentToText leaves for unrecognized blocks, so the
|
|
86
|
+
// text and image paths describe an extension's output the same way.
|
|
87
|
+
blocks.push({ type: "text", text: `[${block.type}]` });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return blocks;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What convertPiMessages discarded, for the debug line in index.ts. */
|
|
94
|
+
export type DroppedContent = {
|
|
95
|
+
thinking: number;
|
|
96
|
+
abortedTurns: number;
|
|
97
|
+
providers: Set<string>;
|
|
98
|
+
other: Map<string, number>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Convert pi message array to Anthropic API format. */
|
|
102
|
+
export function convertPiMessages(
|
|
103
|
+
messages: PiMessage[],
|
|
104
|
+
customToolNameToSdk?: Map<string, string>,
|
|
105
|
+
): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string>; dropped: DroppedContent } {
|
|
106
|
+
const anthropicMessages = [];
|
|
107
|
+
const sanitizedIds = new Map();
|
|
108
|
+
// What conversion discarded. Nothing downstream can tell: a stripped thinking
|
|
109
|
+
// block and a message that never carried one convert to the same thing, so
|
|
110
|
+
// without this the loss is invisible in the log and in a captured request.
|
|
111
|
+
const dropped: DroppedContent = { thinking: 0, abortedTurns: 0, providers: new Set(), other: new Map() };
|
|
112
|
+
// The user message collecting this assistant turn's tool results, if one has
|
|
113
|
+
// been emitted yet, and the index of the assistant message it belongs to. Both
|
|
114
|
+
// are cleared at every assistant message — see the toolResult branch.
|
|
115
|
+
let turnResults: { role: "user"; content: Array<Record<string, unknown>> } | null = null;
|
|
116
|
+
let turnAssistantIdx: number | null = null;
|
|
117
|
+
|
|
118
|
+
for (const msg of messages) {
|
|
119
|
+
if (msg.role === "user") {
|
|
120
|
+
if (typeof msg.content === "string") {
|
|
121
|
+
anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
|
|
122
|
+
} else if (Array.isArray(msg.content)) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const block of msg.content) {
|
|
125
|
+
if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
|
|
126
|
+
else if (block.type === "image" && block.data && block.mimeType) {
|
|
127
|
+
parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
|
|
131
|
+
} else {
|
|
132
|
+
anthropicMessages.push({ role: "user", content: "[empty]" });
|
|
133
|
+
}
|
|
134
|
+
} else if (msg.role === "assistant") {
|
|
135
|
+
const content = Array.isArray(msg.content) ? msg.content : [];
|
|
136
|
+
const blocks = [];
|
|
137
|
+
for (const block of content) {
|
|
138
|
+
if (block.type === "text" && block.text) {
|
|
139
|
+
blocks.push({ type: "text", text: block.text });
|
|
140
|
+
} else if (block.type === "thinking") {
|
|
141
|
+
// Only replay thinking Claude Code itself produced. A signature minted
|
|
142
|
+
// by any other provider — including pi's own Anthropic provider — is
|
|
143
|
+
// not ours to hand back, and Anthropic rejects ones it can't verify.
|
|
144
|
+
const sig = block.thinkingSignature;
|
|
145
|
+
if (msg.provider === PROVIDER_ID && sig) {
|
|
146
|
+
blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
|
|
147
|
+
} else {
|
|
148
|
+
dropped.thinking++;
|
|
149
|
+
dropped.providers.add(msg.provider ?? "unknown");
|
|
150
|
+
}
|
|
151
|
+
} else if (block.type === "toolCall") {
|
|
152
|
+
const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
|
|
153
|
+
blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
|
|
154
|
+
} else {
|
|
155
|
+
dropped.other.set(block.type, (dropped.other.get(block.type) ?? 0) + 1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// A turn the user aborted before anything streamed carries no content at
|
|
159
|
+
// all. Standing a placeholder in its place invents a reply the assistant
|
|
160
|
+
// never made, and because it lands early in the prefix it costs the whole
|
|
161
|
+
// downstream prompt cache every time the session is rebuilt. Drop it:
|
|
162
|
+
// Session.importMessages imposes no alternation, and a turn with no blocks
|
|
163
|
+
// has no tool_use ids needing a synthetic result. Left before the turn
|
|
164
|
+
// bookkeeping so a stray result still attaches to the last assistant
|
|
165
|
+
// message actually emitted.
|
|
166
|
+
//
|
|
167
|
+
// Do NOT clear turnResults/turnAssistantIdx here. It looks like the tidy
|
|
168
|
+
// thing to do, but an abort between two parallel results — assistant[X,Y],
|
|
169
|
+
// R_X, aborted turn, R_Y — would then start a second results message for
|
|
170
|
+
// R_Y. repairToolPairing consumes both pending ids at the first one, stubs
|
|
171
|
+
// Y there and drops the real R_Y as unmatched, destroying the parallel
|
|
172
|
+
// result this merge exists to preserve. unit-import.mjs pins the shape.
|
|
173
|
+
if (!content.length) { dropped.abortedTurns++; continue; }
|
|
174
|
+
// Blocks were present but every one was filtered — content really was
|
|
175
|
+
// dropped here, so keep the slot and say so. Empty content is rejected by
|
|
176
|
+
// the API, and dropping the message would break tool pairing.
|
|
177
|
+
if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
|
|
178
|
+
turnResults = null;
|
|
179
|
+
turnAssistantIdx = anthropicMessages.length;
|
|
180
|
+
anthropicMessages.push({ role: "assistant", content: blocks });
|
|
181
|
+
} else if (msg.role === "toolResult") {
|
|
182
|
+
// Pi records one message per tool result, and repairToolPairing only
|
|
183
|
+
// pairs results that share the user message directly after their
|
|
184
|
+
// assistant message. Split across messages, the second and later results
|
|
185
|
+
// match no pending tool_use id: they are dropped and replaced with a
|
|
186
|
+
// synthetic "[no tool result recorded]", so every rebuild silently
|
|
187
|
+
// destroyed the output of parallel tool calls. Session.importMessages
|
|
188
|
+
// applies the repair itself, so this cannot be opted out of by skipping
|
|
189
|
+
// our own call. (Claude Code's live writer splits a turn across records
|
|
190
|
+
// — one per content block, one per result — so the single-message shape
|
|
191
|
+
// is repairToolPairing's requirement, not a copy of CC's own layout;
|
|
192
|
+
// tests/int-cc-contracts.mjs pins both facts.)
|
|
193
|
+
//
|
|
194
|
+
// Collecting into the turn's first result message rather than the
|
|
195
|
+
// immediately preceding one also handles a steer landing mid-execution,
|
|
196
|
+
// which pi records between the results (see extractAllToolResults).
|
|
197
|
+
// The results also have to sit *directly* after their assistant message:
|
|
198
|
+
// repairToolPairing consumes the turn's pending ids at the first user
|
|
199
|
+
// message that follows it, so a steer arriving before the first result —
|
|
200
|
+
// what any steer during a slow first tool looks like — would otherwise
|
|
201
|
+
// take the stubs and strand every real result behind it.
|
|
202
|
+
//
|
|
203
|
+
// Both hoists reorder the steer against wall-clock: Claude sees results
|
|
204
|
+
// that were still running when the steer arrived. Claude Code normalizes
|
|
205
|
+
// to the same order — it records a mid-turn steer as an `attachment`, and
|
|
206
|
+
// reorderAttachmentsForAPI (claude-code-rip src/utils/messages.ts:1481)
|
|
207
|
+
// bubbles attachments up to the nearest assistant or tool_result message
|
|
208
|
+
// and re-inserts them after it. The on-disk form differs, the order does not.
|
|
209
|
+
const block = { type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: toolResultContent(msg.content), is_error: msg.isError };
|
|
210
|
+
if (turnResults) {
|
|
211
|
+
turnResults.content.push(block);
|
|
212
|
+
} else {
|
|
213
|
+
turnResults = { role: "user", content: [block] };
|
|
214
|
+
// A result with no assistant message before it is malformed history;
|
|
215
|
+
// appending keeps it in order for repairToolPairing to discard.
|
|
216
|
+
anthropicMessages.splice(turnAssistantIdx === null ? anthropicMessages.length : turnAssistantIdx + 1, 0, turnResults);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { anthropicMessages, sanitizedIds, dropped };
|
|
222
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Tool-result extraction: walks the context tail to collect this turn's
|
|
2
|
+
// tool results. Pi appends results to context and calls the provider again;
|
|
3
|
+
// this scrapes them back out. Walks past user messages (steer/followUp) that
|
|
4
|
+
// pi may inject between toolResults. Stops at the nearest assistant message
|
|
5
|
+
// (turn boundary).
|
|
6
|
+
// Extracted from index.ts so tests can import without activating the extension.
|
|
7
|
+
|
|
8
|
+
export type McpContent = Array<
|
|
9
|
+
| { type: "text"; text: string }
|
|
10
|
+
| { type: "image"; data: string; mimeType: string }
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export interface McpResult {
|
|
14
|
+
content: McpContent;
|
|
15
|
+
isError?: boolean;
|
|
16
|
+
toolCallId?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toolResultToMcpContent(
|
|
21
|
+
content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
|
|
22
|
+
): McpContent {
|
|
23
|
+
if (typeof content === "string") return [{ type: "text", text: content || "" }];
|
|
24
|
+
if (!Array.isArray(content)) return [{ type: "text", text: "" }];
|
|
25
|
+
const blocks: McpContent = [];
|
|
26
|
+
for (const block of content) {
|
|
27
|
+
if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
|
|
28
|
+
else if (block.type === "image" && block.data && block.mimeType) blocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
|
|
29
|
+
}
|
|
30
|
+
return blocks.length ? blocks : [{ type: "text", text: "" }];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Returns { results, stopIdx } so callers can log the walk boundary.
|
|
34
|
+
export function extractAllToolResults(
|
|
35
|
+
messages: Array<{ role: string; content?: unknown; toolCallId?: string; isError?: boolean; [key: string]: unknown }>,
|
|
36
|
+
): { results: McpResult[]; stopIdx: number } {
|
|
37
|
+
const results: McpResult[] = [];
|
|
38
|
+
let stopIdx = -1;
|
|
39
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
40
|
+
const msg = messages[i];
|
|
41
|
+
if (msg.role === "toolResult") {
|
|
42
|
+
results.unshift({ content: toolResultToMcpContent(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>), isError: msg.isError, toolCallId: msg.toolCallId });
|
|
43
|
+
} else if (msg.role === "assistant") { stopIdx = i; break; }
|
|
44
|
+
// user messages: skip (steer/followUp injected mid-tool-execution)
|
|
45
|
+
}
|
|
46
|
+
return { results, stopIdx };
|
|
47
|
+
}
|