@runfusion/fusion 0.18.1 → 0.20.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/dist/bin.js +6533 -2558
- package/dist/client/assets/AgentDetailView-C6BG7O7i.js +18 -0
- package/dist/client/assets/AgentDetailView-CUtWvXBn.css +1 -0
- package/dist/client/assets/ChatView-DeXUYwSY.js +1 -0
- package/dist/client/assets/{DevServerView-r6V3FqkY.js → DevServerView-Dariyxt_.js} +1 -1
- package/dist/client/assets/{DirectoryPicker-CTZE95Fk.js → DirectoryPicker-SchiK-Aq.js} +1 -1
- package/dist/client/assets/{DocumentsView-DSEf1Lmg.js → DocumentsView-C6v-tBhG.js} +1 -1
- package/dist/client/assets/InsightsView-AWo5o_81.css +1 -0
- package/dist/client/assets/InsightsView-Cqim12az.js +11 -0
- package/dist/client/assets/{MemoryView-DicXjec9.js → MemoryView-CakLoJtY.js} +2 -2
- package/dist/client/assets/NodesView-BxGm3poT.js +14 -0
- package/dist/client/assets/{NodesView-sJgPLTzz.css → NodesView-fXqDk9ur.css} +1 -1
- package/dist/client/assets/PiExtensionsManager-lJbmskyZ.js +6 -0
- package/dist/client/assets/PluginManager-BZjNNf9m.js +1 -0
- package/dist/client/assets/ResearchView-Bzsr9V0y.js +1 -0
- package/dist/client/assets/{RoadmapsView-DfEF3mql.js → RoadmapsView-CeKks_OI.js} +2 -2
- package/dist/client/assets/SettingsModal-BWe0KrGY.css +1 -0
- package/dist/client/assets/SettingsModal-D-9CLguN.js +31 -0
- package/dist/client/assets/{SettingsModal-YcScdFiG.js → SettingsModal-YdeVPhRJ.js} +1 -1
- package/dist/client/assets/{SetupWizardModal-DRF5fOoR.css → SetupWizardModal-CGYGKurR.css} +1 -1
- package/dist/client/assets/SetupWizardModal-DAC04LlA.js +1 -0
- package/dist/client/assets/{SkillsView-Dkq2CQla.js → SkillsView-CClC_5RN.js} +1 -1
- package/dist/client/assets/index-CrHLf3pB.js +1222 -0
- package/dist/client/assets/index-Df1bHDY4.css +1 -0
- package/dist/client/assets/star-DxVRh9VT.js +6 -0
- package/dist/client/assets/{users-Cp5TSxVm.js → users-3SD3oNMQ.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/version.json +1 -1
- package/dist/droid-cli/index.ts +3 -5
- package/dist/droid-cli/package.json +1 -1
- package/dist/droid-cli/src/__tests__/event-bridge.test.ts +6 -1315
- package/dist/droid-cli/src/__tests__/provider.test.ts +6 -1927
- package/dist/droid-cli/src/control-handler.ts +1 -82
- package/dist/droid-cli/src/event-bridge.ts +1 -397
- package/dist/droid-cli/src/mcp-config.ts +1 -144
- package/dist/droid-cli/src/process-manager.ts +1 -358
- package/dist/droid-cli/src/prompt-builder.ts +1 -629
- package/dist/droid-cli/src/provider.ts +1 -447
- package/dist/droid-cli/src/stream-parser.ts +1 -37
- package/dist/droid-cli/src/thinking-config.ts +1 -83
- package/dist/droid-cli/src/tool-mapping.ts +1 -147
- package/dist/droid-cli/src/types.ts +1 -87
- package/dist/extension.js +4674 -1748
- package/dist/pi-claude-cli/package.json +1 -1
- package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
- package/package.json +5 -4
- package/skill/fusion/references/engine-tools.md +5 -1
- package/skill/fusion/references/extension-tools.md +3 -1
- package/dist/client/assets/ChatView-3Sqm6teN.js +0 -1
- package/dist/client/assets/InsightsView-4KiUKzbz.css +0 -1
- package/dist/client/assets/InsightsView-F5PZsX5u.js +0 -11
- package/dist/client/assets/NodesView-DddCS7zB.js +0 -14
- package/dist/client/assets/PiExtensionsManager-Ch7si-v8.js +0 -11
- package/dist/client/assets/PluginManager-LcTh_fHP.js +0 -1
- package/dist/client/assets/ResearchView-D0TY1VcX.js +0 -1
- package/dist/client/assets/SettingsModal-SOADcCNJ.js +0 -31
- package/dist/client/assets/SettingsModal-oOnIed5O.css +0 -1
- package/dist/client/assets/SetupWizardModal-EDYuf9Yc.js +0 -1
- package/dist/client/assets/index-4hC8zoTD.css +0 -1
- package/dist/client/assets/index-DNzA4aZ7.js +0 -1229
|
@@ -1,629 +1 @@
|
|
|
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
|
-
}
|
|
1
|
+
export * from "../../../plugins/fusion-plugin-droid-runtime/src/prompt-builder.js";
|