@runfusion/fusion 0.1.1 → 0.1.3
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/README.md +2 -0
- package/dist/bin.js +14866 -12340
- package/dist/client/assets/index-BuenKJX0.css +1 -0
- package/dist/client/assets/index-CjGu8HRV.js +1250 -0
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +45 -1
- package/dist/client/theme-data.css +109 -0
- package/dist/extension.js +12264 -11527
- package/dist/pi-claude-cli/index.ts +131 -0
- package/dist/pi-claude-cli/package.json +39 -0
- package/dist/pi-claude-cli/src/control-handler.ts +68 -0
- package/dist/pi-claude-cli/src/event-bridge.ts +386 -0
- package/dist/pi-claude-cli/src/mcp-config.ts +111 -0
- package/dist/pi-claude-cli/src/mcp-schema-server.cjs +49 -0
- package/dist/pi-claude-cli/src/process-manager.ts +218 -0
- package/dist/pi-claude-cli/src/prompt-builder.ts +536 -0
- package/dist/pi-claude-cli/src/provider.ts +354 -0
- package/dist/pi-claude-cli/src/stream-parser.ts +37 -0
- package/dist/pi-claude-cli/src/thinking-config.ts +83 -0
- package/dist/pi-claude-cli/src/tool-mapping.ts +147 -0
- package/dist/pi-claude-cli/src/types.ts +87 -0
- package/package.json +12 -9
- package/skill/fusion/SKILL.md +6 -4
- package/skill/fusion/references/cli-commands.md +22 -22
- package/skill/fusion/references/extension-tools.md +3 -1
- package/skill/fusion/references/fusion-capabilities.md +30 -38
- package/skill/fusion/references/task-structure.md +4 -4
- package/skill/fusion/workflows/dashboard-cli.md +6 -6
- package/skill/fusion/workflows/specifications.md +5 -3
- package/skill/fusion/workflows/task-lifecycle.md +1 -1
- package/skill/fusion/workflows/task-management.md +3 -1
- package/dist/client/assets/index-B3ZN3sln.css +0 -1
- package/dist/client/assets/index-cgKoCmZP.js +0 -1241
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt builder for flattening pi conversation history into a labeled text prompt.
|
|
3
|
+
*
|
|
4
|
+
* Follows the reference project's buildPromptBlocks() pattern:
|
|
5
|
+
* - USER: / ASSISTANT: / TOOL RESULT: labels
|
|
6
|
+
* - Content blocks serialized by type
|
|
7
|
+
* - Images in the final user message are translated to Anthropic API format (HIST-02)
|
|
8
|
+
* - Images in non-final messages get placeholder text with console.warn
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { resolve, join, dirname } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal message shape that prompt-builder accepts.
|
|
17
|
+
* Uses a wide `role: string` discriminant so tests can pass plain objects
|
|
18
|
+
* without literal type annotations. Content is typed broadly as
|
|
19
|
+
* `string | unknown[]` since helper functions narrow at runtime.
|
|
20
|
+
*/
|
|
21
|
+
export interface PiMessage {
|
|
22
|
+
role: string;
|
|
23
|
+
content: string | unknown[];
|
|
24
|
+
toolName?: string;
|
|
25
|
+
}
|
|
26
|
+
export type PiContext = { systemPrompt?: string; messages: PiMessage[] };
|
|
27
|
+
import {
|
|
28
|
+
mapPiToolNameToClaude,
|
|
29
|
+
translatePiArgsToClaude,
|
|
30
|
+
isCustomToolName,
|
|
31
|
+
} from "./tool-mapping.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Anthropic API content block types for image passthrough.
|
|
35
|
+
* Used when the final user message contains images that need to be
|
|
36
|
+
* translated from pi-ai format to Anthropic format.
|
|
37
|
+
*/
|
|
38
|
+
type AnthropicContentBlock =
|
|
39
|
+
| { type: "text"; text: string }
|
|
40
|
+
| {
|
|
41
|
+
type: "image";
|
|
42
|
+
source: { type: "base64"; media_type: string; data: string };
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Flattens a pi conversation context's messages array into a labeled text prompt
|
|
47
|
+
* suitable for sending to the Claude CLI subprocess.
|
|
48
|
+
*
|
|
49
|
+
* Each message is labeled with its role:
|
|
50
|
+
* - USER: for user messages
|
|
51
|
+
* - ASSISTANT: for assistant messages
|
|
52
|
+
* - TOOL RESULT (historical {toolName}): for tool result messages
|
|
53
|
+
*/
|
|
54
|
+
/** Module-level counter for placeholder images, reset per buildPrompt call. */
|
|
55
|
+
let placeholderImageCount = 0;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Translate a pi-ai image block to Anthropic API format.
|
|
59
|
+
* Returns null if the block is missing required data/mimeType fields.
|
|
60
|
+
*
|
|
61
|
+
* pi-ai format: { type: "image", data: string (base64), mimeType: string }
|
|
62
|
+
* Anthropic format: { type: "image", source: { type: "base64", media_type: string, data: string } }
|
|
63
|
+
*/
|
|
64
|
+
function translateImageBlock(piBlock: unknown): AnthropicContentBlock | null {
|
|
65
|
+
const block = piBlock as Record<string, unknown>;
|
|
66
|
+
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
|
67
|
+
return {
|
|
68
|
+
type: "image",
|
|
69
|
+
source: {
|
|
70
|
+
type: "base64",
|
|
71
|
+
media_type: block.mimeType,
|
|
72
|
+
data: block.data,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return null; // Invalid image block, will fall back to placeholder
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build content blocks for the final user message, translating images
|
|
81
|
+
* from pi-ai format to Anthropic API format.
|
|
82
|
+
*
|
|
83
|
+
* @returns Array of AnthropicContentBlock with text and translated images
|
|
84
|
+
*/
|
|
85
|
+
function buildFinalUserContent(
|
|
86
|
+
content: string | unknown[],
|
|
87
|
+
): AnthropicContentBlock[] {
|
|
88
|
+
if (typeof content === "string") {
|
|
89
|
+
return [{ type: "text", text: content }];
|
|
90
|
+
}
|
|
91
|
+
if (!Array.isArray(content)) {
|
|
92
|
+
return [{ type: "text", text: "" }];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const blocks: AnthropicContentBlock[] = [];
|
|
96
|
+
for (const rawBlock of content) {
|
|
97
|
+
const block = rawBlock as Record<string, unknown>;
|
|
98
|
+
if (block.type === "text") {
|
|
99
|
+
blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" });
|
|
100
|
+
} else if (block.type === "image") {
|
|
101
|
+
const translated = translateImageBlock(block);
|
|
102
|
+
if (translated) {
|
|
103
|
+
blocks.push(translated);
|
|
104
|
+
} else {
|
|
105
|
+
// Invalid image block: fall back to placeholder text
|
|
106
|
+
blocks.push({
|
|
107
|
+
type: "text",
|
|
108
|
+
text: "[An image was shared here but could not be included]",
|
|
109
|
+
});
|
|
110
|
+
placeholderImageCount++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Unknown block types silently skipped
|
|
114
|
+
}
|
|
115
|
+
return blocks;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if a message content array contains image blocks.
|
|
120
|
+
*/
|
|
121
|
+
function contentHasImages(content: string | unknown[]): boolean {
|
|
122
|
+
if (typeof content === "string" || !Array.isArray(content)) return false;
|
|
123
|
+
return content.some((block) => (block as Record<string, unknown>).type === "image");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Check if the conversation ends with a custom tool result.
|
|
128
|
+
* If so, build a simplified prompt that presents the result directly
|
|
129
|
+
* instead of replaying the full conversation history with tool labels.
|
|
130
|
+
*/
|
|
131
|
+
function buildCustomToolResultPrompt(messages: PiMessage[]): string | null {
|
|
132
|
+
if (messages.length < 3) return null;
|
|
133
|
+
|
|
134
|
+
const last = messages[messages.length - 1];
|
|
135
|
+
if (last.role !== "toolResult") return null;
|
|
136
|
+
if (!last.toolName || !isCustomToolName(last.toolName)) return null;
|
|
137
|
+
|
|
138
|
+
// Find the original user message (scan backwards past assistant + toolResult)
|
|
139
|
+
let userMessage: string | null = null;
|
|
140
|
+
for (let i = messages.length - 3; i >= 0; i--) {
|
|
141
|
+
const msg = messages[i];
|
|
142
|
+
if (msg.role === "user") {
|
|
143
|
+
userMessage = userContentToText(msg.content);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!userMessage) return null;
|
|
148
|
+
|
|
149
|
+
const toolResult = toolResultContentToText(last.content);
|
|
150
|
+
return `${userMessage}\n\n[The ${last.toolName} tool was called and returned the following result]\n${toolResult}\n\nRespond to the user using the tool result above.`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build a prompt for a resumed session.
|
|
155
|
+
*
|
|
156
|
+
* When resuming via --resume, the CLI already has the full conversation history.
|
|
157
|
+
* We only need to send the new content since the last turn: the last assistant
|
|
158
|
+
* response's tool results (if any) followed by the latest user message.
|
|
159
|
+
*
|
|
160
|
+
* For tool_use flows: pi sends [user, assistant(toolCall), toolResult, ...]
|
|
161
|
+
* We need to include tool results so the resumed session sees them, plus the
|
|
162
|
+
* final user message.
|
|
163
|
+
*
|
|
164
|
+
* Falls back to full prompt if the message structure is unexpected.
|
|
165
|
+
*/
|
|
166
|
+
export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
|
|
167
|
+
const messages = context.messages;
|
|
168
|
+
if (messages.length === 0) return "";
|
|
169
|
+
|
|
170
|
+
// Find the last user message
|
|
171
|
+
const finalUserIndex = findFinalUserMessageIndex(messages);
|
|
172
|
+
if (finalUserIndex < 0) return "";
|
|
173
|
+
|
|
174
|
+
// Collect new messages: everything from the last assistant turn onwards
|
|
175
|
+
// (tool results from the last assistant + the new user message)
|
|
176
|
+
const newMessages: PiMessage[] = [];
|
|
177
|
+
|
|
178
|
+
// Walk backwards from finalUserIndex to find where new content starts.
|
|
179
|
+
// Include trailing toolResult messages that follow the last assistant turn.
|
|
180
|
+
let startIdx = finalUserIndex;
|
|
181
|
+
for (let i = finalUserIndex - 1; i >= 0; i--) {
|
|
182
|
+
if (messages[i].role === "toolResult") {
|
|
183
|
+
startIdx = i;
|
|
184
|
+
} else {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (let i = startIdx; i < messages.length; i++) {
|
|
190
|
+
newMessages.push(messages[i]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// If there are only tool results + one user message, build a combined prompt
|
|
194
|
+
const parts: string[] = [];
|
|
195
|
+
for (const msg of newMessages) {
|
|
196
|
+
if (msg.role === "toolResult") {
|
|
197
|
+
if (msg.toolName && isCustomToolName(msg.toolName)) {
|
|
198
|
+
parts.push(`TOOL RESULT (${msg.toolName}):`);
|
|
199
|
+
} else {
|
|
200
|
+
const claudeToolName = msg.toolName
|
|
201
|
+
? mapPiToolNameToClaude(msg.toolName)
|
|
202
|
+
: "unknown";
|
|
203
|
+
parts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
|
204
|
+
}
|
|
205
|
+
parts.push(toolResultContentToText(msg.content));
|
|
206
|
+
} else if (msg.role === "user") {
|
|
207
|
+
// Check for images in the final user message
|
|
208
|
+
if (contentHasImages(msg.content)) {
|
|
209
|
+
const textSoFar = parts.join("\n");
|
|
210
|
+
const userContent = buildFinalUserContent(msg.content);
|
|
211
|
+
const result: AnthropicContentBlock[] = [];
|
|
212
|
+
if (textSoFar) {
|
|
213
|
+
result.push({ type: "text", text: textSoFar });
|
|
214
|
+
}
|
|
215
|
+
result.push(...userContent);
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
parts.push(userContentToText(msg.content));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return parts.join("\n") || "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function buildPrompt(context: PiContext): string | AnthropicContentBlock[] {
|
|
226
|
+
// Reset placeholder counter for each call
|
|
227
|
+
placeholderImageCount = 0;
|
|
228
|
+
|
|
229
|
+
// Special case: when conversation ends with a custom tool result,
|
|
230
|
+
// present it directly instead of complex history replay
|
|
231
|
+
const customToolPrompt = buildCustomToolResultPrompt(context.messages);
|
|
232
|
+
if (customToolPrompt) {
|
|
233
|
+
// customToolPrompt calls userContentToText which may increment placeholderImageCount
|
|
234
|
+
if (placeholderImageCount > 0) {
|
|
235
|
+
console.warn(
|
|
236
|
+
`[pi-claude-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return customToolPrompt;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Determine if any message has images worth passing through
|
|
243
|
+
const finalUserIndex = findFinalUserMessageIndex(context.messages);
|
|
244
|
+
const finalUserMsg = finalUserIndex >= 0 ? context.messages[finalUserIndex] : undefined;
|
|
245
|
+
const finalUserHasImages =
|
|
246
|
+
finalUserMsg !== undefined &&
|
|
247
|
+
finalUserMsg.role === "user" &&
|
|
248
|
+
contentHasImages(finalUserMsg.content);
|
|
249
|
+
const anyToolResultHasImages = context.messages.some(
|
|
250
|
+
(m) => m.role === "toolResult" && toolResultHasImages(m.content),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
if (finalUserHasImages || anyToolResultHasImages) {
|
|
254
|
+
// Build history as text (all messages except the final user message)
|
|
255
|
+
const historyParts: string[] = [];
|
|
256
|
+
const toolResultImageBlocks: AnthropicContentBlock[] = [];
|
|
257
|
+
for (let i = 0; i < context.messages.length; i++) {
|
|
258
|
+
if (i === finalUserIndex) continue; // Skip final user message -- handled separately
|
|
259
|
+
const message = context.messages[i];
|
|
260
|
+
if (message.role === "user") {
|
|
261
|
+
historyParts.push("USER:");
|
|
262
|
+
historyParts.push(userContentToText(message.content));
|
|
263
|
+
} else if (message.role === "assistant") {
|
|
264
|
+
historyParts.push("ASSISTANT:");
|
|
265
|
+
historyParts.push(contentToText(message.content));
|
|
266
|
+
} else if (message.role === "toolResult") {
|
|
267
|
+
if (message.toolName && isCustomToolName(message.toolName)) {
|
|
268
|
+
historyParts.push(`TOOL RESULT (${message.toolName}):`);
|
|
269
|
+
} else {
|
|
270
|
+
const claudeToolName = message.toolName
|
|
271
|
+
? mapPiToolNameToClaude(message.toolName)
|
|
272
|
+
: "unknown";
|
|
273
|
+
historyParts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
|
274
|
+
}
|
|
275
|
+
// Extract text portion of tool result
|
|
276
|
+
historyParts.push(toolResultContentToText(message.content));
|
|
277
|
+
// Collect image blocks from tool results for passthrough
|
|
278
|
+
if (Array.isArray(message.content)) {
|
|
279
|
+
for (const rawBlock of message.content) {
|
|
280
|
+
const block = rawBlock as Record<string, unknown>;
|
|
281
|
+
if (block.type === "image") {
|
|
282
|
+
const translated = translateImageBlock(block);
|
|
283
|
+
if (translated) {
|
|
284
|
+
toolResultImageBlocks.push(translated);
|
|
285
|
+
// Undo the placeholder count from toolResultContentToText since we're passing through
|
|
286
|
+
placeholderImageCount--;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Build final user message content blocks
|
|
295
|
+
const finalUserContent =
|
|
296
|
+
finalUserMsg?.role === "user"
|
|
297
|
+
? buildFinalUserContent(finalUserMsg.content)
|
|
298
|
+
: [];
|
|
299
|
+
|
|
300
|
+
// Combine: history text + tool result images + final user content blocks
|
|
301
|
+
const result: AnthropicContentBlock[] = [];
|
|
302
|
+
const historyText = historyParts.join("\n");
|
|
303
|
+
if (historyText) {
|
|
304
|
+
result.push({ type: "text", text: historyText });
|
|
305
|
+
}
|
|
306
|
+
// Insert tool result images after history text (Claude sees them in context)
|
|
307
|
+
result.push(...toolResultImageBlocks);
|
|
308
|
+
result.push(...finalUserContent);
|
|
309
|
+
|
|
310
|
+
if (placeholderImageCount > 0) {
|
|
311
|
+
console.warn(
|
|
312
|
+
`[pi-claude-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// No images in final user message: standard text-only path
|
|
320
|
+
const parts: string[] = [];
|
|
321
|
+
|
|
322
|
+
for (const message of context.messages) {
|
|
323
|
+
if (message.role === "user") {
|
|
324
|
+
parts.push("USER:");
|
|
325
|
+
parts.push(userContentToText(message.content));
|
|
326
|
+
} else if (message.role === "assistant") {
|
|
327
|
+
parts.push("ASSISTANT:");
|
|
328
|
+
parts.push(contentToText(message.content));
|
|
329
|
+
} else if (message.role === "toolResult") {
|
|
330
|
+
if (message.toolName && isCustomToolName(message.toolName)) {
|
|
331
|
+
// Custom tools: don't reference MCP tool name. Present result plainly.
|
|
332
|
+
parts.push(`TOOL RESULT (${message.toolName}):`);
|
|
333
|
+
} else {
|
|
334
|
+
const claudeToolName = message.toolName
|
|
335
|
+
? mapPiToolNameToClaude(message.toolName)
|
|
336
|
+
: "unknown";
|
|
337
|
+
parts.push(`TOOL RESULT (historical ${claudeToolName}):`);
|
|
338
|
+
}
|
|
339
|
+
parts.push(toolResultContentToText(message.content));
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (placeholderImageCount > 0) {
|
|
344
|
+
console.warn(
|
|
345
|
+
`[pi-claude-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return parts.join("\n") || "";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Find the index of the last user message in the messages array.
|
|
354
|
+
* Returns -1 if no user message found.
|
|
355
|
+
*/
|
|
356
|
+
function findFinalUserMessageIndex(messages: PiMessage[]): number {
|
|
357
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
358
|
+
if (messages[i].role === "user") return i;
|
|
359
|
+
}
|
|
360
|
+
return -1;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Builds the system prompt from the context's systemPrompt field,
|
|
365
|
+
* appending AGENTS.md content if found (walking up from cwd, then global fallback).
|
|
366
|
+
* Sanitizes .pi references to .claude for Claude Code compatibility.
|
|
367
|
+
*/
|
|
368
|
+
export function buildSystemPrompt(
|
|
369
|
+
context: PiContext,
|
|
370
|
+
cwd: string,
|
|
371
|
+
): string {
|
|
372
|
+
const parts: string[] = [];
|
|
373
|
+
|
|
374
|
+
if (context.systemPrompt) {
|
|
375
|
+
parts.push(context.systemPrompt);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Look for AGENTS.md
|
|
379
|
+
const agentsPath = resolveAgentsMdPath(cwd);
|
|
380
|
+
if (agentsPath) {
|
|
381
|
+
try {
|
|
382
|
+
const content = readFileSync(agentsPath, "utf-8");
|
|
383
|
+
const sanitized = sanitizeAgentsContent(content);
|
|
384
|
+
parts.push(sanitized);
|
|
385
|
+
} catch {
|
|
386
|
+
// If we can't read it, skip silently
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// When conversation history has tool results, instruct Claude to use them
|
|
391
|
+
// instead of trying to re-call tools (which may not be available).
|
|
392
|
+
if (context.messages?.some((m) => m.role === "toolResult")) {
|
|
393
|
+
parts.push(
|
|
394
|
+
"IMPORTANT: The conversation history below contains tool results from previously executed tools. " +
|
|
395
|
+
"Use these results to answer the user's question. Do NOT attempt to re-call tools that already have results.",
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return parts.join("\n\n");
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Converts user message content to text.
|
|
404
|
+
* Handles string content and array of content blocks.
|
|
405
|
+
* Image blocks are replaced with placeholder text (HIST-02).
|
|
406
|
+
* Increments the module-level placeholderImageCount for each image.
|
|
407
|
+
*/
|
|
408
|
+
function userContentToText(content: string | unknown[]): string {
|
|
409
|
+
if (typeof content === "string") return content;
|
|
410
|
+
if (!Array.isArray(content)) return "";
|
|
411
|
+
|
|
412
|
+
const texts: string[] = [];
|
|
413
|
+
for (const rawBlock of content) {
|
|
414
|
+
const block = rawBlock as Record<string, unknown>;
|
|
415
|
+
if (block.type === "text") {
|
|
416
|
+
texts.push(typeof block.text === "string" ? block.text : "");
|
|
417
|
+
} else if (block.type === "image") {
|
|
418
|
+
texts.push("[An image was shared here but could not be included]");
|
|
419
|
+
placeholderImageCount++;
|
|
420
|
+
}
|
|
421
|
+
// Unknown block types silently skipped
|
|
422
|
+
}
|
|
423
|
+
return texts.join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Converts assistant message content to text.
|
|
428
|
+
* Handles string content and array of content blocks (text, thinking, toolCall).
|
|
429
|
+
*/
|
|
430
|
+
function contentToText(content: string | unknown[]): string {
|
|
431
|
+
if (typeof content === "string") return content;
|
|
432
|
+
if (!Array.isArray(content)) return "";
|
|
433
|
+
|
|
434
|
+
return content
|
|
435
|
+
.map((rawBlock) => {
|
|
436
|
+
const block = rawBlock as Record<string, unknown>;
|
|
437
|
+
if (block.type === "text") return typeof block.text === "string" ? block.text : "";
|
|
438
|
+
if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
|
|
439
|
+
if (block.type === "toolCall") {
|
|
440
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
441
|
+
const rawArgs = block.arguments;
|
|
442
|
+
// A toolCall may carry either parsed args (object) or the raw unparsed
|
|
443
|
+
// string that pi produced — preserve the raw string verbatim so callers
|
|
444
|
+
// can see what the model actually sent.
|
|
445
|
+
const argsObject =
|
|
446
|
+
rawArgs && typeof rawArgs === "object" ? (rawArgs as Record<string, unknown>) : undefined;
|
|
447
|
+
const isCustom = isCustomToolName(name);
|
|
448
|
+
if (isCustom) {
|
|
449
|
+
// Custom tools: don't reference the MCP tool name — Claude might try to re-call it.
|
|
450
|
+
// Just note what was done. The result follows as a TOOL RESULT message.
|
|
451
|
+
const argsStr = argsObject
|
|
452
|
+
? JSON.stringify(argsObject)
|
|
453
|
+
: typeof rawArgs === "string"
|
|
454
|
+
? JSON.stringify(rawArgs)
|
|
455
|
+
: "{}";
|
|
456
|
+
return `[Used ${name} tool with args: ${argsStr}]`;
|
|
457
|
+
}
|
|
458
|
+
const claudeName = mapPiToolNameToClaude(name);
|
|
459
|
+
const claudeArgs = argsObject ? translatePiArgsToClaude(name, argsObject) : undefined;
|
|
460
|
+
const argsStr = claudeArgs
|
|
461
|
+
? JSON.stringify(claudeArgs)
|
|
462
|
+
: typeof rawArgs === "string"
|
|
463
|
+
? JSON.stringify(rawArgs)
|
|
464
|
+
: "{}";
|
|
465
|
+
return `Historical tool call (non-executable): ${claudeName} args=${argsStr}`;
|
|
466
|
+
}
|
|
467
|
+
// Unknown block types are represented as a placeholder
|
|
468
|
+
return `[${String(block.type)}]`;
|
|
469
|
+
})
|
|
470
|
+
.join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Converts tool result content to text.
|
|
475
|
+
* Handles string content and array of content blocks.
|
|
476
|
+
* Image blocks get placeholder text (actual image passthrough handled separately).
|
|
477
|
+
*/
|
|
478
|
+
function toolResultContentToText(content: string | unknown[]): string {
|
|
479
|
+
if (typeof content === "string") return content;
|
|
480
|
+
if (!Array.isArray(content)) return "";
|
|
481
|
+
|
|
482
|
+
const texts: string[] = [];
|
|
483
|
+
for (const rawBlock of content) {
|
|
484
|
+
const block = rawBlock as Record<string, unknown>;
|
|
485
|
+
if (block.type === "text") {
|
|
486
|
+
texts.push(typeof block.text === "string" ? block.text : "");
|
|
487
|
+
} else if (block.type === "image") {
|
|
488
|
+
texts.push("[An image was shared here but could not be included]");
|
|
489
|
+
placeholderImageCount++;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return texts.join("\n");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Check if a tool result content array contains image blocks.
|
|
497
|
+
*/
|
|
498
|
+
function toolResultHasImages(content: string | unknown[]): boolean {
|
|
499
|
+
if (typeof content === "string" || !Array.isArray(content)) return false;
|
|
500
|
+
return content.some((block) => (block as Record<string, unknown>).type === "image");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Walk up from cwd looking for AGENTS.md, fall back to ~/.pi/agent/AGENTS.md.
|
|
505
|
+
*/
|
|
506
|
+
function resolveAgentsMdPath(cwd: string): string | undefined {
|
|
507
|
+
let current = resolve(cwd);
|
|
508
|
+
while (true) {
|
|
509
|
+
const candidate = join(current, "AGENTS.md");
|
|
510
|
+
if (existsSync(candidate)) return candidate;
|
|
511
|
+
const parent = dirname(current);
|
|
512
|
+
if (parent === current) break;
|
|
513
|
+
current = parent;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fall back to global path
|
|
517
|
+
const globalPath = join(homedir(), ".pi", "agent", "AGENTS.md");
|
|
518
|
+
if (existsSync(globalPath)) return globalPath;
|
|
519
|
+
|
|
520
|
+
return undefined;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Sanitize .pi references to .claude in AGENTS.md content
|
|
525
|
+
* for Claude Code compatibility.
|
|
526
|
+
*/
|
|
527
|
+
function sanitizeAgentsContent(content: string): string {
|
|
528
|
+
let sanitized = content;
|
|
529
|
+
// ~/.pi -> ~/.claude
|
|
530
|
+
sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
|
|
531
|
+
// .pi/ -> .claude/ (at word boundary or after whitespace/quotes)
|
|
532
|
+
sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
|
|
533
|
+
// Remaining standalone .pi references
|
|
534
|
+
sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
|
|
535
|
+
return sanitized;
|
|
536
|
+
}
|