diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b91d01b..e1e69a4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic). - Auto-reply: place reasoning blocks before the final reply text when appended. - Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331. +- Auto-reply: refresh `/status` output with build info, compact context, and queue depth. - Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295. - Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275. - Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes. diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 9afaaa4bd..6c6ee8b26 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -175,7 +175,7 @@ describe("trigger handling", () => { makeCfg(home), ); const text = Array.isArray(res) ? res[0]?.text : res?.text; - expect(text).toContain("Status"); + expect(text).toContain("ClawdBot"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 27d62facf..722ee4132 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -25,8 +25,6 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { normalizeE164 } from "../../utils.js"; -import { resolveHeartbeatSeconds } from "../../web/reconnect.js"; -import { getWebAuthAgeMs, webAuthExists } from "../../web/session.js"; import { resolveCommandAuthorization } from "../command-auth.js"; import { shouldHandleTextCommands } from "../commands-registry.js"; import { @@ -51,6 +49,7 @@ import type { ReplyPayload } from "../types.js"; import { isAbortTrigger, setAbortMemory } from "./abort.js"; import type { InlineDirectives } from "./directive-handling.js"; import { stripMentions, stripStructuralPrefixes } from "./mentions.js"; +import { getFollowupQueueDepth, resolveQueueSettings } from "./queue.js"; import { incrementCompactionCount } from "./session-updates.js"; function resolveSessionEntryForKey( @@ -384,9 +383,18 @@ export async function handleCommands(params: { ); return { shouldContinue: false }; } - const webLinked = await webAuthExists(); - const webAuthAgeMs = getWebAuthAgeMs(); - const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + 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()) @@ -403,11 +411,9 @@ export async function handleCommands(params: { verboseDefault: cfg.agent?.verboseDefault, elevatedDefault: cfg.agent?.elevatedDefault, }, - workspaceDir, sessionEntry, sessionKey, sessionScope, - storePath, groupActivation, resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()), @@ -415,9 +421,15 @@ export async function handleCommands(params: { resolvedReasoning: resolvedReasoningLevel, resolvedElevated: resolvedElevatedLevel, modelAuth: resolveModelAuthLabel(provider, cfg), - webLinked, - webAuthAgeMs, - heartbeatSeconds, + queue: { + mode: queueSettings.mode, + depth: queueDepth, + debounceMs: queueSettings.debounceMs, + cap: queueSettings.cap, + dropPolicy: queueSettings.dropPolicy, + showDetails: queueOverrides, + }, + includeTranscriptUsage: false, }); return { shouldContinue: false, reply: { text: statusText } }; } diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts index f1939ba59..6dccf37b0 100644 --- a/src/auto-reply/reply/queue.ts +++ b/src/auto-reply/reply/queue.ts @@ -586,3 +586,11 @@ export function resolveQueueSettings(params: { dropPolicy: dropRaw, }; } + +export function getFollowupQueueDepth(key: string): number { + const cleaned = key.trim(); + if (!cleaned) return 0; + const queue = FOLLOWUP_QUEUES.get(cleaned); + if (!queue) return 0; + return queue.items.length; +} diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 878065913..f958b5402 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -26,27 +26,23 @@ describe("buildStatusMessage", () => { }, sessionKey: "agent:main:main", sessionScope: "per-sender", - storePath: "/tmp/sessions.json", resolvedThink: "medium", resolvedVerbose: "off", + queue: { mode: "collect", depth: 0 }, now: 10 * 60_000, // 10 minutes later - webLinked: true, - webAuthAgeMs: 5 * 60_000, - heartbeatSeconds: 45, }); - expect(text).toContain("⚙️ Status"); - expect(text).toContain("Agent: embedded pi"); + 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("compactions 2"); - expect(text).toContain("Web: linked"); - expect(text).toContain("heartbeat 45s"); - expect(text).toContain("thinking=medium"); - expect(text).toContain("verbose=off"); - expect(text).not.toContain("Shortcuts:"); - expect(text).not.toContain("set with"); + 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"); }); it("handles missing agent config gracefully", () => { @@ -56,9 +52,9 @@ describe("buildStatusMessage", () => { webLinked: false, }); - expect(text).toContain("Agent: embedded pi"); + expect(text).toContain("🧠 Model:"); expect(text).toContain("Context:"); - expect(text).toContain("Web: not linked"); + expect(text).toContain("Queue:"); }); it("includes group activation for group sessions", () => { @@ -72,10 +68,31 @@ describe("buildStatusMessage", () => { }, sessionKey: "agent:main:whatsapp:group:123@g.us", sessionScope: "per-sender", - webLinked: true, + queue: { mode: "collect", depth: 0 }, }); - expect(text).toContain("Group activation: always"); + expect(text).toContain("Activation: always"); + }); + + it("shows queue details when overridden", () => { + const text = buildStatusMessage({ + agent: {}, + sessionEntry: { sessionId: "q1", updatedAt: 0 }, + sessionKey: "agent:main:main", + sessionScope: "per-sender", + queue: { + mode: "collect", + depth: 3, + debounceMs: 2000, + cap: 5, + dropPolicy: "old", + showDetails: true, + }, + }); + + expect(text).toContain( + "Queue: collect (depth 3 · debounce 2s · cap 5 · drop old)", + ); }); it("prefers cached prompt tokens from the session log", async () => { @@ -88,14 +105,6 @@ describe("buildStatusMessage", () => { "./status.js" ); - const storePath = path.join( - dir, - ".clawdbot", - "agents", - "main", - "sessions", - "sessions.json", - ); const sessionId = "sess-1"; const logPath = path.join( dir, @@ -141,8 +150,8 @@ describe("buildStatusMessage", () => { }, sessionKey: "agent:main:main", sessionScope: "per-sender", - storePath, - webLinked: true, + queue: { mode: "collect", depth: 0 }, + includeTranscriptUsage: true, }); expect(text).toContain("Context: 1.0k/32k"); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 7166c7d63..ffaff2e12 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import path from "node:path"; import { lookupContextTokens } from "../agents/context.js"; import { @@ -19,7 +20,7 @@ import { type SessionEntry, type SessionScope, } from "../config/sessions.js"; -import { shortenHomePath } from "../utils.js"; +import { VERSION } from "../version.js"; import type { ElevatedLevel, ReasoningLevel, @@ -29,23 +30,29 @@ import type { type AgentConfig = NonNullable; +type QueueStatus = { + mode?: string; + depth?: number; + debounceMs?: number; + cap?: number; + dropPolicy?: string; + showDetails?: boolean; +}; + type StatusArgs = { agent: AgentConfig; - workspaceDir?: string; sessionEntry?: SessionEntry; sessionKey?: string; sessionScope?: SessionScope; - storePath?: string; groupActivation?: "mention" | "always"; resolvedThink?: ThinkLevel; resolvedVerbose?: VerboseLevel; resolvedReasoning?: ReasoningLevel; resolvedElevated?: ElevatedLevel; modelAuth?: string; + queue?: QueueStatus; + includeTranscriptUsage?: boolean; now?: number; - webLinked?: boolean; - webAuthAgeMs?: number | null; - heartbeatSeconds?: number; }; const formatAge = (ms?: number | null) => { @@ -84,6 +91,97 @@ export const formatContextUsageShort = ( contextTokens: number | null | undefined, ) => `Context ${formatTokens(total, contextTokens ?? null)}`; +const formatCommit = (value?: string | null) => { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; +}; + +const resolveGitHead = (startDir: string) => { + let current = startDir; + for (let i = 0; i < 12; i += 1) { + const gitPath = path.join(current, ".git"); + try { + const stat = fs.statSync(gitPath); + if (stat.isDirectory()) { + return path.join(gitPath, "HEAD"); + } + if (stat.isFile()) { + const raw = fs.readFileSync(gitPath, "utf-8"); + const match = raw.match(/gitdir:\s*(.+)/i); + if (match?.[1]) { + const resolved = path.resolve(current, match[1].trim()); + return path.join(resolved, "HEAD"); + } + } + } catch { + // ignore missing .git at this level + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return null; +}; + +let cachedCommit: string | null | undefined; +const resolveCommitHash = () => { + if (cachedCommit !== undefined) return cachedCommit; + const envCommit = + process.env.GIT_COMMIT?.trim() || process.env.GIT_SHA?.trim(); + const normalized = formatCommit(envCommit); + if (normalized) { + cachedCommit = normalized; + return cachedCommit; + } + try { + const headPath = resolveGitHead(process.cwd()); + if (!headPath) { + cachedCommit = null; + return cachedCommit; + } + const head = fs.readFileSync(headPath, "utf-8").trim(); + if (!head) { + cachedCommit = null; + return cachedCommit; + } + if (head.startsWith("ref:")) { + const ref = head.replace(/^ref:\s*/i, "").trim(); + const refPath = path.resolve(path.dirname(headPath), ref); + const refHash = fs.readFileSync(refPath, "utf-8").trim(); + cachedCommit = formatCommit(refHash); + return cachedCommit; + } + cachedCommit = formatCommit(head); + return cachedCommit; + } catch { + cachedCommit = null; + return cachedCommit; + } +}; + +const formatQueueDetails = (queue?: QueueStatus) => { + if (!queue) return ""; + const depth = typeof queue.depth === "number" ? `depth ${queue.depth}` : null; + if (!queue.showDetails) { + return depth ? ` (${depth})` : ""; + } + const detailParts: string[] = []; + if (depth) detailParts.push(depth); + if (typeof queue.debounceMs === "number") { + const ms = Math.max(0, Math.round(queue.debounceMs)); + const label = + ms >= 1000 + ? `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(1)}s` + : `${ms}ms`; + detailParts.push(`debounce ${label}`); + } + if (typeof queue.cap === "number") detailParts.push(`cap ${queue.cap}`); + if (queue.dropPolicy) detailParts.push(`drop ${queue.dropPolicy}`); + return detailParts.length ? ` (${detailParts.join(" · ")})` : ""; +}; + const readUsageFromSessionLog = ( sessionId?: string, ): @@ -164,15 +262,17 @@ export function buildStatusMessage(args: StatusArgs): string { // Prefer prompt-size tokens from the session transcript when it looks larger // (cached prompt tokens are often missing from agent meta/store). - const logUsage = readUsageFromSessionLog(entry?.sessionId); - if (logUsage) { - const candidate = logUsage.promptTokens || logUsage.total; - if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { - totalTokens = candidate; - } - if (!model) model = logUsage.model ?? model; - if (!contextTokens && logUsage.model) { - contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; + if (args.includeTranscriptUsage) { + const logUsage = readUsageFromSessionLog(entry?.sessionId); + if (logUsage) { + const candidate = logUsage.promptTokens || logUsage.total; + if (!totalTokens || totalTokens === 0 || candidate > totalTokens) { + totalTokens = candidate; + } + if (!model) model = logUsage.model ?? model; + if (!contextTokens && logUsage.model) { + contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens; + } } } @@ -188,8 +288,7 @@ export function buildStatusMessage(args: StatusArgs): string { const runtime = (() => { const sandboxMode = args.agent?.sandbox?.mode ?? "off"; - if (sandboxMode === "off") - return { line: "Runtime: direct", sandboxed: false }; + if (sandboxMode === "off") return { label: "direct" }; const sessionScope = args.sessionScope ?? "per-sender"; const mainKey = resolveMainSessionKey({ session: { scope: sessionScope }, @@ -199,35 +298,17 @@ export function buildStatusMessage(args: StatusArgs): string { ? sandboxMode === "all" || sessionKey !== mainKey.trim() : false; const runtime = sandboxed ? "docker" : sessionKey ? "direct" : "unknown"; - const suffix = sandboxed ? ` • elevated ${elevatedLevel}` : ""; return { - line: `Runtime: ${runtime} (sandbox ${sandboxMode})${suffix}`, - sandboxed, + label: `${runtime}/${sandboxMode}`, }; })(); - const webLine = (() => { - if (args.webLinked === false) { - return "Web: not linked — run `clawdbot login` to scan the QR."; - } - const authAge = formatAge(args.webAuthAgeMs); - const heartbeat = - typeof args.heartbeatSeconds === "number" - ? ` • heartbeat ${args.heartbeatSeconds}s` - : ""; - return `Web: linked • auth refreshed ${authAge}${heartbeat}`; - })(); - + const updatedAt = entry?.updatedAt; const sessionLine = [ `Session: ${args.sessionKey ?? "unknown"}`, - `scope ${args.sessionScope ?? "per-sender"}`, - entry?.updatedAt - ? `updated ${formatAge(now - entry.updatedAt)}` + typeof updatedAt === "number" + ? `updated ${formatAge(now - updatedAt)}` : "no activity", - typeof entry?.compactionCount === "number" - ? `compactions ${entry.compactionCount}` - : undefined, - args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined, ] .filter(Boolean) .join(" • "); @@ -238,39 +319,42 @@ export function buildStatusMessage(args: StatusArgs): string { Boolean(args.sessionKey?.includes(":group:")) || Boolean(args.sessionKey?.includes(":channel:")) || Boolean(args.sessionKey?.startsWith("group:")); - const groupActivationLine = isGroupSession - ? `Group activation: ${args.groupActivation ?? entry?.groupActivation ?? "mention"}` + const groupActivationValue = isGroupSession + ? (args.groupActivation ?? entry?.groupActivation ?? "mention") : undefined; - const contextLine = `Context: ${formatTokens( - totalTokens, - contextTokens ?? null, - )}${entry?.abortedLastRun ? " • last run aborted" : ""}`; + const contextLine = [ + `Context: ${formatTokens(totalTokens, contextTokens ?? null)}`, + `🧹 Compactions: ${entry?.compactionCount ?? 0}`, + ] + .filter(Boolean) + .join(" · "); - const optionsLine = runtime.sandboxed - ? `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel} | elevated=${elevatedLevel}` - : `Options: thinking=${thinkLevel} | verbose=${verboseLevel} | reasoning=${reasoningLevel}`; + 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}`, + groupActivationValue ? `👥 Activation: ${groupActivationValue}` : null, + `🪢 Queue: ${queueMode}${queueDetails}`, + ]; + const optionsLine = optionParts.filter(Boolean).join(" · "); const modelLabel = model ? `${provider}/${model}` : "unknown"; - - const agentLine = `Agent: embedded pi • ${modelLabel}`; - const authLine = args.modelAuth ? `Model auth: ${args.modelAuth}` : undefined; - - const workspaceLine = args.workspaceDir - ? `Workspace: ${shortenHomePath(args.workspaceDir)}` - : undefined; + const authLabel = args.modelAuth ? ` · 🔑 ${args.modelAuth}` : ""; + const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; + const commit = resolveCommitHash(); + const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; return [ - "⚙️ Status", - webLine, - agentLine, - authLine, - runtime.line, - workspaceLine, - contextLine, - sessionLine, - groupActivationLine, - optionsLine, + versionLine, + modelLine, + `📚 ${contextLine}`, + `🧵 ${sessionLine}`, + `⚙️ ${optionsLine}`, ] .filter(Boolean) .join("\n");