diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index fd95b2ec8..c091d5abd 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -55,6 +55,7 @@ Text + native (when enabled): - `/help` - `/commands` - `/status` (show current status; includes a short provider usage/quota line when available) +- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/usage` (alias: `/status`) - `/whoami` (show your sender id; alias: `/id`) - `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`) @@ -90,7 +91,9 @@ Notes: - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). -- **Inline shortcuts (allowlisted senders only):** `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`) also work when embedded in text. +- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. + - Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow. + - Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`). - Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text. ## Usage vs cost (what shows where) diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 8127ed2ec..6cbb44045 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -250,6 +250,7 @@ export async function runEmbeddedPiAgent( provider, model: model.id, }, + systemPromptReport: attempt.systemPromptReport, error: { kind, message: errorText }, }, }; @@ -404,6 +405,7 @@ export async function runEmbeddedPiAgent( durationMs: Date.now() - started, agentMeta, aborted, + systemPromptReport: attempt.systemPromptReport, }, didSendViaMessagingTool: attempt.didSendViaMessagingTool, messagingToolSentTexts: attempt.messagingToolSentTexts, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 8b2e9f710..93e881c59 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -37,6 +37,7 @@ import { loadWorkspaceSkillEntries, resolveSkillsPromptForRun, } from "../../skills.js"; +import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { filterBootstrapFilesForSession, loadWorkspaceBootstrapFiles } from "../../workspace.js"; import { isAbortError } from "../abort.js"; @@ -62,6 +63,7 @@ import { resolveExecToolDefaults, resolveUserTimezone, } from "../utils.js"; +import { resolveSandboxRuntimeStatus } from "../../sandbox/runtime-status.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; @@ -198,6 +200,28 @@ export async function runEmbeddedAttempt( userTime, contextFiles, }); + const systemPromptReport = buildSystemPromptReport({ + source: "run", + generatedAt: Date.now(), + sessionId: params.sessionId, + sessionKey: params.sessionKey, + provider: params.provider, + model: params.modelId, + workspaceDir: effectiveWorkspace, + bootstrapMaxChars: resolveBootstrapMaxChars(params.config), + sandbox: (() => { + const runtime = resolveSandboxRuntimeStatus({ + cfg: params.config, + sessionKey: params.sessionKey ?? params.sessionId, + }); + return { mode: runtime.mode, sandboxed: runtime.sandboxed }; + })(), + systemPrompt: appendPrompt, + bootstrapFiles, + injectedFiles: contextFiles, + skillsPrompt, + tools, + }); const systemPrompt = createSystemPromptOverride(appendPrompt); const sessionLock = await acquireSessionWriteLock({ @@ -427,6 +451,7 @@ export async function runEmbeddedAttempt( timedOut, promptError, sessionIdUsed, + systemPromptReport, messagesSnapshot, assistantTexts, toolMetas: toolMetasNormalized, diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index e132511f0..92b2ff9e2 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -8,6 +8,7 @@ import type { ExecElevatedDefaults } from "../../bash-tools.js"; import type { MessagingToolSend } from "../../pi-embedded-messaging.js"; import type { BlockReplyChunking } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; +import type { SessionSystemPromptReport } from "../../../config/sessions/types.js"; type AuthStorage = ReturnType; type ModelRegistry = ReturnType; @@ -65,6 +66,7 @@ export type EmbeddedRunAttemptResult = { timedOut: boolean; promptError: unknown; sessionIdUsed: string; + systemPromptReport?: SessionSystemPromptReport; messagesSnapshot: AgentMessage[]; assistantTexts: string[]; toolMetas: Array<{ toolName: string; meta?: string }>; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 7a40a4a22..3525de004 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -1,4 +1,5 @@ import type { MessagingToolSend } from "../pi-embedded-messaging.js"; +import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; export type EmbeddedPiAgentMeta = { sessionId: string; @@ -17,6 +18,7 @@ export type EmbeddedPiRunMeta = { durationMs: number; agentMeta?: EmbeddedPiAgentMeta; aborted?: boolean; + systemPromptReport?: SessionSystemPromptReport; error?: { kind: "context_overflow" | "compaction_failure"; message: string; diff --git a/src/agents/system-prompt-report.ts b/src/agents/system-prompt-report.ts new file mode 100644 index 000000000..eda8e4237 --- /dev/null +++ b/src/agents/system-prompt-report.ts @@ -0,0 +1,150 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; + +import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; +import type { WorkspaceBootstrapFile } from "./workspace.js"; +import type { SessionSystemPromptReport } from "../config/sessions/types.js"; + +function extractBetween( + input: string, + startMarker: string, + endMarker: string, +): { text: string; found: boolean } { + const start = input.indexOf(startMarker); + if (start === -1) return { text: "", found: false }; + const end = input.indexOf(endMarker, start + startMarker.length); + if (end === -1) return { text: input.slice(start), found: true }; + return { text: input.slice(start, end), found: true }; +} + +function parseSkillBlocks(skillsPrompt: string): Array<{ name: string; blockChars: number }> { + const prompt = skillsPrompt.trim(); + if (!prompt) return []; + const blocks = Array.from(prompt.matchAll(/[\s\S]*?<\/skill>/gi)).map( + (match) => match[0] ?? "", + ); + return blocks + .map((block) => { + const name = + block.match(/\s*([^<]+?)\s*<\/name>/i)?.[1]?.trim() || "(unknown)"; + return { name, blockChars: block.length }; + }) + .filter((b) => b.blockChars > 0); +} + +function buildInjectedWorkspaceFiles(params: { + bootstrapFiles: WorkspaceBootstrapFile[]; + injectedFiles: EmbeddedContextFile[]; + bootstrapMaxChars: number; +}): SessionSystemPromptReport["injectedWorkspaceFiles"] { + const injectedByName = new Map(params.injectedFiles.map((f) => [f.path, f.content])); + return params.bootstrapFiles.map((file) => { + const rawChars = file.missing ? 0 : (file.content ?? "").trimEnd().length; + const injected = injectedByName.get(file.name); + const injectedChars = injected ? injected.length : 0; + const truncated = !file.missing && rawChars > params.bootstrapMaxChars; + return { + name: file.name, + path: file.path, + missing: file.missing, + rawChars, + injectedChars, + truncated, + }; + }); +} + +function buildToolsEntries(tools: AgentTool[]): SessionSystemPromptReport["tools"]["entries"] { + return tools.map((tool) => { + const name = tool.name; + const summary = tool.description?.trim() || tool.label?.trim() || ""; + const summaryChars = summary.length; + const schemaChars = (() => { + if (!tool.parameters || typeof tool.parameters !== "object") return 0; + try { + return JSON.stringify(tool.parameters).length; + } catch { + return 0; + } + })(); + const propertiesCount = (() => { + const schema = + tool.parameters && typeof tool.parameters === "object" + ? (tool.parameters as Record) + : null; + const props = schema && typeof schema.properties === "object" ? schema.properties : null; + if (!props || typeof props !== "object") return null; + return Object.keys(props as Record).length; + })(); + return { name, summaryChars, schemaChars, propertiesCount }; + }); +} + +function extractToolListText(systemPrompt: string): string { + const markerA = "Tool names are case-sensitive. Call tools exactly as listed.\n"; + const markerB = "\nTOOLS.md does not control tool availability; it is user guidance for how to use external tools."; + const extracted = extractBetween(systemPrompt, markerA, markerB); + if (!extracted.found) return ""; + return extracted.text.replace(markerA, "").trim(); +} + +export function buildSystemPromptReport(params: { + source: SessionSystemPromptReport["source"]; + generatedAt: number; + sessionId?: string; + sessionKey?: string; + provider?: string; + model?: string; + workspaceDir?: string; + bootstrapMaxChars: number; + sandbox?: SessionSystemPromptReport["sandbox"]; + systemPrompt: string; + bootstrapFiles: WorkspaceBootstrapFile[]; + injectedFiles: EmbeddedContextFile[]; + skillsPrompt: string; + tools: AgentTool[]; +}): SessionSystemPromptReport { + const systemPrompt = params.systemPrompt.trim(); + const projectContext = extractBetween( + systemPrompt, + "\n# Project Context\n", + "\n## Silent Replies\n", + ); + const projectContextChars = projectContext.text.length; + const toolListText = extractToolListText(systemPrompt); + const toolListChars = toolListText.length; + const toolsEntries = buildToolsEntries(params.tools); + const toolsSchemaChars = toolsEntries.reduce((sum, t) => sum + (t.schemaChars ?? 0), 0); + const skillsEntries = parseSkillBlocks(params.skillsPrompt); + + return { + source: params.source, + generatedAt: params.generatedAt, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + provider: params.provider, + model: params.model, + workspaceDir: params.workspaceDir, + bootstrapMaxChars: params.bootstrapMaxChars, + sandbox: params.sandbox, + systemPrompt: { + chars: systemPrompt.length, + projectContextChars, + nonProjectContextChars: Math.max(0, systemPrompt.length - projectContextChars), + }, + injectedWorkspaceFiles: buildInjectedWorkspaceFiles({ + bootstrapFiles: params.bootstrapFiles, + injectedFiles: params.injectedFiles, + bootstrapMaxChars: params.bootstrapMaxChars, + }), + skills: { + promptChars: params.skillsPrompt.length, + entries: skillsEntries, + }, + tools: { + listChars: toolListChars, + schemaChars: toolsSchemaChars, + entries: toolsEntries, + }, + }; +} + diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 879307f27..1707c79b4 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -108,6 +108,13 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { description: "Show current status.", textAlias: "/status", }), + defineChatCommand({ + key: "context", + nativeName: "context", + description: "Explain how context is built and used.", + textAlias: "/context", + acceptsArgs: true, + }), defineChatCommand({ key: "whoami", nativeName: "whoami", diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 084dc619e..103df3432 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -351,6 +351,7 @@ export async function runReplyAgent(params: { modelProvider: providerUsed, model: modelUsed, contextTokens: contextTokensUsed ?? entry.contextTokens, + systemPromptReport: runResult.meta.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), }; if (cliSessionId) { @@ -378,6 +379,7 @@ export async function runReplyAgent(params: { modelProvider: providerUsed ?? entry.modelProvider, model: modelUsed ?? entry.model, contextTokens: contextTokensUsed ?? entry.contextTokens, + systemPromptReport: runResult.meta.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), }; if (cliSessionId) { diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts new file mode 100644 index 000000000..418e34019 --- /dev/null +++ b/src/auto-reply/reply/commands-context-report.ts @@ -0,0 +1,296 @@ +import { buildBootstrapContextFiles, resolveBootstrapMaxChars } from "../../agents/pi-embedded-helpers.js"; +import { createClawdbotCodingTools } from "../../agents/pi-tools.js"; +import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js"; +import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; +import { buildAgentSystemPrompt } from "../../agents/system-prompt.js"; +import { buildSystemPromptReport } from "../../agents/system-prompt-report.js"; +import { buildToolSummaryMap } from "../../agents/tool-summaries.js"; +import { + filterBootstrapFilesForSession, + loadWorkspaceBootstrapFiles, +} from "../../agents/workspace.js"; +import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; +import type { ReplyPayload } from "../types.js"; +import type { HandleCommandsParams } from "./commands-types.js"; + +function estimateTokensFromChars(chars: number): number { + return Math.ceil(Math.max(0, chars) / 4); +} + +function formatInt(n: number): string { + return new Intl.NumberFormat("en-US").format(n); +} + +function formatCharsAndTokens(chars: number): string { + return `${formatInt(chars)} chars (~${formatInt(estimateTokensFromChars(chars))} tok)`; +} + +function parseContextArgs(commandBodyNormalized: string): string { + if (commandBodyNormalized === "/context") return ""; + if (commandBodyNormalized.startsWith("/context ")) return commandBodyNormalized.slice(8).trim(); + return ""; +} + +function formatListTop( + entries: Array<{ name: string; value: number }>, + cap: number, +): { lines: string[]; omitted: number } { + const sorted = [...entries].sort((a, b) => b.value - a.value); + const top = sorted.slice(0, cap); + const omitted = Math.max(0, sorted.length - top.length); + const lines = top.map((e) => `- ${e.name}: ${formatCharsAndTokens(e.value)}`); + return { lines, omitted }; +} + +async function resolveContextReport( + params: HandleCommandsParams, +): Promise { + const existing = params.sessionEntry?.systemPromptReport; + if (existing && existing.source === "run") return existing; + + const workspaceDir = params.workspaceDir; + const bootstrapMaxChars = resolveBootstrapMaxChars(params.cfg); + const bootstrapFiles = filterBootstrapFilesForSession( + await loadWorkspaceBootstrapFiles(workspaceDir), + params.sessionKey, + ); + const injectedFiles = buildBootstrapContextFiles(bootstrapFiles, { + maxChars: bootstrapMaxChars, + }); + const skillsSnapshot = (() => { + try { + return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg }); + } catch { + return { prompt: "", skills: [], resolvedSkills: [] }; + } + })(); + const skillsPrompt = skillsSnapshot.prompt ?? ""; + const sandboxRuntime = resolveSandboxRuntimeStatus({ + cfg: params.cfg, + sessionKey: params.ctx.SessionKey ?? params.sessionKey, + }); + const tools = (() => { + try { + return createClawdbotCodingTools({ + config: params.cfg, + workspaceDir, + sessionKey: params.sessionKey, + messageProvider: params.command.channel, + modelProvider: params.provider, + modelId: params.model, + }); + } catch { + return []; + } + })(); + const toolSummaries = buildToolSummaryMap(tools); + const toolNames = tools.map((t) => t.name); + const runtimeInfo = { + host: "unknown", + os: "unknown", + arch: "unknown", + node: process.version, + model: `${params.provider}/${params.model}`, + }; + const sandboxInfo = sandboxRuntime.sandboxed + ? { + enabled: true, + workspaceDir, + workspaceAccess: "rw" as const, + elevated: { + allowed: params.elevated.allowed, + defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const), + }, + } + : { enabled: false }; + + const systemPrompt = buildAgentSystemPrompt({ + workspaceDir, + defaultThinkLevel: params.resolvedThinkLevel, + reasoningLevel: params.resolvedReasoningLevel, + extraSystemPrompt: undefined, + ownerNumbers: undefined, + reasoningTagHint: false, + toolNames, + toolSummaries, + modelAliasLines: [], + userTimezone: "", + userTime: "", + contextFiles: injectedFiles, + skillsPrompt, + heartbeatPrompt: undefined, + runtimeInfo, + sandboxInfo, + }); + + return buildSystemPromptReport({ + source: "estimate", + generatedAt: Date.now(), + sessionId: params.sessionEntry?.sessionId, + sessionKey: params.sessionKey, + provider: params.provider, + model: params.model, + workspaceDir, + bootstrapMaxChars, + sandbox: { mode: sandboxRuntime.mode, sandboxed: sandboxRuntime.sandboxed }, + systemPrompt, + bootstrapFiles, + injectedFiles, + skillsPrompt, + tools, + }); +} + +export async function buildContextReply(params: HandleCommandsParams): Promise { + const args = parseContextArgs(params.command.commandBodyNormalized); + const sub = args.split(/\s+/).filter(Boolean)[0]?.toLowerCase() ?? ""; + + if (!sub || sub === "help") { + return { + text: [ + "🧠 /context", + "", + "What counts as context (high-level), plus a breakdown mode.", + "", + "Try:", + "- /context list (short breakdown)", + "- /context detail (per-file + per-tool + per-skill + system prompt size)", + "- /context json (same, machine-readable)", + "", + "Inline shortcut = a command token inside a normal message (e.g. “hey /status”). It runs immediately (allowlisted senders only) and is stripped before the model sees the remaining text.", + ].join("\n"), + }; + } + + const report = await resolveContextReport(params); + const session = { + totalTokens: params.sessionEntry?.totalTokens ?? null, + inputTokens: params.sessionEntry?.inputTokens ?? null, + outputTokens: params.sessionEntry?.outputTokens ?? null, + contextTokens: params.contextTokens ?? null, + } as const; + + if (sub === "json") { + return { text: JSON.stringify({ report, session }, null, 2) }; + } + + if (sub !== "list" && sub !== "show" && sub !== "detail" && sub !== "deep") { + return { + text: [ + "Unknown /context mode.", + "Use: /context, /context list, /context detail, or /context json", + ].join("\n"), + }; + } + + const fileLines = report.injectedWorkspaceFiles.map((f) => { + const status = f.missing ? "MISSING" : f.truncated ? "TRUNCATED" : "OK"; + const raw = f.missing ? "0" : formatCharsAndTokens(f.rawChars); + const injected = f.missing ? "0" : formatCharsAndTokens(f.injectedChars); + return `- ${f.name}: ${status} | raw ${raw} | injected ${injected}`; + }); + + const sandboxLine = `Sandbox: mode=${report.sandbox?.mode ?? "unknown"} sandboxed=${report.sandbox?.sandboxed ?? false}`; + const toolSchemaLine = `Tool schemas (JSON): ${formatCharsAndTokens(report.tools.schemaChars)} (counts toward context; not shown as text)`; + const toolListLine = `Tool list (system prompt text): ${formatCharsAndTokens(report.tools.listChars)}`; + const skillNameSet = new Set(report.skills.entries.map((s) => s.name)); + const skillNames = Array.from(skillNameSet); + const toolNames = report.tools.entries.map((t) => t.name); + const formatNameList = (names: string[], cap: number) => + names.length <= cap ? names.join(", ") : `${names.slice(0, cap).join(", ")}, … (+${names.length - cap} more)`; + const skillsLine = `Skills list (system prompt text): ${formatCharsAndTokens(report.skills.promptChars)} (${skillNameSet.size} skills)`; + const skillsNamesLine = skillNameSet.size + ? `Skills: ${formatNameList(skillNames, 20)}` + : "Skills: (none)"; + const toolsNamesLine = toolNames.length + ? `Tools: ${formatNameList(toolNames, 30)}` + : "Tools: (none)"; + const systemPromptLine = `System prompt (${report.source}): ${formatCharsAndTokens(report.systemPrompt.chars)} (Project Context ${formatCharsAndTokens(report.systemPrompt.projectContextChars)})`; + const workspaceLabel = report.workspaceDir ?? params.workspaceDir; + const bootstrapMaxLabel = + typeof report.bootstrapMaxChars === "number" ? `${formatInt(report.bootstrapMaxChars)} chars` : "? chars"; + + const totalsLine = + session.totalTokens != null + ? `Session tokens (cached): ${formatInt(session.totalTokens)} total / ctx=${session.contextTokens ?? "?"}` + : `Session tokens (cached): unknown / ctx=${session.contextTokens ?? "?"}`; + + if (sub === "detail" || sub === "deep") { + const perSkill = formatListTop( + report.skills.entries.map((s) => ({ name: s.name, value: s.blockChars })), + 30, + ); + const perToolSchema = formatListTop( + report.tools.entries.map((t) => ({ name: t.name, value: t.schemaChars })), + 30, + ); + const perToolSummary = formatListTop( + report.tools.entries.map((t) => ({ name: t.name, value: t.summaryChars })), + 30, + ); + const toolPropsLines = report.tools.entries + .filter((t) => t.propertiesCount != null) + .sort((a, b) => (b.propertiesCount ?? 0) - (a.propertiesCount ?? 0)) + .slice(0, 30) + .map((t) => `- ${t.name}: ${t.propertiesCount} params`); + + return { + text: [ + "🧠 Context breakdown (detailed)", + `Workspace: ${workspaceLabel}`, + `Bootstrap max/file: ${bootstrapMaxLabel}`, + sandboxLine, + systemPromptLine, + "", + "Injected workspace files:", + ...fileLines, + "", + skillsLine, + skillsNamesLine, + ...(perSkill.lines.length ? ["Top skills (prompt entry size):", ...perSkill.lines] : []), + ...(perSkill.omitted ? [`… (+${perSkill.omitted} more skills)`] : []), + "", + toolListLine, + toolSchemaLine, + toolsNamesLine, + "Top tools (schema size):", + ...perToolSchema.lines, + ...(perToolSchema.omitted ? [`… (+${perToolSchema.omitted} more tools)`] : []), + "", + "Top tools (summary text size):", + ...perToolSummary.lines, + ...(perToolSummary.omitted ? [`… (+${perToolSummary.omitted} more tools)`] : []), + ...(toolPropsLines.length ? ["", "Tools (param count):", ...toolPropsLines] : []), + "", + totalsLine, + "", + "Inline shortcut: a command token inside normal text (e.g. “hey /status”) that runs immediately (allowlisted senders only) and is stripped before the model sees the remaining message.", + ] + .filter(Boolean) + .join("\n"), + }; + } + + return { + text: [ + "🧠 Context breakdown", + `Workspace: ${workspaceLabel}`, + `Bootstrap max/file: ${bootstrapMaxLabel}`, + sandboxLine, + systemPromptLine, + "", + "Injected workspace files:", + ...fileLines, + "", + skillsLine, + skillsNamesLine, + toolListLine, + toolSchemaLine, + toolsNamesLine, + "", + totalsLine, + "", + "Inline shortcut: a command token inside normal text (e.g. “hey /status”) that runs immediately (allowlisted senders only) and is stripped before the model sees the remaining message.", + ].join("\n"), + }; +} diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index 6229fd6fd..abaf948b3 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -6,6 +6,7 @@ import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleCommandsListCommand, + handleContextCommand, handleHelpCommand, handleStatusCommand, handleWhoamiCommand, @@ -31,6 +32,7 @@ const HANDLERS: CommandHandler[] = [ handleHelpCommand, handleCommandsListCommand, handleStatusCommand, + handleContextCommand, handleWhoamiCommand, handleConfigCommand, handleDebugCommand, diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index 97875d2f7..c65cde77f 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -1,6 +1,7 @@ import { logVerbose } from "../../globals.js"; import { buildCommandsMessage, buildHelpMessage } from "../status.js"; import { buildStatusReply } from "./commands-status.js"; +import { buildContextReply } from "./commands-context-report.js"; import type { CommandHandler } from "./commands-types.js"; export const handleHelpCommand: CommandHandler = async (params, allowTextCommands) => { @@ -64,6 +65,19 @@ export const handleStatusCommand: CommandHandler = async (params, allowTextComma return { shouldContinue: false, reply }; }; +export const handleContextCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + const normalized = params.command.commandBodyNormalized; + if (normalized !== "/context" && !normalized.startsWith("/context ")) return null; + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /context from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + return { shouldContinue: false, reply: await buildContextReply(params) }; +}; + export const handleWhoamiCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) return null; if (params.command.commandBodyNormalized !== "/whoami") return null; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 995df0f1e..eb09b61cd 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -142,3 +142,41 @@ describe("handleCommands identity", () => { expect(result.reply?.text).toContain("AllowFrom: 12345"); }); }); + +describe("handleCommands context", () => { + it("returns context help for /context", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/context", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("/context list"); + expect(result.reply?.text).toContain("Inline shortcut"); + }); + + it("returns a per-file breakdown for /context list", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/context list", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Injected workspace files:"); + expect(result.reply?.text).toContain("AGENTS.md"); + }); + + it("returns a detailed breakdown for /context detail", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + } as ClawdbotConfig; + const params = buildParams("/context detail", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Context breakdown (detailed)"); + expect(result.reply?.text).toContain("Top tools (schema size):"); + }); +}); diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 1b1fbd48a..2d607ba8d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -61,6 +61,7 @@ export type SessionEntry = { lastTo?: string; lastAccountId?: string; skillsSnapshot?: SessionSkillSnapshot; + systemPromptReport?: SessionSystemPromptReport; }; export function mergeSessionEntry( @@ -87,6 +88,48 @@ export type SessionSkillSnapshot = { resolvedSkills?: Skill[]; }; +export type SessionSystemPromptReport = { + source: "run" | "estimate"; + generatedAt: number; + sessionId?: string; + sessionKey?: string; + provider?: string; + model?: string; + workspaceDir?: string; + bootstrapMaxChars?: number; + sandbox?: { + mode?: string; + sandboxed?: boolean; + }; + systemPrompt: { + chars: number; + projectContextChars: number; + nonProjectContextChars: number; + }; + injectedWorkspaceFiles: Array<{ + name: string; + path: string; + missing: boolean; + rawChars: number; + injectedChars: number; + truncated: boolean; + }>; + skills: { + promptChars: number; + entries: Array<{ name: string; blockChars: number }>; + }; + tools: { + listChars: number; + schemaChars: number; + entries: Array<{ + name: string; + summaryChars: number; + schemaChars: number; + propertiesCount?: number | null; + }>; + }; +}; + export const DEFAULT_RESET_TRIGGER = "/new"; export const DEFAULT_RESET_TRIGGERS = ["/new", "/reset"]; export const DEFAULT_IDLE_MINUTES = 60;