feat: add /context prompt breakdown

This commit is contained in:
Peter Steinberger
2026-01-15 01:06:19 +00:00
parent 632651aee2
commit bcde09ae91
13 changed files with 587 additions and 1 deletions

View File

@@ -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) {

View File

@@ -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<SessionSystemPromptReport> {
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<ReplyPayload> {
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"),
};
}

View File

@@ -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,

View File

@@ -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 || "<unknown>"}`,
);
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;

View File

@@ -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):");
});
});