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