/** * Prompt and message construction for /btw side requests. * * A side request is one no-tools, single-response call that sees the main * session's conversation plus the earlier exchanges of the side thread. * * Only types are imported from pi-ai, so the message-building rules are unit * tested outside the pi runtime. */ import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai"; import type { BtwExchange } from "./thread.ts"; /** * Instructions attached as the system prompt of every side request. * * Claude Code reuses the main system prompt and keeps the full tool schema in * the request while denying every tool call client-side. This extension sends * a short standalone block instead: the side answer must not inherit the main * agent's tool-oriented prompt, and it must answer from context only. The * trade-off is that a side request shares no prompt-cache prefix with the main * conversation (different system prompt, no tools), so its input is billed in * full. */ export const BTW_SYSTEM_PROMPT = `This is a side question ("/btw") from the user about an ongoing coding session. Answer it directly in a single response. CRITICAL CONSTRAINTS: - You have NO tools available: you cannot read files, run commands, search, or take any actions. - This is a one-off response. The user sees it in a side overlay; no follow-up turn follows inside this response. - Use only information already present in the conversation context. Earlier exchanges are part of this side thread and may be referenced. - NEVER say "Let me check...", "I'll now...", "Let me look...", or promise to do anything. - If the context does not contain the answer, say so directly. Do not offer to investigate. - Answer in the same language as the question. Be concise and specific; prefer one short paragraph or a short list over a long essay.`; /** Model identity needed to fabricate replayed assistant replies. */ export interface SideModelRef { api: string; provider: string; id: string; } export interface SideRequestInput { /** Main-session context converted to provider messages, oldest first. */ context: readonly Message[]; /** Earlier exchanges of the side thread, oldest first. */ replayed: readonly BtwExchange[]; /** The new side question. */ question: string; /** Active model, used for the replayed assistant replies' metadata. */ model: SideModelRef; /** Timestamp for the new question message. */ now: number; } /** * Build the provider messages for one side request. * * Promoted exchanges are part of the main context at the position where they * were appended, so a promoted Q&A can precede older replayed side questions. * Both orders are valid: the main context is chronological, and the replay * block only carries side questions that never entered it. */ export function buildSideRequest(input: SideRequestInput): Message[] { const messages = trimOpenToolCalls(input.context); for (const exchange of input.replayed) { messages.push({ role: "user", content: wrapSideQuestion(exchange.question), timestamp: exchange.answeredAt, }); messages.push(assistantReply(exchange, input.model)); } messages.push({ role: "user", content: wrapSideQuestion(input.question), timestamp: input.now }); return messages; } /** Wrap a side question so replayed and current questions are unambiguous. */ export function wrapSideQuestion(question: string): string { return `\n${question.trim()}\n`; } /** * Keep the longest context prefix in which every tool call is resolved. * * While the main agent executes tools, the session branch can end with an * assistant message whose tool calls have no matching tool result yet. * Providers reject a trailing unresolved tool call, so the side request drops * that unfinished tail instead of failing. */ export function trimOpenToolCalls(messages: readonly Message[]): Message[] { const open = new Set(); let safeLength = 0; for (let index = 0; index < messages.length; index += 1) { const message = messages[index]!; if (message.role === "assistant") { for (const block of message.content) { if (block.type === "toolCall") open.add(block.id); } } else if (message.role === "toolResult") { open.delete(message.toolCallId); } if (open.size === 0) safeLength = index + 1; } return safeLength === messages.length ? [...messages] : messages.slice(0, safeLength); } /** * Fabricate the assistant half of a replayed exchange. * * The recorded answer becomes a normal assistant turn so the model treats the * side thread as an alternating conversation. Token usage is zero because * replaying carries no billing information of its own. */ function assistantReply(exchange: BtwExchange, model: SideModelRef): AssistantMessage { return { role: "assistant", content: [{ type: "text", text: exchange.answer }], api: model.api, provider: model.provider, model: model.id, usage: emptyUsage(), stopReason: "stop", timestamp: exchange.answeredAt, }; } function emptyUsage(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; }