@runfusion/fusion 0.13.0 → 0.14.1

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 (72) hide show
  1. package/README.md +13 -0
  2. package/dist/bin.js +1332 -528
  3. package/dist/client/assets/AgentDetailView-B3KAsP2O.js +18 -0
  4. package/dist/client/assets/{AgentsView-Dvf_xUkx.js → AgentsView-DoXb_amw.js} +4 -4
  5. package/dist/client/assets/ChatView-BJ2c7wvd.js +1 -0
  6. package/dist/client/assets/{DevServerView-C2qTJch7.js → DevServerView-DbgM4tlT.js} +1 -1
  7. package/dist/client/assets/{DirectoryPicker-DRfhg9zz.js → DirectoryPicker-DfmtfMiu.js} +1 -1
  8. package/dist/client/assets/{DocumentsView-j8ic1xUw.js → DocumentsView-_-Efkx_W.js} +1 -1
  9. package/dist/client/assets/{InsightsView-CpAz3o0i.js → InsightsView-DUjcfW53.js} +1 -1
  10. package/dist/client/assets/{MemoryView-BcQsi_JK.js → MemoryView-DxMPBb0q.js} +1 -1
  11. package/dist/client/assets/{NodesView-Bo_Yhr4N.js → NodesView-BEBTI15s.js} +1 -1
  12. package/dist/client/assets/PiExtensionsManager-BpMYhHH_.js +11 -0
  13. package/dist/client/assets/PluginManager-CPv7yQd3.js +1 -0
  14. package/dist/client/assets/PluginManager-DA_T0GHn.css +1 -0
  15. package/dist/client/assets/{ResearchView-CLyyqAWE.js → ResearchView-BrFvdyXT.js} +1 -1
  16. package/dist/client/assets/{RoadmapsView-tG7IdOoc.js → RoadmapsView-BDjLrtcj.js} +1 -1
  17. package/dist/client/assets/SettingsModal-Cd-QGB0C.js +31 -0
  18. package/dist/client/assets/{SettingsModal-CXUGeZ0_.js → SettingsModal-CxDxiTRy.js} +1 -1
  19. package/dist/client/assets/SettingsModal-D_AFkDJa.css +1 -0
  20. package/dist/client/assets/{SetupWizardModal-BMJL6eNR.js → SetupWizardModal-DFUA4X3z.js} +1 -1
  21. package/dist/client/assets/{SkillMultiselect-ILMft-Kz.js → SkillMultiselect-BUWe5ujb.js} +1 -1
  22. package/dist/client/assets/{SkillsView-x4_YwBz6.js → SkillsView-RAkqGX3y.js} +1 -1
  23. package/dist/client/assets/TodoView-Ceb0wrg1.js +6 -0
  24. package/dist/client/assets/TodoView-SeO9o7km.css +1 -0
  25. package/dist/client/assets/{folder-open-DDdJt8aE.js → folder-open-DcM-Vd6r.js} +1 -1
  26. package/dist/client/assets/index-C1prPuSl.css +1 -0
  27. package/dist/client/assets/index-DH3aprf6.js +661 -0
  28. package/dist/client/assets/{list-checks-DFxQ9biT.js → list-checks-ByGHVQpZ.js} +1 -1
  29. package/dist/client/assets/{star-BKs1bgJN.js → star-DlEYI8GL.js} +1 -1
  30. package/dist/client/assets/{upload-Bb5Pidne.js → upload-DKshabz-.js} +1 -1
  31. package/dist/client/assets/{users-BImNn91Q.js → users-X6tYPPBV.js} +1 -1
  32. package/dist/client/index.html +2 -2
  33. package/dist/client/sw.js +6 -0
  34. package/dist/client/version.json +1 -1
  35. package/dist/droid-cli/index.ts +127 -0
  36. package/dist/droid-cli/package.json +37 -0
  37. package/dist/droid-cli/src/__tests__/control-handler.test.ts +164 -0
  38. package/dist/droid-cli/src/__tests__/event-bridge.test.ts +1318 -0
  39. package/dist/droid-cli/src/__tests__/mcp-config.test.ts +310 -0
  40. package/dist/droid-cli/src/__tests__/process-manager.test.ts +818 -0
  41. package/dist/droid-cli/src/__tests__/prompt-builder.test.ts +1206 -0
  42. package/dist/droid-cli/src/__tests__/provider.test.ts +1894 -0
  43. package/dist/droid-cli/src/__tests__/setup-test-isolation.test.ts +32 -0
  44. package/dist/droid-cli/src/__tests__/setup-test-isolation.ts +14 -0
  45. package/dist/droid-cli/src/__tests__/stream-parser.test.ts +188 -0
  46. package/dist/droid-cli/src/__tests__/thinking-config.test.ts +141 -0
  47. package/dist/droid-cli/src/__tests__/tool-mapping.test.ts +253 -0
  48. package/dist/droid-cli/src/control-handler.ts +82 -0
  49. package/dist/droid-cli/src/event-bridge.ts +397 -0
  50. package/dist/droid-cli/src/mcp-config.ts +144 -0
  51. package/dist/droid-cli/src/mcp-schema-server.cjs +49 -0
  52. package/dist/droid-cli/src/process-manager.ts +358 -0
  53. package/dist/droid-cli/src/prompt-builder.ts +629 -0
  54. package/dist/droid-cli/src/provider.ts +447 -0
  55. package/dist/droid-cli/src/stream-parser.ts +37 -0
  56. package/dist/droid-cli/src/thinking-config.ts +83 -0
  57. package/dist/droid-cli/src/tool-mapping.ts +147 -0
  58. package/dist/droid-cli/src/types.ts +87 -0
  59. package/dist/extension.js +555 -125
  60. package/dist/pi-claude-cli/package.json +1 -1
  61. package/package.json +2 -1
  62. package/dist/client/assets/AgentDetailView-B7j297GT.js +0 -18
  63. package/dist/client/assets/ChatView-BgUt38ty.js +0 -1
  64. package/dist/client/assets/PiExtensionsManager-DHt2zFg8.js +0 -11
  65. package/dist/client/assets/PluginManager-BQhBHWrB.js +0 -1
  66. package/dist/client/assets/PluginManager-jyNkJZSz.css +0 -1
  67. package/dist/client/assets/SettingsModal-9HS8MnmW.css +0 -1
  68. package/dist/client/assets/SettingsModal-UziTDnLh.js +0 -31
  69. package/dist/client/assets/TodoView-BBYcMbXE.js +0 -6
  70. package/dist/client/assets/TodoView-C1g65hJo.css +0 -1
  71. package/dist/client/assets/index-B15xwijw.css +0 -1
  72. package/dist/client/assets/index-DmSs2FGE.js +0 -661
@@ -0,0 +1,629 @@
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
+ /**
27
+ * Minimal pi-ai Tool shape — the subset we read from `Context.tools` to build
28
+ * the deferred-tools system-prompt addendum.
29
+ */
30
+ export interface PiToolLike {
31
+ name: string;
32
+ description?: string;
33
+ }
34
+
35
+ export type PiContext = {
36
+ systemPrompt?: string;
37
+ messages: PiMessage[];
38
+ tools?: ReadonlyArray<PiToolLike>;
39
+ };
40
+ import {
41
+ mapPiToolNameToDroid,
42
+ translatePiArgsToDroid,
43
+ isCustomToolName,
44
+ } from "./tool-mapping.js";
45
+
46
+ /**
47
+ * Anthropic API content block types for image passthrough.
48
+ * Used when the final user message contains images that need to be
49
+ * translated from pi-ai format to Anthropic format.
50
+ */
51
+ type AnthropicContentBlock =
52
+ | { type: "text"; text: string }
53
+ | {
54
+ type: "image";
55
+ source: { type: "base64"; media_type: string; data: string };
56
+ };
57
+
58
+ /**
59
+ * Flattens a pi conversation context's messages array into a labeled text prompt
60
+ * suitable for sending to the Droid CLI subprocess.
61
+ *
62
+ * Each message is labeled with its role:
63
+ * - USER: for user messages
64
+ * - ASSISTANT: for assistant messages
65
+ * - TOOL RESULT ({toolName}): for tool result messages
66
+ */
67
+ /** Module-level counter for placeholder images, reset per buildPrompt call. */
68
+ let placeholderImageCount = 0;
69
+
70
+ /**
71
+ * Translate a pi-ai image block to Anthropic API format.
72
+ * Returns null if the block is missing required data/mimeType fields.
73
+ *
74
+ * pi-ai format: { type: "image", data: string (base64), mimeType: string }
75
+ * Anthropic format: { type: "image", source: { type: "base64", media_type: string, data: string } }
76
+ */
77
+ function translateImageBlock(piBlock: unknown): AnthropicContentBlock | null {
78
+ const block = piBlock as Record<string, unknown>;
79
+ if (typeof block.data === "string" && typeof block.mimeType === "string") {
80
+ return {
81
+ type: "image",
82
+ source: {
83
+ type: "base64",
84
+ media_type: block.mimeType,
85
+ data: block.data,
86
+ },
87
+ };
88
+ }
89
+ return null; // Invalid image block, will fall back to placeholder
90
+ }
91
+
92
+ /**
93
+ * Build content blocks for the final user message, translating images
94
+ * from pi-ai format to Anthropic API format.
95
+ *
96
+ * @returns Array of AnthropicContentBlock with text and translated images
97
+ */
98
+ function buildFinalUserContent(
99
+ content: string | unknown[],
100
+ ): AnthropicContentBlock[] {
101
+ if (typeof content === "string") {
102
+ return [{ type: "text", text: content }];
103
+ }
104
+ if (!Array.isArray(content)) {
105
+ return [{ type: "text", text: "" }];
106
+ }
107
+
108
+ const blocks: AnthropicContentBlock[] = [];
109
+ for (const rawBlock of content) {
110
+ const block = rawBlock as Record<string, unknown>;
111
+ if (block.type === "text") {
112
+ blocks.push({ type: "text", text: typeof block.text === "string" ? block.text : "" });
113
+ } else if (block.type === "image") {
114
+ const translated = translateImageBlock(block);
115
+ if (translated) {
116
+ blocks.push(translated);
117
+ } else {
118
+ // Invalid image block: fall back to placeholder text
119
+ blocks.push({
120
+ type: "text",
121
+ text: "[An image was shared here but could not be included]",
122
+ });
123
+ placeholderImageCount++;
124
+ }
125
+ }
126
+ // Unknown block types silently skipped
127
+ }
128
+ return blocks;
129
+ }
130
+
131
+ /**
132
+ * Check if a message content array contains image blocks.
133
+ */
134
+ function contentHasImages(content: string | unknown[]): boolean {
135
+ if (typeof content === "string" || !Array.isArray(content)) return false;
136
+ return content.some((block) => (block as Record<string, unknown>).type === "image");
137
+ }
138
+
139
+ /**
140
+ * Check if the conversation ends with a custom tool result.
141
+ * If so, build a simplified prompt that presents the result directly
142
+ * instead of replaying the full conversation history with tool labels.
143
+ */
144
+ function buildCustomToolResultPrompt(messages: PiMessage[]): string | null {
145
+ if (messages.length < 3) return null;
146
+
147
+ const last = messages[messages.length - 1];
148
+ if (last.role !== "toolResult") return null;
149
+ if (!last.toolName || !isCustomToolName(last.toolName)) return null;
150
+
151
+ // Find the original user message (scan backwards past assistant + toolResult)
152
+ let userMessage: string | null = null;
153
+ for (let i = messages.length - 3; i >= 0; i--) {
154
+ const msg = messages[i];
155
+ if (msg.role === "user") {
156
+ userMessage = userContentToText(msg.content);
157
+ break;
158
+ }
159
+ }
160
+ if (!userMessage) return null;
161
+
162
+ const toolResult = toolResultContentToText(last.content);
163
+ 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.`;
164
+ }
165
+
166
+ /**
167
+ * Build a prompt for a resumed session.
168
+ *
169
+ * When resuming via --resume, the CLI already has the full conversation history
170
+ * up through (and including) the most recent assistant turn that it produced.
171
+ * We only need to send the *delta* since that turn: any trailing tool results
172
+ * for the last assistant tool_use, and/or a new user message.
173
+ *
174
+ * Why anchor on the last assistant message (not the last user message)?
175
+ * Pi's tool-use loop appends `[user, assistant(toolUse), toolResult,
176
+ * assistant(toolUse), toolResult, ...]` — the only `user` entry stays at index
177
+ * 0 across many provider invocations. Anchoring on the last user message and
178
+ * walking forward (the prior implementation) re-sent the entire transcript on
179
+ * every tool-loop iteration, so each --resume turn appended a duplicate of the
180
+ * original query plus a growing stack of tool results to the on-disk session.
181
+ *
182
+ * Returns "" when there's nothing new to send (e.g. only an assistant message
183
+ * exists in the context — can happen mid-shutdown).
184
+ */
185
+ export function buildResumePrompt(context: PiContext): string | AnthropicContentBlock[] {
186
+ const messages = context.messages;
187
+ if (messages.length === 0) return "";
188
+
189
+ let lastAssistantIdx = -1;
190
+ for (let i = messages.length - 1; i >= 0; i--) {
191
+ if (messages[i].role === "assistant") {
192
+ lastAssistantIdx = i;
193
+ break;
194
+ }
195
+ }
196
+ const newMessages = messages.slice(lastAssistantIdx + 1);
197
+ if (newMessages.length === 0) return "";
198
+
199
+ const parts: string[] = [];
200
+ for (const msg of newMessages) {
201
+ if (msg.role === "toolResult") {
202
+ if (msg.toolName && isCustomToolName(msg.toolName)) {
203
+ parts.push(`TOOL RESULT (${msg.toolName}):`);
204
+ } else {
205
+ const claudeToolName = msg.toolName
206
+ ? mapPiToolNameToDroid(msg.toolName)
207
+ : "unknown";
208
+ parts.push(`TOOL RESULT (${claudeToolName}):`);
209
+ }
210
+ parts.push(toolResultContentToText(msg.content));
211
+ } else if (msg.role === "user") {
212
+ if (contentHasImages(msg.content)) {
213
+ const textSoFar = parts.join("\n");
214
+ const userContent = buildFinalUserContent(msg.content);
215
+ const result: AnthropicContentBlock[] = [];
216
+ if (textSoFar) {
217
+ result.push({ type: "text", text: textSoFar });
218
+ }
219
+ result.push(...userContent);
220
+ return result;
221
+ }
222
+ parts.push(userContentToText(msg.content));
223
+ }
224
+ }
225
+
226
+ return parts.join("\n") || "";
227
+ }
228
+
229
+ export function buildPrompt(context: PiContext): string | AnthropicContentBlock[] {
230
+ // Reset placeholder counter for each call
231
+ placeholderImageCount = 0;
232
+
233
+ // Special case: when conversation ends with a custom tool result,
234
+ // present it directly instead of complex history replay
235
+ const customToolPrompt = buildCustomToolResultPrompt(context.messages);
236
+ if (customToolPrompt) {
237
+ // customToolPrompt calls userContentToText which may increment placeholderImageCount
238
+ if (placeholderImageCount > 0) {
239
+ console.warn(
240
+ `[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
241
+ );
242
+ }
243
+ return customToolPrompt;
244
+ }
245
+
246
+ // Determine if any message has images worth passing through
247
+ const finalUserIndex = findFinalUserMessageIndex(context.messages);
248
+ const finalUserMsg = finalUserIndex >= 0 ? context.messages[finalUserIndex] : undefined;
249
+ const finalUserHasImages =
250
+ finalUserMsg !== undefined &&
251
+ finalUserMsg.role === "user" &&
252
+ contentHasImages(finalUserMsg.content);
253
+ const anyToolResultHasImages = context.messages.some(
254
+ (m) => m.role === "toolResult" && toolResultHasImages(m.content),
255
+ );
256
+
257
+ if (finalUserHasImages || anyToolResultHasImages) {
258
+ // Build history as text (all messages except the final user message)
259
+ const historyParts: string[] = [];
260
+ const toolResultImageBlocks: AnthropicContentBlock[] = [];
261
+ for (let i = 0; i < context.messages.length; i++) {
262
+ if (i === finalUserIndex) continue; // Skip final user message -- handled separately
263
+ const message = context.messages[i];
264
+ if (message.role === "user") {
265
+ historyParts.push("USER:");
266
+ historyParts.push(userContentToText(message.content));
267
+ } else if (message.role === "assistant") {
268
+ historyParts.push("ASSISTANT:");
269
+ historyParts.push(contentToText(message.content));
270
+ } else if (message.role === "toolResult") {
271
+ if (message.toolName && isCustomToolName(message.toolName)) {
272
+ historyParts.push(`TOOL RESULT (${message.toolName}):`);
273
+ } else {
274
+ const claudeToolName = message.toolName
275
+ ? mapPiToolNameToDroid(message.toolName)
276
+ : "unknown";
277
+ historyParts.push(`TOOL RESULT (${claudeToolName}):`);
278
+ }
279
+ // Extract text portion of tool result
280
+ historyParts.push(toolResultContentToText(message.content));
281
+ // Collect image blocks from tool results for passthrough
282
+ if (Array.isArray(message.content)) {
283
+ for (const rawBlock of message.content) {
284
+ const block = rawBlock as Record<string, unknown>;
285
+ if (block.type === "image") {
286
+ const translated = translateImageBlock(block);
287
+ if (translated) {
288
+ toolResultImageBlocks.push(translated);
289
+ // Undo the placeholder count from toolResultContentToText since we're passing through
290
+ placeholderImageCount--;
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ // Build final user message content blocks
299
+ const finalUserContent =
300
+ finalUserMsg?.role === "user"
301
+ ? buildFinalUserContent(finalUserMsg.content)
302
+ : [];
303
+
304
+ // Combine: history text + tool result images + final user content blocks
305
+ const result: AnthropicContentBlock[] = [];
306
+ const historyText = historyParts.join("\n");
307
+ if (historyText) {
308
+ result.push({ type: "text", text: historyText });
309
+ }
310
+ // Insert tool result images after history text (Claude sees them in context)
311
+ result.push(...toolResultImageBlocks);
312
+ result.push(...finalUserContent);
313
+
314
+ if (placeholderImageCount > 0) {
315
+ console.warn(
316
+ `[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
317
+ );
318
+ }
319
+
320
+ return result;
321
+ }
322
+
323
+ // No images in final user message: standard text-only path
324
+ const parts: string[] = [];
325
+
326
+ for (const message of context.messages) {
327
+ if (message.role === "user") {
328
+ parts.push("USER:");
329
+ parts.push(userContentToText(message.content));
330
+ } else if (message.role === "assistant") {
331
+ parts.push("ASSISTANT:");
332
+ parts.push(contentToText(message.content));
333
+ } else if (message.role === "toolResult") {
334
+ if (message.toolName && isCustomToolName(message.toolName)) {
335
+ // Custom tools: don't reference MCP tool name. Present result plainly.
336
+ parts.push(`TOOL RESULT (${message.toolName}):`);
337
+ } else {
338
+ const claudeToolName = message.toolName
339
+ ? mapPiToolNameToDroid(message.toolName)
340
+ : "unknown";
341
+ parts.push(`TOOL RESULT (${claudeToolName}):`);
342
+ }
343
+ parts.push(toolResultContentToText(message.content));
344
+ }
345
+ }
346
+
347
+ if (placeholderImageCount > 0) {
348
+ console.warn(
349
+ `[droid-cli] ${placeholderImageCount} image(s) in conversation history could not be included in the prompt`,
350
+ );
351
+ }
352
+
353
+ return parts.join("\n") || "";
354
+ }
355
+
356
+ /**
357
+ * Find the index of the last user message in the messages array.
358
+ * Returns -1 if no user message found.
359
+ */
360
+ function findFinalUserMessageIndex(messages: PiMessage[]): number {
361
+ for (let i = messages.length - 1; i >= 0; i--) {
362
+ if (messages[i].role === "user") return i;
363
+ }
364
+ return -1;
365
+ }
366
+
367
+ /**
368
+ * Builds the system prompt from the context's systemPrompt field,
369
+ * appending AGENTS.md content if found (walking up from cwd, then global fallback).
370
+ * Sanitizes .pi references to .claude for Claude Code compatibility.
371
+ */
372
+ export function buildSystemPrompt(
373
+ context: PiContext,
374
+ cwd: string,
375
+ ): string {
376
+ const parts: string[] = [];
377
+
378
+ if (context.systemPrompt) {
379
+ parts.push(rewriteCustomToolReferences(context.systemPrompt, context.tools));
380
+ }
381
+
382
+ // Look for AGENTS.md
383
+ const agentsPath = resolveAgentsMdPath(cwd);
384
+ if (agentsPath) {
385
+ try {
386
+ const content = readFileSync(agentsPath, "utf-8");
387
+ const sanitized = sanitizeAgentsContent(content);
388
+ parts.push(sanitized);
389
+ } catch {
390
+ // If we can't read it, skip silently
391
+ }
392
+ }
393
+
394
+ // When conversation history has tool results, instruct Claude to use them
395
+ // instead of trying to re-call tools (which may not be available).
396
+ if (context.messages?.some((m) => m.role === "toolResult")) {
397
+ parts.push(
398
+ "IMPORTANT: The conversation history below contains tool results from previously executed tools. " +
399
+ "Use these results to answer the user's question. Do NOT attempt to re-call tools that already have results.",
400
+ );
401
+ }
402
+
403
+ const customToolsAddendum = buildCustomToolsAddendum(context.tools);
404
+ if (customToolsAddendum) {
405
+ parts.push(customToolsAddendum);
406
+ }
407
+
408
+ return parts.join("\n\n");
409
+ }
410
+
411
+ /** Pi built-in tool names — these go through pi's wrapped built-ins, not MCP. */
412
+ const BUILT_IN_PI_TOOLS = new Set([
413
+ "read",
414
+ "write",
415
+ "edit",
416
+ "bash",
417
+ "grep",
418
+ "find",
419
+ ]);
420
+
421
+ /**
422
+ * Rewrite bare references to custom pi tool names (e.g. `fn_review_spec`,
423
+ * `fn_review_spec()`) in the system prompt so they appear as their
424
+ * MCP-prefixed names (`mcp__custom-tools__fn_review_spec`). Engine prompts are
425
+ * written for direct API tool calls; under droid-cli the same tools are
426
+ * reachable only through the MCP shim. Without this rewrite, models like
427
+ * Sonnet 4.6 inconsistently translate the names — sometimes calling MCP
428
+ * variants, sometimes silently skipping the call (observed in triage where
429
+ * `fn_review_spec` was never invoked even though the prompt said "MUST call").
430
+ *
431
+ * Only rewrites whole-word matches anchored to a non-identifier boundary, so
432
+ * substrings inside other identifiers stay intact. Skips already-prefixed
433
+ * occurrences (`mcp__custom-tools__fn_review_spec`) and pi built-ins.
434
+ */
435
+ function rewriteCustomToolReferences(
436
+ prompt: string,
437
+ tools: ReadonlyArray<PiToolLike> | undefined,
438
+ ): string {
439
+ if (!prompt || !tools || tools.length === 0) {
440
+ return prompt;
441
+ }
442
+
443
+ let result = prompt;
444
+ for (const tool of tools) {
445
+ if (BUILT_IN_PI_TOOLS.has(tool.name)) continue;
446
+ // \b doesn't treat `_` as a word boundary the way we want here, so anchor
447
+ // the match between either start-of-string/non-identifier-char and either
448
+ // end-of-string/non-identifier-char. Also negative-lookbehind for
449
+ // `mcp__custom-tools__` so we don't double-prefix.
450
+ const escaped = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
451
+ const pattern = new RegExp(
452
+ `(?<![A-Za-z0-9_])(?<!mcp__custom-tools__)${escaped}(?![A-Za-z0-9_])`,
453
+ "g",
454
+ );
455
+ result = result.replace(pattern, `mcp__custom-tools__${tool.name}`);
456
+ }
457
+ return result;
458
+ }
459
+
460
+ /**
461
+ * Build a system-prompt addendum that maps each custom pi tool to its
462
+ * MCP-exposed name (`mcp__custom-tools__<name>`) and tells Claude to call
463
+ * those names directly. We intentionally avoid a ToolSearch prerequisite:
464
+ * requiring an internal discovery step can send the model into long internal
465
+ * tool loops before it emits actionable pi tool calls.
466
+ *
467
+ * Returns an empty string when there are no custom tools so the addendum
468
+ * doesn't pollute prompts on plain chat sessions with only built-ins.
469
+ */
470
+ function buildCustomToolsAddendum(
471
+ tools: ReadonlyArray<PiToolLike> | undefined,
472
+ ): string {
473
+ if (!tools || tools.length === 0) return "";
474
+ const customNames = tools
475
+ .map((t) => t.name)
476
+ .filter((name) => !BUILT_IN_PI_TOOLS.has(name));
477
+ if (customNames.length === 0) return "";
478
+
479
+ const lines = customNames
480
+ .sort()
481
+ .map((name) => `- \`${name}\` is exposed as \`mcp__custom-tools__${name}\``);
482
+
483
+ return [
484
+ "## Custom tool naming (MCP)",
485
+ "",
486
+ "The following pi extension tools are available under MCP-prefixed",
487
+ "names. When a system prompt or task instruction asks you to call one",
488
+ "of these by its short name, call the MCP-prefixed name directly.",
489
+ "",
490
+ ...lines,
491
+ ].join("\n");
492
+ }
493
+
494
+ /**
495
+ * Converts user message content to text.
496
+ * Handles string content and array of content blocks.
497
+ * Image blocks are replaced with placeholder text (HIST-02).
498
+ * Increments the module-level placeholderImageCount for each image.
499
+ */
500
+ function userContentToText(content: string | unknown[]): string {
501
+ if (typeof content === "string") return content;
502
+ if (!Array.isArray(content)) return "";
503
+
504
+ const texts: string[] = [];
505
+ for (const rawBlock of content) {
506
+ const block = rawBlock as Record<string, unknown>;
507
+ if (block.type === "text") {
508
+ texts.push(typeof block.text === "string" ? block.text : "");
509
+ } else if (block.type === "image") {
510
+ texts.push("[An image was shared here but could not be included]");
511
+ placeholderImageCount++;
512
+ }
513
+ // Unknown block types silently skipped
514
+ }
515
+ return texts.join("\n");
516
+ }
517
+
518
+ /**
519
+ * Converts assistant message content to text.
520
+ * Handles string content and array of content blocks (text, thinking, toolCall).
521
+ */
522
+ function contentToText(content: string | unknown[]): string {
523
+ if (typeof content === "string") return content;
524
+ if (!Array.isArray(content)) return "";
525
+
526
+ return content
527
+ .map((rawBlock) => {
528
+ const block = rawBlock as Record<string, unknown>;
529
+ if (block.type === "text") return typeof block.text === "string" ? block.text : "";
530
+ if (block.type === "thinking") return ""; // Skip thinking — internal reasoning, not conversation
531
+ if (block.type === "toolCall") {
532
+ const name = typeof block.name === "string" ? block.name : "";
533
+ const rawArgs = block.arguments;
534
+ // A toolCall may carry either parsed args (object) or the raw unparsed
535
+ // string that pi produced — preserve the raw string verbatim so callers
536
+ // can see what the model actually sent.
537
+ const argsObject =
538
+ rawArgs && typeof rawArgs === "object" ? (rawArgs as Record<string, unknown>) : undefined;
539
+ const isCustom = isCustomToolName(name);
540
+ if (isCustom) {
541
+ // Custom tools: don't reference the MCP tool name — Claude might try to re-call it.
542
+ // Just note what was done. The result follows as a TOOL RESULT message.
543
+ const argsStr = argsObject
544
+ ? JSON.stringify(argsObject)
545
+ : typeof rawArgs === "string"
546
+ ? JSON.stringify(rawArgs)
547
+ : "{}";
548
+ return `[Used ${name} tool with args: ${argsStr}]`;
549
+ }
550
+ const claudeName = mapPiToolNameToDroid(name);
551
+ const claudeArgs = argsObject ? translatePiArgsToDroid(name, argsObject) : undefined;
552
+ const argsStr = claudeArgs
553
+ ? JSON.stringify(claudeArgs)
554
+ : typeof rawArgs === "string"
555
+ ? JSON.stringify(rawArgs)
556
+ : "{}";
557
+ return `[Prior tool call — already executed; result follows in TOOL RESULT (${claudeName}):] args=${argsStr}`;
558
+ }
559
+ // Unknown block types are represented as a placeholder
560
+ return `[${String(block.type)}]`;
561
+ })
562
+ .join("\n");
563
+ }
564
+
565
+ /**
566
+ * Converts tool result content to text.
567
+ * Handles string content and array of content blocks.
568
+ * Image blocks get placeholder text (actual image passthrough handled separately).
569
+ */
570
+ function toolResultContentToText(content: string | unknown[]): string {
571
+ if (typeof content === "string") return content;
572
+ if (!Array.isArray(content)) return "";
573
+
574
+ const texts: string[] = [];
575
+ for (const rawBlock of content) {
576
+ const block = rawBlock as Record<string, unknown>;
577
+ if (block.type === "text") {
578
+ texts.push(typeof block.text === "string" ? block.text : "");
579
+ } else if (block.type === "image") {
580
+ texts.push("[An image was shared here but could not be included]");
581
+ placeholderImageCount++;
582
+ }
583
+ }
584
+ return texts.join("\n");
585
+ }
586
+
587
+ /**
588
+ * Check if a tool result content array contains image blocks.
589
+ */
590
+ function toolResultHasImages(content: string | unknown[]): boolean {
591
+ if (typeof content === "string" || !Array.isArray(content)) return false;
592
+ return content.some((block) => (block as Record<string, unknown>).type === "image");
593
+ }
594
+
595
+ /**
596
+ * Walk up from cwd looking for AGENTS.md, fall back to ~/.pi/agent/AGENTS.md.
597
+ */
598
+ function resolveAgentsMdPath(cwd: string): string | undefined {
599
+ let current = resolve(cwd);
600
+ while (true) {
601
+ const candidate = join(current, "AGENTS.md");
602
+ if (existsSync(candidate)) return candidate;
603
+ const parent = dirname(current);
604
+ if (parent === current) break;
605
+ current = parent;
606
+ }
607
+
608
+ // Fall back to global path
609
+ const globalHome = process.env.HOME || process.env.USERPROFILE || homedir();
610
+ const globalPath = join(globalHome, ".pi", "agent", "AGENTS.md");
611
+ if (existsSync(globalPath)) return globalPath;
612
+
613
+ return undefined;
614
+ }
615
+
616
+ /**
617
+ * Sanitize .pi references to .claude in AGENTS.md content
618
+ * for Claude Code compatibility.
619
+ */
620
+ function sanitizeAgentsContent(content: string): string {
621
+ let sanitized = content;
622
+ // ~/.pi -> ~/.claude
623
+ sanitized = sanitized.replace(/~\/\.pi\b/gi, "~/.claude");
624
+ // .pi/ -> .claude/ (at word boundary or after whitespace/quotes)
625
+ sanitized = sanitized.replace(/(^|[\s'"`])\.pi\//g, "$1.claude/");
626
+ // Remaining standalone .pi references
627
+ sanitized = sanitized.replace(/\b\.pi\b/gi, ".claude");
628
+ return sanitized;
629
+ }