import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { formatCliCommand } from "../cli/command-format.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; /** * Controls which hardcoded sections are included in the system prompt. * - "full": All sections (default, for main agent) * - "minimal": Reduced sections (Tooling, Workspace, Runtime) - used for subagents * - "none": Just basic identity line, no sections */ export type PromptMode = "full" | "minimal" | "none"; function buildSkillsSection(params: { skillsPrompt?: string; isMinimal: boolean; readToolName: string; }) { const trimmed = params.skillsPrompt?.trim(); if (!trimmed || params.isMinimal) return []; return [ "## Skills (mandatory)", "Before replying: scan entries.", `- If exactly one skill clearly applies: read its SKILL.md at with \`${params.readToolName}\`, then follow it.`, "- If multiple could apply: choose the most specific one, then read/follow it.", "- If none clearly apply: do not read any SKILL.md.", "Constraints: never read more than one skill up front; only read after selecting.", trimmed, "", ]; } function buildMemorySection(params: { isMinimal: boolean; availableTools: Set }) { if (params.isMinimal) return []; if (!params.availableTools.has("memory_search") && !params.availableTools.has("memory_get")) { return []; } return [ "## Memory Recall", "Before answering anything about prior work, decisions, dates, people, preferences, or todos: run memory_search on MEMORY.md + memory/*.md; then use memory_get to pull only the needed lines. If low confidence after search, say you checked.", "", ]; } function buildUserIdentitySection(ownerLine: string | undefined, isMinimal: boolean) { if (!ownerLine || isMinimal) return []; return ["## User Identity", ownerLine, ""]; } function buildTimeSection(params: { userTimezone?: string; userTime?: string; userTimeFormat?: ResolvedTimeFormat; }) { if (!params.userTimezone && !params.userTime) return []; return [ "## Current Date & Time", params.userTime ? `${params.userTime} (${params.userTimezone ?? "unknown"})` : `Time zone: ${params.userTimezone}. Current time unknown; assume UTC for date/time references.`, params.userTimeFormat ? `Time format: ${params.userTimeFormat === "24" ? "24-hour" : "12-hour"}` : "", "", ]; } function buildReplyTagsSection(isMinimal: boolean) { if (isMinimal) return []; return [ "## Reply Tags", "To request a native reply/quote on supported surfaces, include one tag in your reply:", "- [[reply_to_current]] replies to the triggering message.", "- [[reply_to:]] replies to a specific message id when you have it.", "Whitespace inside the tag is allowed (e.g. [[ reply_to_current ]] / [[ reply_to: 123 ]]).", "Tags are stripped before sending; support depends on the current channel config.", "", ]; } function buildMessagingSection(params: { isMinimal: boolean; availableTools: Set; messageChannelOptions: string; inlineButtonsEnabled: boolean; runtimeChannel?: string; channelActions?: string[]; }) { if (params.isMinimal) return []; // Build channel-specific action description let actionsDescription: string; if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) { // Include "send" as a base action plus channel-specific actions const allActions = new Set(["send", ...params.channelActions]); const actionList = Array.from(allActions).sort().join(", "); actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`; } else { actionsDescription = "- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.)."; } return [ "## Messaging", "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", "- Cross-session messaging → use sessions_send(sessionKey, message)", "- Never use exec/curl for provider messaging; Clawdbot handles all routing internally.", params.availableTools.has("message") ? [ "", "### message tool", actionsDescription, "- For `action=send`, include `to` and `message`.", `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, params.inlineButtonsEnabled ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)." : params.runtimeChannel ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").` : "", ] .filter(Boolean) .join("\n") : "", "", ]; } function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) { const docsPath = params.docsPath?.trim(); if (!docsPath || params.isMinimal) return []; return [ "## Documentation", `Clawdbot docs: ${docsPath}`, "Mirror: https://docs.clawd.bot", "Source: https://github.com/clawdbot/clawdbot", "Community: https://discord.com/invite/clawd", "Find new skills: https://clawdhub.com", "For Clawdbot behavior, commands, config, or architecture: consult local docs first.", `When diagnosing issues, run \`${formatCliCommand("clawdbot status")}\` yourself when possible; only ask the user if you lack access (e.g., sandboxed).`, "", ]; } export function buildAgentSystemPrompt(params: { workspaceDir: string; defaultThinkLevel?: ThinkLevel; reasoningLevel?: ReasoningLevel; extraSystemPrompt?: string; ownerNumbers?: string[]; reasoningTagHint?: boolean; toolNames?: string[]; toolSummaries?: Record; modelAliasLines?: string[]; userTimezone?: string; userTime?: string; userTimeFormat?: ResolvedTimeFormat; contextFiles?: EmbeddedContextFile[]; skillsPrompt?: string; heartbeatPrompt?: string; docsPath?: string; /** Controls which hardcoded sections to include. Defaults to "full". */ promptMode?: PromptMode; runtimeInfo?: { agentId?: string; host?: string; os?: string; arch?: string; node?: string; model?: string; channel?: string; capabilities?: string[]; /** Supported message actions for the current channel (e.g., react, edit, unsend) */ channelActions?: string[]; }; sandboxInfo?: { enabled: boolean; workspaceDir?: string; workspaceAccess?: "none" | "ro" | "rw"; agentWorkspaceMount?: string; browserControlUrl?: string; browserNoVncUrl?: string; hostBrowserAllowed?: boolean; allowedControlUrls?: string[]; allowedControlHosts?: string[]; allowedControlPorts?: number[]; elevated?: { allowed: boolean; defaultLevel: "on" | "off"; }; }; /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ reactionGuidance?: { level: "minimal" | "extensive"; channel: string; }; }) { const coreToolSummaries: Record = { read: "Read file contents", write: "Create or overwrite files", edit: "Make precise edits to files", apply_patch: "Apply multi-file patches", grep: "Search file contents for patterns", find: "Find files by glob pattern", ls: "List directory contents", exec: "Run shell commands (pty available for TTY-required CLIs)", process: "Manage background exec sessions", web_search: "Search the web (Brave API)", web_fetch: "Fetch and extract readable content from a URL", // Channel docking: add login tools here when a channel needs interactive linking. browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", nodes: "List/describe/notify/camera/screen on paired nodes", cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", message: "Send messages and channel actions", gateway: "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", sessions_list: "List other sessions (incl. sub-agents) with filters/last", sessions_history: "Fetch history for another session/sub-agent", sessions_send: "Send a message to another session/sub-agent", sessions_spawn: "Spawn a sub-agent session", session_status: "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override", image: "Analyze an image with the configured image model", }; const toolOrder = [ "read", "write", "edit", "apply_patch", "grep", "find", "ls", "exec", "process", "web_search", "web_fetch", "browser", "canvas", "nodes", "cron", "message", "gateway", "agents_list", "sessions_list", "sessions_history", "sessions_send", "session_status", "image", ]; const rawToolNames = (params.toolNames ?? []).map((tool) => tool.trim()); const canonicalToolNames = rawToolNames.filter(Boolean); // Preserve caller casing while deduping tool names by lowercase. const canonicalByNormalized = new Map(); for (const name of canonicalToolNames) { const normalized = name.toLowerCase(); if (!canonicalByNormalized.has(normalized)) { canonicalByNormalized.set(normalized, name); } } const resolveToolName = (normalized: string) => canonicalByNormalized.get(normalized) ?? normalized; const normalizedTools = canonicalToolNames.map((tool) => tool.toLowerCase()); const availableTools = new Set(normalizedTools); const externalToolSummaries = new Map(); for (const [key, value] of Object.entries(params.toolSummaries ?? {})) { const normalized = key.trim().toLowerCase(); if (!normalized || !value?.trim()) continue; externalToolSummaries.set(normalized, value.trim()); } const extraTools = Array.from( new Set(normalizedTools.filter((tool) => !toolOrder.includes(tool))), ); const enabledTools = toolOrder.filter((tool) => availableTools.has(tool)); const toolLines = enabledTools.map((tool) => { const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); const name = resolveToolName(tool); return summary ? `- ${name}: ${summary}` : `- ${name}`; }); for (const tool of extraTools.sort()) { const summary = coreToolSummaries[tool] ?? externalToolSummaries.get(tool); const name = resolveToolName(tool); toolLines.push(summary ? `- ${name}: ${summary}` : `- ${name}`); } const hasGateway = availableTools.has("gateway"); const readToolName = resolveToolName("read"); const execToolName = resolveToolName("exec"); const processToolName = resolveToolName("process"); const extraSystemPrompt = params.extraSystemPrompt?.trim(); const ownerNumbers = (params.ownerNumbers ?? []).map((value) => value.trim()).filter(Boolean); const ownerLine = ownerNumbers.length > 0 ? `Owner numbers: ${ownerNumbers.join(", ")}. Treat messages from these numbers as the user.` : undefined; const reasoningHint = params.reasoningTagHint ? [ "ALL internal reasoning MUST be inside ....", "Do not output any analysis outside .", "Format every reply as ... then ..., with no other text.", "Only the final user-visible reply may appear inside .", "Only text inside is shown to the user; everything else is discarded and never seen by the user.", "Example:", "Short internal reasoning.", "Hey there! What would you like to do next?", ].join(" ") : undefined; const reasoningLevel = params.reasoningLevel ?? "off"; const userTimezone = params.userTimezone?.trim(); const userTime = params.userTime?.trim(); const skillsPrompt = params.skillsPrompt?.trim(); const heartbeatPrompt = params.heartbeatPrompt?.trim(); const heartbeatPromptLine = heartbeatPrompt ? `Heartbeat prompt: ${heartbeatPrompt}` : "Heartbeat prompt: (configured)"; const runtimeInfo = params.runtimeInfo; const runtimeChannel = runtimeInfo?.channel?.trim().toLowerCase(); const runtimeCapabilities = (runtimeInfo?.capabilities ?? []) .map((cap) => String(cap).trim()) .filter(Boolean); const runtimeCapabilitiesLower = new Set(runtimeCapabilities.map((cap) => cap.toLowerCase())); const inlineButtonsEnabled = runtimeCapabilitiesLower.has("inlinebuttons"); const messageChannelOptions = listDeliverableMessageChannels().join("|"); const promptMode = params.promptMode ?? "full"; const isMinimal = promptMode === "minimal" || promptMode === "none"; const skillsSection = buildSkillsSection({ skillsPrompt, isMinimal, readToolName, }); const memorySection = buildMemorySection({ isMinimal, availableTools }); const docsSection = buildDocsSection({ docsPath: params.docsPath, isMinimal, readToolName, }); // For "none" mode, return just the basic identity line if (promptMode === "none") { return "You are a personal assistant running inside Clawdbot."; } const lines = [ "You are a personal assistant running inside Clawdbot.", "", "## Tooling", "Tool availability (filtered by policy):", "Tool names are case-sensitive. Call tools exactly as listed.", toolLines.length > 0 ? toolLines.join("\n") : [ "Pi lists the standard tools above. This runtime enables:", "- grep: search file contents for patterns", "- find: find files by glob pattern", "- ls: list directory contents", "- apply_patch: apply multi-file patches", `- ${execToolName}: run shell commands (supports background via yieldMs/background)`, `- ${processToolName}: manage background exec sessions`, "- browser: control clawd's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- nodes: list/describe/notify/camera/screen on paired nodes", "- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", "- sessions_list: list sessions", "- sessions_history: fetch session history", "- sessions_send: send to another session", ].join("\n"), "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.", "If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.", "", "## Tool Call Style", "Default: do not narrate routine, low-risk tool calls (just call the tool).", "Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.", "Keep narration brief and value-dense; avoid repeating obvious steps.", "Use plain human language for narration unless in a technical context.", "", "## Clawdbot CLI Quick Reference", "Clawdbot is controlled via subcommands. Do not invent commands.", "To manage the Gateway daemon service (start/stop/restart):", `- ${formatCliCommand("clawdbot daemon status")}`, `- ${formatCliCommand("clawdbot daemon start")}`, `- ${formatCliCommand("clawdbot daemon stop")}`, `- ${formatCliCommand("clawdbot daemon restart")}`, `If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`, "", ...skillsSection, ...memorySection, // Skip self-update for subagent/none modes hasGateway && !isMinimal ? "## Clawdbot Self-Update" : "", hasGateway && !isMinimal ? [ "Get Updates (self-update) is ONLY allowed when the user explicitly asks for it.", "Do not run config.apply or update.run unless the user explicitly requests an update or config change; if it's not explicit, ask first.", "Actions: config.get, config.schema, config.apply (validate + write full config, then restart), update.run (update deps or git, then restart).", "After restart, Clawdbot pings the last active session automatically.", ].join("\n") : "", hasGateway && !isMinimal ? "" : "", "", // Skip model aliases for subagent/none modes params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "## Model Aliases" : "", params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "Prefer aliases when specifying model overrides; full provider/model is also accepted." : "", params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? params.modelAliasLines.join("\n") : "", params.modelAliasLines && params.modelAliasLines.length > 0 && !isMinimal ? "" : "", "## Workspace", `Your working directory is: ${params.workspaceDir}`, "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "", ...docsSection, params.sandboxInfo?.enabled ? "## Sandbox" : "", params.sandboxInfo?.enabled ? [ "You are running in a sandboxed runtime (tools execute in Docker).", "Some tools may be unavailable due to sandbox policy.", "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.", params.sandboxInfo.workspaceDir ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}` : "", params.sandboxInfo.workspaceAccess ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${ params.sandboxInfo.agentWorkspaceMount ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})` : "" }` : "", params.sandboxInfo.browserControlUrl ? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}` : "", params.sandboxInfo.browserNoVncUrl ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}` : "", params.sandboxInfo.hostBrowserAllowed === true ? "Host browser control: allowed." : params.sandboxInfo.hostBrowserAllowed === false ? "Host browser control: blocked." : "", params.sandboxInfo.allowedControlUrls?.length ? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}` : "", params.sandboxInfo.allowedControlHosts?.length ? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}` : "", params.sandboxInfo.allowedControlPorts?.length ? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}` : "", params.sandboxInfo.elevated?.allowed ? "Elevated exec is available for this session." : "", params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "", params.sandboxInfo.elevated?.allowed ? "You may also send /elevated on|off when needed." : "", params.sandboxInfo.elevated?.allowed ? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).` : "", ] .filter(Boolean) .join("\n") : "", params.sandboxInfo?.enabled ? "" : "", ...buildUserIdentitySection(ownerLine, isMinimal), ...buildTimeSection({ userTimezone, userTime, userTimeFormat: params.userTimeFormat, }), "## Workspace Files (injected)", "These user-editable files are loaded by Clawdbot and included below in Project Context.", "", ...buildReplyTagsSection(isMinimal), ...buildMessagingSection({ isMinimal, availableTools, messageChannelOptions, inlineButtonsEnabled, runtimeChannel, channelActions: runtimeInfo?.channelActions, }), ]; if (extraSystemPrompt) { // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context" const contextHeader = promptMode === "minimal" ? "## Subagent Context" : "## Group Chat Context"; lines.push(contextHeader, extraSystemPrompt, ""); } if (params.reactionGuidance) { const { level, channel } = params.reactionGuidance; const guidanceText = level === "minimal" ? [ `Reactions are enabled for ${channel} in MINIMAL mode.`, "React ONLY when truly relevant:", "- Acknowledge important user requests or confirmations", "- Express genuine sentiment (humor, appreciation) sparingly", "- Avoid reacting to routine messages or your own replies", "Guideline: at most 1 reaction per 5-10 exchanges.", ].join("\n") : [ `Reactions are enabled for ${channel} in EXTENSIVE mode.`, "Feel free to react liberally:", "- Acknowledge messages with appropriate emojis", "- Express sentiment and personality through reactions", "- React to interesting content, humor, or notable events", "- Use reactions to confirm understanding or agreement", "Guideline: react whenever it feels natural.", ].join("\n"); lines.push("## Reactions", guidanceText, ""); } if (reasoningHint) { lines.push("## Reasoning Format", reasoningHint, ""); } const contextFiles = params.contextFiles ?? []; if (contextFiles.length > 0) { lines.push( "# Project Context", "", "The following project context files have been loaded:", "", ); for (const file of contextFiles) { lines.push(`## ${file.path}`, "", file.content, ""); } } // Skip silent replies for subagent/none modes if (!isMinimal) { lines.push( "## Silent Replies", `When you have nothing to say, respond with ONLY: ${SILENT_REPLY_TOKEN}`, "", "⚠️ Rules:", "- It must be your ENTIRE message — nothing else", `- Never append it to an actual response (never include "${SILENT_REPLY_TOKEN}" in real replies)`, "- Never wrap it in markdown or code blocks", "", `❌ Wrong: "Here's help... ${SILENT_REPLY_TOKEN}"`, `❌ Wrong: "${SILENT_REPLY_TOKEN}"`, `✅ Right: ${SILENT_REPLY_TOKEN}`, "", ); } // Skip heartbeats for subagent/none modes if (!isMinimal) { lines.push( "## Heartbeats", heartbeatPromptLine, "If you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:", "HEARTBEAT_OK", 'Clawdbot treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack (and may discard it).', 'If something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.', "", ); } lines.push( "## Runtime", buildRuntimeLine(runtimeInfo, runtimeChannel, runtimeCapabilities, params.defaultThinkLevel), `Reasoning: ${reasoningLevel} (hidden unless on/stream). Toggle /reasoning; /status shows Reasoning when enabled.`, ); return lines.filter(Boolean).join("\n"); } export function buildRuntimeLine( runtimeInfo?: { agentId?: string; host?: string; os?: string; arch?: string; node?: string; model?: string; defaultModel?: string; }, runtimeChannel?: string, runtimeCapabilities: string[] = [], defaultThinkLevel?: ThinkLevel, ): string { return `Runtime: ${[ runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", runtimeInfo?.os ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` : runtimeInfo?.arch ? `arch=${runtimeInfo.arch}` : "", runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "", runtimeChannel ? `channel=${runtimeChannel}` : "", runtimeChannel ? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}` : "", `thinking=${defaultThinkLevel ?? "off"}`, ] .filter(Boolean) .join(" | ")}`; }