feat: add /context prompt breakdown
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof discoverAuthStorage>;
|
||||
type ModelRegistry = ReturnType<typeof discoverModels>;
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
150
src/agents/system-prompt-report.ts
Normal file
150
src/agents/system-prompt-report.ts
Normal file
@@ -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(/<skill>[\s\S]*?<\/skill>/gi)).map(
|
||||
(match) => match[0] ?? "",
|
||||
);
|
||||
return blocks
|
||||
.map((block) => {
|
||||
const name =
|
||||
block.match(/<name>\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<string, unknown>)
|
||||
: null;
|
||||
const props = schema && typeof schema.properties === "object" ? schema.properties : null;
|
||||
if (!props || typeof props !== "object") return null;
|
||||
return Object.keys(props as Record<string, unknown>).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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
296
src/auto-reply/reply/commands-context-report.ts
Normal file
296
src/auto-reply/reply/commands-context-report.ts
Normal 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"),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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):");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user