Merge branch 'main' into commands-list-clean
This commit is contained in:
@@ -34,6 +34,13 @@ const CHAT_COMMANDS: ChatCommandDefinition[] = [
|
||||
description: "Show current status.",
|
||||
textAliases: ["/status"],
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
nativeName: "cost",
|
||||
description: "Toggle per-response usage line.",
|
||||
textAliases: ["/cost"],
|
||||
acceptsArgs: true,
|
||||
},
|
||||
{
|
||||
key: "stop",
|
||||
nativeName: "stop",
|
||||
|
||||
@@ -472,6 +472,40 @@ describe("directive behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when elevated is used in direct runtime", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated off",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
sandbox: { mode: "off" },
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode disabled.");
|
||||
expect(text).toContain("Runtime is direct; sandboxing does not apply.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid elevated level", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
@@ -504,6 +538,72 @@ describe("directive behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple directives in a single message", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated off\n/verbose on",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode disabled.");
|
||||
expect(text).toContain("Verbose logging enabled.");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns status alongside directive-only acks", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated off\n/status",
|
||||
From: "+1222",
|
||||
To: "+1222",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1222",
|
||||
},
|
||||
{},
|
||||
{
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1222"] },
|
||||
},
|
||||
},
|
||||
whatsapp: { allowFrom: ["+1222"] },
|
||||
session: { store: path.join(home, "sessions.json") },
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode disabled.");
|
||||
expect(text).toContain("status agent:main:main");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("acks queue directive and persists override", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
|
||||
@@ -14,6 +14,17 @@ vi.mock("../agents/pi-embedded.js", () => ({
|
||||
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const usageMocks = vi.hoisted(() => ({
|
||||
loadProviderUsageSummary: vi.fn().mockResolvedValue({
|
||||
updatedAt: 0,
|
||||
providers: [],
|
||||
}),
|
||||
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
|
||||
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/provider-usage.js", () => usageMocks);
|
||||
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
@@ -66,6 +77,30 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("filters usage summary to the current model provider", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
usageMocks.loadProviderUsageSummary.mockClear();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1000",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("📊 Usage: Claude 80% left");
|
||||
expect(usageMocks.loadProviderUsageSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providers: ["anthropic"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts even with timestamp prefix", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -178,7 +213,71 @@ describe("trigger handling", () => {
|
||||
makeCfg(home),
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("ClawdBot");
|
||||
expect(text).toContain("status");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("reports active auth profile and key snippet in status", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const agentDir = join(home, ".clawdbot", "agents", "main", "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
join(agentDir, "auth-profiles.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:work": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "sk-test-1234567890abcdef",
|
||||
},
|
||||
},
|
||||
lastGood: { anthropic: "anthropic:work" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const sessionKey = resolveSessionKey("per-sender", {
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
} as Parameters<typeof resolveSessionKey>[1]);
|
||||
await fs.writeFile(
|
||||
cfg.session.store,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "session-auth",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "anthropic:work",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+1002",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1002",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("🔑 api-key");
|
||||
expect(text).toContain("…");
|
||||
expect(text).toContain("(anthropic:work)");
|
||||
expect(text).not.toContain("mixed");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -383,6 +482,48 @@ describe("trigger handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows elevated off in groups without mention", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: join(home, "clawd"),
|
||||
elevated: {
|
||||
allowFrom: { whatsapp: ["+1000"] },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
session: { store: join(home, "sessions.json") },
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/elevated off",
|
||||
From: "group:123@g.us",
|
||||
To: "whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+1000",
|
||||
ChatType: "group",
|
||||
WasMentioned: false,
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Elevated mode disabled.");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows elevated directive in groups when mentioned", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = {
|
||||
|
||||
@@ -40,7 +40,11 @@ import { getAbortMemory } from "./reply/abort.js";
|
||||
import { runReplyAgent } from "./reply/agent-runner.js";
|
||||
import { resolveBlockStreamingChunking } from "./reply/block-streaming.js";
|
||||
import { applySessionHints } from "./reply/body.js";
|
||||
import { buildCommandContext, handleCommands } from "./reply/commands.js";
|
||||
import {
|
||||
buildCommandContext,
|
||||
buildStatusReply,
|
||||
handleCommands,
|
||||
} from "./reply/commands.js";
|
||||
import {
|
||||
handleDirectiveOnly,
|
||||
type InlineDirectives,
|
||||
@@ -329,11 +333,23 @@ export async function getReplyFromConfig(
|
||||
.map((entry) => entry.alias?.trim())
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
|
||||
const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
|
||||
let parsedDirectives = parseInlineDirectives(rawBody, {
|
||||
modelAliases: configuredAliases,
|
||||
disableElevated: disableElevatedInGroup,
|
||||
});
|
||||
if (
|
||||
isGroup &&
|
||||
ctx.WasMentioned !== true &&
|
||||
parsedDirectives.hasElevatedDirective
|
||||
) {
|
||||
if (parsedDirectives.elevatedLevel !== "off") {
|
||||
parsedDirectives = {
|
||||
...parsedDirectives,
|
||||
hasElevatedDirective: false,
|
||||
elevatedLevel: undefined,
|
||||
rawElevatedLevel: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
const hasDirective =
|
||||
parsedDirectives.hasThinkDirective ||
|
||||
parsedDirectives.hasVerboseDirective ||
|
||||
@@ -348,7 +364,12 @@ export async function getReplyFromConfig(
|
||||
? stripMentions(stripped, ctx, cfg, agentId)
|
||||
: stripped;
|
||||
if (noMentions.trim().length > 0) {
|
||||
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
|
||||
const directiveOnlyCheck = parseInlineDirectives(noMentions, {
|
||||
modelAliases: configuredAliases,
|
||||
});
|
||||
if (directiveOnlyCheck.cleaned.trim().length > 0) {
|
||||
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
|
||||
}
|
||||
}
|
||||
}
|
||||
const directives = commandAuthorized
|
||||
@@ -465,6 +486,21 @@ export async function getReplyFromConfig(
|
||||
? undefined
|
||||
: directives.rawModelDirective;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
|
||||
if (
|
||||
isDirectiveOnly({
|
||||
directives,
|
||||
@@ -510,8 +546,36 @@ export async function getReplyFromConfig(
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
});
|
||||
let statusReply: ReplyPayload | undefined;
|
||||
if (directives.hasStatusDirective && allowTextCommands) {
|
||||
statusReply = await buildStatusReply({
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel:
|
||||
currentThinkLevel ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
|
||||
resolvedVerboseLevel: (currentVerboseLevel ?? "off") as VerboseLevel,
|
||||
resolvedReasoningLevel: (currentReasoningLevel ??
|
||||
"off") as ReasoningLevel,
|
||||
resolvedElevatedLevel: currentElevatedLevel,
|
||||
resolveDefaultThinkingLevel: async () =>
|
||||
currentThinkLevel ??
|
||||
(agentCfg?.thinkingDefault as ThinkLevel | undefined),
|
||||
isGroup,
|
||||
defaultGroupActivation: () => defaultActivation,
|
||||
});
|
||||
}
|
||||
typing.cleanup();
|
||||
return directiveReply;
|
||||
if (statusReply?.text && directiveReply?.text) {
|
||||
return { text: `${directiveReply.text}\n${statusReply.text}` };
|
||||
}
|
||||
return statusReply ?? directiveReply;
|
||||
}
|
||||
|
||||
const persisted = await persistInlineDirectives({
|
||||
@@ -551,20 +615,6 @@ export async function getReplyFromConfig(
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
agentId,
|
||||
sessionKey,
|
||||
isGroup,
|
||||
triggerBodyNormalized,
|
||||
commandAuthorized,
|
||||
});
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: command.surface,
|
||||
commandSource: ctx.CommandSource,
|
||||
});
|
||||
const isEmptyConfig = Object.keys(cfg).length === 0;
|
||||
if (
|
||||
command.isWhatsAppProvider &&
|
||||
|
||||
@@ -2,12 +2,13 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import {
|
||||
queueEmbeddedPiMessage,
|
||||
runEmbeddedPiAgent,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
resolveSessionTranscriptPath,
|
||||
@@ -18,6 +19,12 @@ import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { registerAgentRunContext } from "../../infra/agent-events.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
formatTokenCount,
|
||||
formatUsd,
|
||||
resolveModelCostConfig,
|
||||
} from "../../utils/usage-format.js";
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
|
||||
import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js";
|
||||
@@ -62,6 +69,65 @@ const formatBunFetchSocketError = (message: string) => {
|
||||
].join("\n");
|
||||
};
|
||||
|
||||
const formatResponseUsageLine = (params: {
|
||||
usage?: NormalizedUsage;
|
||||
showCost: boolean;
|
||||
costConfig?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
}): string | null => {
|
||||
const usage = params.usage;
|
||||
if (!usage) return null;
|
||||
const input = usage.input;
|
||||
const output = usage.output;
|
||||
if (typeof input !== "number" && typeof output !== "number") return null;
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel =
|
||||
typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
const cost =
|
||||
params.showCost && typeof input === "number" && typeof output === "number"
|
||||
? estimateUsageCost({
|
||||
usage: {
|
||||
input,
|
||||
output,
|
||||
cacheRead: usage.cacheRead,
|
||||
cacheWrite: usage.cacheWrite,
|
||||
},
|
||||
cost: params.costConfig,
|
||||
})
|
||||
: undefined;
|
||||
const costLabel = params.showCost ? formatUsd(cost) : undefined;
|
||||
const suffix = costLabel ? ` · est ${costLabel}` : "";
|
||||
return `Usage: ${inputLabel} in / ${outputLabel} out${suffix}`;
|
||||
};
|
||||
|
||||
const appendUsageLine = (
|
||||
payloads: ReplyPayload[],
|
||||
line: string,
|
||||
): ReplyPayload[] => {
|
||||
let index = -1;
|
||||
for (let i = payloads.length - 1; i >= 0; i -= 1) {
|
||||
if (payloads[i]?.text) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index === -1) return [...payloads, { text: line }];
|
||||
const existing = payloads[index];
|
||||
const existingText = existing.text ?? "";
|
||||
const separator = existingText.endsWith("\n") ? "" : "\n";
|
||||
const next = {
|
||||
...existing,
|
||||
text: `${existingText}${separator}${line}`,
|
||||
};
|
||||
const updated = payloads.slice();
|
||||
updated[index] = next;
|
||||
return updated;
|
||||
};
|
||||
|
||||
const withTimeout = async <T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
@@ -191,6 +257,7 @@ export async function runReplyAgent(params: {
|
||||
replyToChannel,
|
||||
);
|
||||
const applyReplyToMode = createReplyToModeFilter(replyToMode);
|
||||
const cfg = followupRun.run.config;
|
||||
|
||||
if (shouldSteer && isStreaming) {
|
||||
const steered = queueEmbeddedPiMessage(
|
||||
@@ -242,6 +309,7 @@ export async function runReplyAgent(params: {
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
let responseUsageLine: string | undefined;
|
||||
try {
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
@@ -641,20 +709,20 @@ export async function runReplyAgent(params: {
|
||||
await typingSignals.signalRunStart();
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ??
|
||||
fallbackProvider ??
|
||||
followupRun.run.provider;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
const providerUsed =
|
||||
runResult.meta.agentMeta?.provider ??
|
||||
fallbackProvider ??
|
||||
followupRun.run.provider;
|
||||
const contextTokensUsed =
|
||||
agentCfgContextTokens ??
|
||||
lookupContextTokens(modelUsed) ??
|
||||
sessionEntry?.contextTokens ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
if (hasNonzeroUsage(usage)) {
|
||||
const entry = sessionEntry ?? sessionStore[sessionKey];
|
||||
if (entry) {
|
||||
@@ -694,6 +762,29 @@ export async function runReplyAgent(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const responseUsageEnabled =
|
||||
(sessionEntry?.responseUsage ??
|
||||
(sessionKey
|
||||
? sessionStore?.[sessionKey]?.responseUsage
|
||||
: undefined)) === "on";
|
||||
if (responseUsageEnabled && hasNonzeroUsage(usage)) {
|
||||
const authMode = resolveModelAuthMode(providerUsed, cfg);
|
||||
const showCost = authMode === "api-key";
|
||||
const costConfig = showCost
|
||||
? resolveModelCostConfig({
|
||||
provider: providerUsed,
|
||||
model: modelUsed,
|
||||
config: cfg,
|
||||
})
|
||||
: undefined;
|
||||
const formatted = formatResponseUsageLine({
|
||||
usage,
|
||||
showCost,
|
||||
costConfig,
|
||||
});
|
||||
if (formatted) responseUsageLine = formatted;
|
||||
}
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
let finalPayloads = replyPayloads;
|
||||
if (autoCompactionCompleted) {
|
||||
@@ -717,6 +808,9 @@ export async function runReplyAgent(params: {
|
||||
...finalPayloads,
|
||||
];
|
||||
}
|
||||
if (responseUsageLine) {
|
||||
finalPayloads = appendUsageLine(finalPayloads, responseUsageLine);
|
||||
}
|
||||
|
||||
return finalizeWithFollowup(
|
||||
finalPayloads.length === 1 ? finalPayloads[0] : finalPayloads,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import {
|
||||
getCustomProviderApiKey,
|
||||
resolveEnvApiKey,
|
||||
} from "../../agents/model-auth.js";
|
||||
import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import {
|
||||
abortEmbeddedPiRun,
|
||||
compactEmbeddedPiSession,
|
||||
@@ -23,6 +25,7 @@ import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
formatUsageSummaryLine,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import {
|
||||
scheduleGatewaySigusr1Restart,
|
||||
@@ -92,32 +95,166 @@ export type CommandContext = {
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export async function buildStatusReply(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
command: CommandContext;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
resolvedThinkLevel?: ThinkLevel;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
resolvedReasoningLevel: ReasoningLevel;
|
||||
resolvedElevatedLevel?: ElevatedLevel;
|
||||
resolveDefaultThinkingLevel: () => Promise<ThinkLevel | undefined>;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
}): Promise<ReplyPayload | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation,
|
||||
} = params;
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
let usageLine: string | null = null;
|
||||
try {
|
||||
const usageProvider = resolveUsageProviderId(provider);
|
||||
if (usageProvider) {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: [usageProvider],
|
||||
});
|
||||
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
|
||||
}
|
||||
} catch {
|
||||
usageLine = null;
|
||||
}
|
||||
const queueSettings = resolveQueueSettings({
|
||||
cfg,
|
||||
provider: command.provider,
|
||||
sessionEntry,
|
||||
});
|
||||
const queueKey = sessionKey ?? sessionEntry?.sessionId;
|
||||
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
|
||||
const queueOverrides = Boolean(
|
||||
sessionEntry?.queueDebounceMs ??
|
||||
sessionEntry?.queueCap ??
|
||||
sessionEntry?.queueDrop,
|
||||
);
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
: undefined;
|
||||
const statusText = buildStatusMessage({
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...cfg.agent?.model,
|
||||
primary: `${provider}/${model}`,
|
||||
},
|
||||
contextTokens,
|
||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||
verboseDefault: cfg.agent?.verboseDefault,
|
||||
elevatedDefault: cfg.agent?.elevatedDefault,
|
||||
},
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
groupActivation,
|
||||
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(provider, cfg, sessionEntry),
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
depth: queueDepth,
|
||||
debounceMs: queueSettings.debounceMs,
|
||||
cap: queueSettings.cap,
|
||||
dropPolicy: queueSettings.dropPolicy,
|
||||
showDetails: queueOverrides,
|
||||
},
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
return { text: statusText };
|
||||
}
|
||||
|
||||
function formatApiKeySnippet(apiKey: string): string {
|
||||
const compact = apiKey.replace(/\s+/g, "");
|
||||
if (!compact) return "unknown";
|
||||
const edge = compact.length >= 12 ? 6 : 4;
|
||||
const head = compact.slice(0, edge);
|
||||
const tail = compact.slice(-edge);
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
|
||||
function resolveModelAuthLabel(
|
||||
provider?: string,
|
||||
cfg?: ClawdbotConfig,
|
||||
sessionEntry?: SessionEntry,
|
||||
): string | undefined {
|
||||
const resolved = provider?.trim();
|
||||
if (!resolved) return undefined;
|
||||
|
||||
const providerKey = normalizeProviderId(resolved);
|
||||
const store = ensureAuthProfileStore();
|
||||
const profiles = listProfilesForProvider(store, resolved);
|
||||
if (profiles.length > 0) {
|
||||
const modes = new Set(
|
||||
profiles
|
||||
.map((id) => store.profiles[id]?.type)
|
||||
.filter((mode): mode is "api_key" | "oauth" => Boolean(mode)),
|
||||
);
|
||||
if (modes.has("oauth") && modes.has("api_key")) return "mixed";
|
||||
if (modes.has("oauth")) return "oauth";
|
||||
if (modes.has("api_key")) return "api-key";
|
||||
const profileOverride = sessionEntry?.authProfileOverride?.trim();
|
||||
const lastGood = store.lastGood?.[providerKey] ?? store.lastGood?.[resolved];
|
||||
const order = resolveAuthProfileOrder({
|
||||
cfg,
|
||||
store,
|
||||
provider: providerKey,
|
||||
preferredProfile: profileOverride,
|
||||
});
|
||||
const candidates = [profileOverride, lastGood, ...order].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
|
||||
for (const profileId of candidates) {
|
||||
const profile = store.profiles[profileId];
|
||||
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
|
||||
continue;
|
||||
}
|
||||
const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId });
|
||||
if (profile.type === "oauth") {
|
||||
return `oauth${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
const snippet = formatApiKeySnippet(profile.key);
|
||||
return `api-key ${snippet}${label ? ` (${label})` : ""}`;
|
||||
}
|
||||
|
||||
const envKey = resolveEnvApiKey(resolved);
|
||||
const envKey = resolveEnvApiKey(providerKey);
|
||||
if (envKey?.apiKey) {
|
||||
return envKey.source.includes("OAUTH_TOKEN") ? "oauth" : "api-key";
|
||||
if (envKey.source.includes("OAUTH_TOKEN")) {
|
||||
return `oauth (${envKey.source})`;
|
||||
}
|
||||
return `api-key ${formatApiKeySnippet(envKey.apiKey)} (${envKey.source})`;
|
||||
}
|
||||
|
||||
if (getCustomProviderApiKey(cfg, resolved)) return "api-key";
|
||||
const customKey = getCustomProviderApiKey(cfg, providerKey);
|
||||
if (customKey) {
|
||||
return `api-key ${formatApiKeySnippet(customKey)} (models.json)`;
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -426,71 +563,24 @@ export async function handleCommands(params: {
|
||||
directives.hasStatusDirective ||
|
||||
command.commandBodyNormalized === "/status";
|
||||
if (allowTextCommands && statusRequested) {
|
||||
if (!command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /status from unauthorized sender: ${command.senderE164 || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
let usageLine: string | null = null;
|
||||
try {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
});
|
||||
usageLine = formatUsageSummaryLine(usageSummary, { now: Date.now() });
|
||||
} catch {
|
||||
usageLine = null;
|
||||
}
|
||||
const queueSettings = resolveQueueSettings({
|
||||
const reply = await buildStatusReply({
|
||||
cfg,
|
||||
provider: command.provider,
|
||||
sessionEntry,
|
||||
});
|
||||
const queueKey = sessionKey ?? sessionEntry?.sessionId;
|
||||
const queueDepth = queueKey ? getFollowupQueueDepth(queueKey) : 0;
|
||||
const queueOverrides = Boolean(
|
||||
sessionEntry?.queueDebounceMs ??
|
||||
sessionEntry?.queueCap ??
|
||||
sessionEntry?.queueDrop,
|
||||
);
|
||||
const groupActivation = isGroup
|
||||
? (normalizeGroupActivation(sessionEntry?.groupActivation) ??
|
||||
defaultGroupActivation())
|
||||
: undefined;
|
||||
const statusText = buildStatusMessage({
|
||||
agent: {
|
||||
...cfg.agent,
|
||||
model: {
|
||||
...cfg.agent?.model,
|
||||
primary: `${provider}/${model}`,
|
||||
},
|
||||
contextTokens,
|
||||
thinkingDefault: cfg.agent?.thinkingDefault,
|
||||
verboseDefault: cfg.agent?.verboseDefault,
|
||||
elevatedDefault: cfg.agent?.elevatedDefault,
|
||||
},
|
||||
command,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
groupActivation,
|
||||
resolvedThink:
|
||||
resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
resolvedReasoning: resolvedReasoningLevel,
|
||||
resolvedElevated: resolvedElevatedLevel,
|
||||
modelAuth: resolveModelAuthLabel(provider, cfg),
|
||||
usageLine: usageLine ?? undefined,
|
||||
queue: {
|
||||
mode: queueSettings.mode,
|
||||
depth: queueDepth,
|
||||
debounceMs: queueSettings.debounceMs,
|
||||
cap: queueSettings.cap,
|
||||
dropPolicy: queueSettings.dropPolicy,
|
||||
showDetails: queueOverrides,
|
||||
},
|
||||
includeTranscriptUsage: false,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
resolvedThinkLevel,
|
||||
resolvedVerboseLevel,
|
||||
resolvedReasoningLevel,
|
||||
resolvedElevatedLevel,
|
||||
resolveDefaultThinkingLevel,
|
||||
isGroup,
|
||||
defaultGroupActivation,
|
||||
});
|
||||
return { shouldContinue: false, reply: { text: statusText } };
|
||||
return { shouldContinue: false, reply };
|
||||
}
|
||||
|
||||
const stopRequested = command.commandBodyNormalized === "/stop";
|
||||
|
||||
@@ -24,7 +24,12 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, saveSessionStore } from "../../config/sessions.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
type SessionEntry,
|
||||
saveSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { extractModelDirective } from "../model.js";
|
||||
@@ -57,6 +62,8 @@ const SYSTEM_MARK = "⚙️";
|
||||
const formatOptionsLine = (options: string) => `Options: ${options}.`;
|
||||
const withOptions = (line: string, options: string) =>
|
||||
`${line}\n${formatOptionsLine(options)}`;
|
||||
const formatElevatedRuntimeHint = () =>
|
||||
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
|
||||
|
||||
const maskApiKey = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
@@ -350,6 +357,21 @@ export async function handleDirectiveOnly(params: {
|
||||
currentReasoningLevel,
|
||||
currentElevatedLevel,
|
||||
} = params;
|
||||
const runtimeIsSandboxed = (() => {
|
||||
const sandboxMode = params.cfg.agent?.sandbox?.mode ?? "off";
|
||||
if (sandboxMode === "off") return false;
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) return false;
|
||||
const agentId = resolveAgentIdFromSessionKey(sessionKey);
|
||||
const mainKey = resolveAgentMainSessionKey({
|
||||
cfg: params.cfg,
|
||||
agentId,
|
||||
});
|
||||
if (sandboxMode === "all") return true;
|
||||
return sessionKey !== mainKey;
|
||||
})();
|
||||
const shouldHintDirectRuntime =
|
||||
directives.hasElevatedDirective && !runtimeIsSandboxed;
|
||||
|
||||
if (directives.hasModelDirective) {
|
||||
const modelDirective = directives.rawModelDirective?.trim().toLowerCase();
|
||||
@@ -463,7 +485,12 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
const level = currentElevatedLevel ?? "off";
|
||||
return {
|
||||
text: withOptions(`Current elevated level: ${level}.`, "on, off"),
|
||||
text: [
|
||||
withOptions(`Current elevated level: ${level}.`, "on, off"),
|
||||
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -681,6 +708,7 @@ export async function handleDirectiveOnly(params: {
|
||||
? `${SYSTEM_MARK} Elevated mode disabled.`
|
||||
: `${SYSTEM_MARK} Elevated mode enabled.`,
|
||||
);
|
||||
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
|
||||
}
|
||||
if (modelSelection) {
|
||||
const label = `${modelSelection.provider}/${modelSelection.model}`;
|
||||
@@ -716,6 +744,7 @@ export async function handleDirectiveOnly(params: {
|
||||
parts.push(`${SYSTEM_MARK} Queue drop set to ${directives.dropPolicy}.`);
|
||||
}
|
||||
const ack = parts.join(" ").trim();
|
||||
if (!ack && directives.hasStatusDirective) return undefined;
|
||||
return { text: ack || "OK." };
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ export async function initSessionState(params: {
|
||||
// Persist previously stored thinking/verbose levels when present.
|
||||
thinkingLevel: persistedThinking ?? baseEntry?.thinkingLevel,
|
||||
verboseLevel: persistedVerbose ?? baseEntry?.verboseLevel,
|
||||
responseUsage: baseEntry?.responseUsage,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
sendPolicy: baseEntry?.sendPolicy,
|
||||
|
||||
@@ -63,20 +63,34 @@ describe("buildStatusMessage", () => {
|
||||
resolvedThink: "medium",
|
||||
resolvedVerbose: "off",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
now: 10 * 60_000, // 10 minutes later
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🦞 ClawdBot");
|
||||
expect(text).toContain("🧠 Model:");
|
||||
expect(text).toContain("Runtime: direct");
|
||||
expect(text).toContain("Context: 16k/32k (50%)");
|
||||
expect(text).toContain("🧹 Compactions: 2");
|
||||
expect(text).toContain("Session: agent:main:main");
|
||||
expect(text).toContain("updated 10m ago");
|
||||
expect(text).toContain("Think: medium");
|
||||
expect(text).toContain("Verbose: off");
|
||||
expect(text).toContain("Elevated: on");
|
||||
expect(text).toContain("Queue: collect");
|
||||
expect(text).toContain("status agent:main:main");
|
||||
expect(text).toContain("model anthropic/pi:opus (api-key)");
|
||||
expect(text).toContain("Context 16k/32k (50%)");
|
||||
expect(text).toContain("compactions 2");
|
||||
expect(text).toContain("think medium");
|
||||
expect(text).toContain("verbose off");
|
||||
expect(text).toContain("reasoning off");
|
||||
expect(text).toContain("elevated on");
|
||||
expect(text).toContain("queue collect");
|
||||
});
|
||||
|
||||
it("shows verbose/elevated labels only when enabled", () => {
|
||||
const text = buildStatusMessage({
|
||||
agent: { model: "anthropic/claude-opus-4-5" },
|
||||
sessionEntry: { sessionId: "v1", updatedAt: 0 },
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
resolvedThink: "low",
|
||||
resolvedVerbose: "on",
|
||||
resolvedElevated: "on",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
});
|
||||
|
||||
expect(text).toContain("verbose on");
|
||||
expect(text).toContain("elevated on");
|
||||
});
|
||||
|
||||
it("prefers model overrides over last-run model", () => {
|
||||
@@ -97,9 +111,10 @@ describe("buildStatusMessage", () => {
|
||||
sessionKey: "agent:main:main",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model: openai/gpt-4.1-mini");
|
||||
expect(text).toContain("model openai/gpt-4.1-mini");
|
||||
});
|
||||
|
||||
it("keeps provider prefix from configured model", () => {
|
||||
@@ -109,21 +124,23 @@ describe("buildStatusMessage", () => {
|
||||
},
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model: google-antigravity/claude-sonnet-4-5");
|
||||
expect(text).toContain("model google-antigravity/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("handles missing agent config gracefully", () => {
|
||||
const text = buildStatusMessage({
|
||||
agent: {},
|
||||
sessionScope: "per-sender",
|
||||
webLinked: false,
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("🧠 Model:");
|
||||
expect(text).toContain("Context:");
|
||||
expect(text).toContain("Queue:");
|
||||
expect(text).toContain("model");
|
||||
expect(text).toContain("Context");
|
||||
expect(text).toContain("queue collect");
|
||||
});
|
||||
|
||||
it("includes group activation for group sessions", () => {
|
||||
@@ -138,9 +155,10 @@ describe("buildStatusMessage", () => {
|
||||
sessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("Activation: always");
|
||||
expect(text).toContain("activation always");
|
||||
});
|
||||
|
||||
it("shows queue details when overridden", () => {
|
||||
@@ -157,10 +175,11 @@ describe("buildStatusMessage", () => {
|
||||
dropPolicy: "old",
|
||||
showDetails: true,
|
||||
},
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain(
|
||||
"Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)",
|
||||
"queue collect (depth 3 · debounce 2s · cap 5 · drop old)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -172,12 +191,10 @@ describe("buildStatusMessage", () => {
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
usageLine: "📊 Usage: Claude 80% left (5h)",
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
const lines = text.split("\n");
|
||||
const contextIndex = lines.findIndex((line) => line.startsWith("📚 "));
|
||||
expect(contextIndex).toBeGreaterThan(-1);
|
||||
expect(lines[contextIndex + 1]).toBe("📊 Usage: Claude 80% left (5h)");
|
||||
expect(text).toContain("📊 Usage: Claude 80% left (5h)");
|
||||
});
|
||||
|
||||
it("prefers cached prompt tokens from the session log", async () => {
|
||||
@@ -237,9 +254,10 @@ describe("buildStatusMessage", () => {
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(text).toContain("Context: 1.0k/32k");
|
||||
expect(text).toContain("Context 1.0k/32k");
|
||||
} finally {
|
||||
restoreHomeEnv(previousHome);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_MODEL,
|
||||
DEFAULT_PROVIDER,
|
||||
} from "../agents/defaults.js";
|
||||
import { resolveModelAuthMode } from "../agents/model-auth.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
derivePromptTokens,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} from "../agents/usage.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { listChatCommands } from "./commands-registry.js";
|
||||
import {
|
||||
estimateUsageCost,
|
||||
formatTokenCount as formatTokenCountShared,
|
||||
formatUsd,
|
||||
resolveModelCostConfig,
|
||||
} from "../utils/usage-format.js";
|
||||
import type {
|
||||
ElevatedLevel,
|
||||
ReasoningLevel,
|
||||
@@ -31,6 +37,8 @@ import type {
|
||||
|
||||
type AgentConfig = NonNullable<ClawdbotConfig["agent"]>;
|
||||
|
||||
export const formatTokenCount = formatTokenCountShared;
|
||||
|
||||
type QueueStatus = {
|
||||
mode?: string;
|
||||
depth?: number;
|
||||
@@ -41,6 +49,7 @@ type QueueStatus = {
|
||||
};
|
||||
|
||||
type StatusArgs = {
|
||||
config?: ClawdbotConfig;
|
||||
agent: AgentConfig;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
@@ -54,37 +63,20 @@ type StatusArgs = {
|
||||
usageLine?: string;
|
||||
queue?: QueueStatus;
|
||||
includeTranscriptUsage?: boolean;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
const formatAge = (ms?: number | null) => {
|
||||
if (!ms || ms < 0) return "unknown";
|
||||
const minutes = Math.round(ms / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 48) return `${hours}h ago`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days}d ago`;
|
||||
};
|
||||
|
||||
const formatKTokens = (value: number) =>
|
||||
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
||||
|
||||
export const formatTokenCount = (value: number) => formatKTokens(value);
|
||||
|
||||
const formatTokens = (
|
||||
total: number | null | undefined,
|
||||
contextTokens: number | null,
|
||||
) => {
|
||||
const ctx = contextTokens ?? null;
|
||||
if (total == null) {
|
||||
const ctxLabel = ctx ? formatKTokens(ctx) : "?";
|
||||
return `unknown/${ctxLabel}`;
|
||||
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
|
||||
return `?/${ctxLabel}`;
|
||||
}
|
||||
const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null;
|
||||
const totalLabel = formatKTokens(total);
|
||||
const ctxLabel = ctx ? formatKTokens(ctx) : "?";
|
||||
const totalLabel = formatTokenCount(total);
|
||||
const ctxLabel = ctx ? formatTokenCount(ctx) : "?";
|
||||
return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`;
|
||||
};
|
||||
|
||||
@@ -172,8 +164,15 @@ const readUsageFromSessionLog = (
|
||||
}
|
||||
};
|
||||
|
||||
const formatUsagePair = (input?: number | null, output?: number | null) => {
|
||||
if (input == null && output == null) return null;
|
||||
const inputLabel = typeof input === "number" ? formatTokenCount(input) : "?";
|
||||
const outputLabel =
|
||||
typeof output === "number" ? formatTokenCount(output) : "?";
|
||||
return `usage ${inputLabel} in / ${outputLabel} out`;
|
||||
};
|
||||
|
||||
export function buildStatusMessage(args: StatusArgs): string {
|
||||
const now = args.now ?? Date.now();
|
||||
const entry = args.sessionEntry;
|
||||
const resolved = resolveConfiguredModelRef({
|
||||
cfg: { agent: args.agent ?? {} },
|
||||
@@ -189,6 +188,8 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
lookupContextTokens(model) ??
|
||||
DEFAULT_CONTEXT_TOKENS;
|
||||
|
||||
let inputTokens = entry?.inputTokens;
|
||||
let outputTokens = entry?.outputTokens;
|
||||
let totalTokens =
|
||||
entry?.totalTokens ??
|
||||
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
|
||||
@@ -206,6 +207,8 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
if (!contextTokens && logUsage.model) {
|
||||
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
|
||||
}
|
||||
if (!inputTokens || inputTokens === 0) inputTokens = logUsage.input;
|
||||
if (!outputTokens || outputTokens === 0) outputTokens = logUsage.output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,33 +222,6 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
args.agent?.elevatedDefault ??
|
||||
"on";
|
||||
|
||||
const runtime = (() => {
|
||||
const sandboxMode = args.agent?.sandbox?.mode ?? "off";
|
||||
if (sandboxMode === "off") return { label: "direct" };
|
||||
const sessionScope = args.sessionScope ?? "per-sender";
|
||||
const mainKey = resolveMainSessionKey({
|
||||
session: { scope: sessionScope },
|
||||
});
|
||||
const sessionKey = args.sessionKey?.trim();
|
||||
const sandboxed = sessionKey
|
||||
? sandboxMode === "all" || sessionKey !== mainKey.trim()
|
||||
: false;
|
||||
const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown";
|
||||
return {
|
||||
label: `${runtime}/${sandboxMode}`,
|
||||
};
|
||||
})();
|
||||
|
||||
const updatedAt = entry?.updatedAt;
|
||||
const sessionLine = [
|
||||
`Session: ${args.sessionKey ?? "unknown"}`,
|
||||
typeof updatedAt === "number"
|
||||
? `updated ${formatAge(now - updatedAt)}`
|
||||
: "no activity",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" • ");
|
||||
|
||||
const isGroupSession =
|
||||
entry?.chatType === "group" ||
|
||||
entry?.chatType === "room" ||
|
||||
@@ -256,54 +232,68 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
? (args.groupActivation ?? entry?.groupActivation ?? "mention")
|
||||
: undefined;
|
||||
|
||||
const contextLine = [
|
||||
`Context: ${formatTokens(totalTokens, contextTokens ?? null)}`,
|
||||
`🧹 Compactions: ${entry?.compactionCount ?? 0}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const authMode =
|
||||
args.modelAuth ?? resolveModelAuthMode(provider, args.config);
|
||||
const showCost = authMode === "api-key";
|
||||
const costConfig = showCost
|
||||
? resolveModelCostConfig({
|
||||
provider,
|
||||
model,
|
||||
config: args.config,
|
||||
})
|
||||
: undefined;
|
||||
const hasUsage =
|
||||
typeof inputTokens === "number" || typeof outputTokens === "number";
|
||||
const cost =
|
||||
showCost && hasUsage
|
||||
? estimateUsageCost({
|
||||
usage: {
|
||||
input: inputTokens ?? undefined,
|
||||
output: outputTokens ?? undefined,
|
||||
},
|
||||
cost: costConfig,
|
||||
})
|
||||
: undefined;
|
||||
const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined;
|
||||
|
||||
const parts: Array<string | null> = [];
|
||||
parts.push(`status ${args.sessionKey ?? "unknown"}`);
|
||||
|
||||
const modelLabel = model ? `${provider}/${model}` : "unknown";
|
||||
const authLabel = authMode && authMode !== "unknown" ? ` (${authMode})` : "";
|
||||
parts.push(`model ${modelLabel}${authLabel}`);
|
||||
|
||||
const usagePair = formatUsagePair(inputTokens, outputTokens);
|
||||
if (usagePair) parts.push(usagePair);
|
||||
if (costLabel) parts.push(`cost ${costLabel}`);
|
||||
|
||||
const contextSummary = formatContextUsageShort(
|
||||
totalTokens && totalTokens > 0 ? totalTokens : null,
|
||||
contextTokens ?? null,
|
||||
);
|
||||
parts.push(contextSummary);
|
||||
parts.push(`compactions ${entry?.compactionCount ?? 0}`);
|
||||
parts.push(`think ${thinkLevel}`);
|
||||
parts.push(`verbose ${verboseLevel}`);
|
||||
parts.push(`reasoning ${reasoningLevel}`);
|
||||
parts.push(`elevated ${elevatedLevel}`);
|
||||
if (groupActivationValue) parts.push(`activation ${groupActivationValue}`);
|
||||
|
||||
const queueMode = args.queue?.mode ?? "unknown";
|
||||
const queueDetails = formatQueueDetails(args.queue);
|
||||
const optionParts = [
|
||||
`Runtime: ${runtime.label}`,
|
||||
`Think: ${thinkLevel}`,
|
||||
`Verbose: ${verboseLevel}`,
|
||||
reasoningLevel !== "off" ? `Reasoning: ${reasoningLevel}` : null,
|
||||
`Elevated: ${elevatedLevel}`,
|
||||
];
|
||||
const optionsLine = optionParts.filter(Boolean).join(" · ");
|
||||
const activationParts = [
|
||||
groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null,
|
||||
`🪢 Queue: ${queueMode}${queueDetails}`,
|
||||
];
|
||||
const activationLine = activationParts.filter(Boolean).join(" · ");
|
||||
parts.push(`queue ${queueMode}${queueDetails}`);
|
||||
|
||||
const modelLabel = model ? `${provider}/${model}` : "unknown";
|
||||
const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : "";
|
||||
const modelLine = `🧠 Model: ${modelLabel}${authLabel}`;
|
||||
const commit = resolveCommitHash();
|
||||
const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`;
|
||||
if (args.usageLine) parts.push(args.usageLine);
|
||||
|
||||
return [
|
||||
versionLine,
|
||||
modelLine,
|
||||
`📚 ${contextLine}`,
|
||||
args.usageLine,
|
||||
`🧵 ${sessionLine}`,
|
||||
`⚙️ ${optionsLine}`,
|
||||
activationLine,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return parts.filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export function buildHelpMessage(): string {
|
||||
return [
|
||||
"ℹ️ Help",
|
||||
"Shortcuts: /new reset | /compact [instructions] | /restart relink",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id>",
|
||||
"More: /commands for all slash commands",
|
||||
"Options: /think <level> | /verbose on|off | /reasoning on|off | /elevated on|off | /model <id> | /cost on|off",
|
||||
"More: /commands for all slash commands"
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||||
export type VerboseLevel = "off" | "on";
|
||||
export type ElevatedLevel = "off" | "on";
|
||||
export type ReasoningLevel = "off" | "on" | "stream";
|
||||
export type UsageDisplayLevel = "off" | "on";
|
||||
|
||||
// Normalize user-provided thinking level strings to the canonical enum.
|
||||
export function normalizeThinkLevel(
|
||||
@@ -46,6 +47,19 @@ export function normalizeVerboseLevel(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize response-usage display flags used to toggle cost/token lines.
|
||||
export function normalizeUsageDisplay(
|
||||
raw?: string | null,
|
||||
): UsageDisplayLevel | undefined {
|
||||
if (!raw) return undefined;
|
||||
const key = raw.toLowerCase();
|
||||
if (["off", "false", "no", "0", "disable", "disabled"].includes(key))
|
||||
return "off";
|
||||
if (["on", "true", "yes", "1", "enable", "enabled"].includes(key))
|
||||
return "on";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normalize elevated flags used to toggle elevated bash permissions.
|
||||
export function normalizeElevatedLevel(
|
||||
raw?: string | null,
|
||||
|
||||
Reference in New Issue
Block a user