From e2c6546b61ca54c332e7b3180a5ccb5719c4e535 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Dec 2025 16:53:19 +0000 Subject: [PATCH] auto-reply: enrich chat status --- CHANGELOG.md | 1 + README.md | 5 +- docs/health.md | 1 + docs/session.md | 1 + docs/troubleshooting.md | 1 + src/auto-reply/reply.triggers.test.ts | 24 ++++ src/auto-reply/reply.ts | 27 +++++ src/auto-reply/status.test.ts | 63 ++++++++++ src/auto-reply/status.ts | 161 ++++++++++++++++++++++++++ 9 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/auto-reply/status.test.ts create mode 100644 src/auto-reply/status.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c74d2b1..6172f9119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ First Clawdis release after the Warelay rebrand. This is a semver-major because - Thinking/verbosity directives: `/think` and `/verbose` acknowledge and persist per session while allowing inline overrides; verbose mode streams tool metadata with emoji/args/previews and coalesces bursts to reduce WhatsApp noise. - Heartbeats: configurable cadence with CLI/GUI toggles; directive acks suppressed during heartbeats; array/multi-payload replies normalized for Baileys. - Reply quality: smarter chunking on words/newlines, fallback warnings when media fails to send, self-number mention detection, and primed group sessions send the roster on first turn. +- In-chat `/status`: prints agent readiness, session context usage %, current thinking/verbose options, and when the WhatsApp web creds were refreshed (helps decide when to re-scan QR); still available via `clawdis status` CLI for web session health. ### CLI, RPC, and health - New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table. diff --git a/README.md b/README.md index c36f9d2a5..7c57ea8a7 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,12 @@ clawdis relay # Start listening | `clawdis send` | Send a message | | `clawdis agent` | Talk directly to the agent (no WhatsApp send) | | `clawdis relay` | Start auto-reply loop | -| `clawdis status` | Show recent messages | +| `clawdis status` | Web session health + session store summary | | `clawdis heartbeat` | Trigger a heartbeat | +In chat, send `/status` to see if the agent is reachable, how much context the session has used, and the current thinking/verbose toggles—no agent call required. +`/status` also shows whether your WhatsApp web session is linked and how long ago the creds were refreshed so you know when to re-scan the QR. + ### Sessions, surfaces, and WebChat - Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:`. diff --git a/docs/health.md b/docs/health.md index 78486e9f8..d583faab6 100644 --- a/docs/health.md +++ b/docs/health.md @@ -4,6 +4,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## Quick checks - `pnpm clawdis status --json` — confirms creds exist (`web.linked`), shows auth age (`authAgeMs`), heartbeat interval, and where the session store lives. +- Send `/status` in WhatsApp/WebChat to see agent readiness, session context usage, current thinking/verbose options, and when the web creds were last refreshed (relink if it looks stale) without invoking the agent. - `pnpm clawdis heartbeat --verbose --dry-run` — runs the heartbeat path end-to-end (session resolution, message creation) without sending anything. Drop `--dry-run` or add `--message "Ping"` to actually send. - `pnpm clawdis relay --verbose --heartbeat-now` — spins the full monitor loop, fires a heartbeat immediately, and will reconnect per `web.reconnect` settings. Good for soak testing. - Logs: tail `/tmp/clawdis/clawdis.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. diff --git a/docs/session.md b/docs/session.md index e3cea26cf..cfcf24a15 100644 --- a/docs/session.md +++ b/docs/session.md @@ -38,6 +38,7 @@ Clawdis treats **one session as primary**. By default the canonical key is `main ## Inspecting - `pnpm clawdis status` — shows store path and recent sessions. - `pnpm clawdis sessions --json` — dumps every entry (filter with `--active `). +- Send `/status` in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - JSONL transcripts can be opened directly to review full turns. ## Tips diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 686528a70..479bceb1a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -78,6 +78,7 @@ Or use the `process` tool to background long commands. ```bash # Check status clawdis status +# Or from chat: send /status for agent + context usage # View recent connection events tail -100 /tmp/clawdis/clawdis.log | grep "connection\|disconnect\|logout" diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index fd0003b76..063372934 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as tauRpc from "../process/tau-rpc.js"; import { getReplyFromConfig } from "./reply.js"; +const webMocks = vi.hoisted(() => ({ + webAuthExists: vi.fn().mockResolvedValue(true), + getWebAuthAgeMs: vi.fn().mockReturnValue(120_000), +})); + +vi.mock("../web/session.js", () => webMocks); + const baseCfg = { inbound: { reply: { @@ -52,6 +59,23 @@ describe("trigger handling", () => { expect(runner).not.toHaveBeenCalled(); }); + it("reports status without invoking the agent", async () => { + const runner = vi.fn(); + const res = await getReplyFromConfig( + { + Body: "/status", + From: "+1002", + To: "+2000", + }, + {}, + baseCfg, + runner, + ); + const text = Array.isArray(res) ? res[0]?.text : res?.text; + expect(text).toContain("Status"); + expect(runner).not.toHaveBeenCalled(); + }); + it("ignores think directives that only appear in the context wrapper", async () => { const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({ stdout: diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index d4fd1f4e2..a6d0857ed 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -22,6 +22,9 @@ import { type MsgContext, type TemplateContext, } from "./templating.js"; +import { buildStatusMessage } from "./status.js"; +import { resolveHeartbeatSeconds } from "../web/reconnect.js"; +import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; import { normalizeThinkLevel, normalizeVerboseLevel, @@ -503,6 +506,30 @@ export async function getReplyFromConfig( }; } + if ( + rawBodyNormalized === "/status" || + rawBodyNormalized === "status" || + rawBodyNormalized.startsWith("/status ") + ) { + const webLinked = await webAuthExists(); + const webAuthAgeMs = getWebAuthAgeMs(); + const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined); + const statusText = buildStatusMessage({ + reply, + sessionEntry, + sessionKey, + sessionScope, + storePath, + resolvedThink: resolvedThinkLevel, + resolvedVerbose: resolvedVerboseLevel, + webLinked, + webAuthAgeMs, + heartbeatSeconds, + }); + cleanupTyping(); + return { text: statusText }; + } + const abortRequested = reply?.mode === "command" && isAbortTrigger(rawBodyNormalized); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts new file mode 100644 index 000000000..948ad0d4b --- /dev/null +++ b/src/auto-reply/status.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildStatusMessage } from "./status.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("buildStatusMessage", () => { + it("summarizes agent readiness and context usage", () => { + const text = buildStatusMessage({ + reply: { + mode: "command", + command: ["echo", "{{Body}}"], + agent: { kind: "pi", model: "pi:opus", contextTokens: 32_000 }, + session: { scope: "per-sender" }, + }, + sessionEntry: { + sessionId: "abc", + updatedAt: 0, + totalTokens: 16_000, + contextTokens: 32_000, + thinkingLevel: "low", + verboseLevel: "on", + }, + sessionKey: "main", + sessionScope: "per-sender", + storePath: "/tmp/sessions.json", + resolvedThink: "medium", + resolvedVerbose: "off", + now: 10 * 60_000, // 10 minutes later + webLinked: true, + webAuthAgeMs: 5 * 60_000, + heartbeatSeconds: 45, + }); + + expect(text).toContain("⚙️ Status"); + expect(text).toContain("Agent: ready"); + expect(text).toContain("Context: 16k/32k (50%)"); + expect(text).toContain("Session: main"); + expect(text).toContain("Web: linked"); + expect(text).toContain("heartbeat 45s"); + expect(text).toContain("thinking=medium"); + expect(text).toContain("verbose=off"); + }); + + it("handles missing agent command gracefully", () => { + const text = buildStatusMessage({ + reply: { + mode: "command", + command: [], + session: { scope: "per-sender" }, + }, + sessionScope: "per-sender", + webLinked: false, + }); + + expect(text).toContain("Agent: check"); + expect(text).toContain("not set"); + expect(text).toContain("Context:"); + expect(text).toContain("Web: not linked"); + }); +}); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts new file mode 100644 index 000000000..49c0ba9c4 --- /dev/null +++ b/src/auto-reply/status.ts @@ -0,0 +1,161 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { lookupContextTokens } from "../agents/context.js"; +import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; +import type { WarelayConfig } from "../config/config.js"; +import type { SessionEntry, SessionScope } from "../config/sessions.js"; +import type { ThinkLevel, VerboseLevel } from "./thinking.js"; + +type ReplyConfig = NonNullable["reply"]; + +type StatusArgs = { + reply: ReplyConfig; + sessionEntry?: SessionEntry; + sessionKey?: string; + sessionScope?: SessionScope; + storePath?: string; + resolvedThink?: ThinkLevel; + resolvedVerbose?: VerboseLevel; + now?: number; + webLinked?: boolean; + webAuthAgeMs?: number | null; + heartbeatSeconds?: number; +}; + +type AgentProbe = { + ok: boolean; + detail: string; + label: string; +}; + +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`; + +const abbreviatePath = (p?: string) => { + if (!p) return undefined; + const home = os.homedir(); + if (p.startsWith(home)) return p.replace(home, "~"); + return p; +}; + +const probeAgentCommand = (command?: string[]): AgentProbe => { + const bin = command?.[0]; + if (!bin) { + return { ok: false, detail: "no command configured", label: "not set" }; + } + + const commandLabel = command + .slice(0, 3) + .map((c) => c.replace(/\{\{[^}]+}}/g, "{…}")) + .join(" ") + .concat(command.length > 3 ? " …" : ""); + + const looksLikePath = bin.includes("/") || bin.startsWith("."); + if (looksLikePath) { + const exists = fs.existsSync(bin); + return { + ok: exists, + detail: exists ? "binary found" : "binary missing", + label: commandLabel || bin, + }; + } + + try { + const res = spawnSync("which", [bin], { + encoding: "utf-8", + timeout: 1500, + }); + const found = res.status === 0 && res.stdout + ? res.stdout.split("\n")[0]?.trim() + : ""; + return { + ok: Boolean(found), + detail: found || "not in PATH", + label: commandLabel || bin, + }; + } catch (err) { + return { + ok: false, + detail: `probe failed: ${String(err)}`, + label: commandLabel || bin, + }; + } +}; + +const formatTokens = (total: number, contextTokens: number | null) => { + const ctx = contextTokens ?? null; + const pct = ctx ? Math.min(999, Math.round((total / ctx) * 100)) : null; + const totalLabel = formatKTokens(total); + const ctxLabel = ctx ? formatKTokens(ctx) : "?"; + return `${totalLabel}/${ctxLabel}${pct !== null ? ` (${pct}%)` : ""}`; +}; + +export function buildStatusMessage(args: StatusArgs): string { + const now = args.now ?? Date.now(); + const entry = args.sessionEntry; + const model = entry?.model ?? args.reply?.agent?.model ?? DEFAULT_MODEL; + const contextTokens = + entry?.contextTokens ?? + args.reply?.agent?.contextTokens ?? + lookupContextTokens(model) ?? + DEFAULT_CONTEXT_TOKENS; + const totalTokens = + entry?.totalTokens ?? + ((entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0)); + const agentProbe = probeAgentCommand(args.reply?.command); + + const thinkLevel = + args.resolvedThink ?? args.reply?.thinkingDefault ?? "auto"; + const verboseLevel = + args.resolvedVerbose ?? args.reply?.verboseDefault ?? "off"; + + const webLine = (() => { + if (args.webLinked === false) { + return "Web: not linked — run `clawdis 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 sessionLine = [ + `Session: ${args.sessionKey ?? "unknown"}`, + `scope ${args.sessionScope ?? "per-sender"}`, + entry?.updatedAt ? `updated ${formatAge(now - entry.updatedAt)}` : "no activity", + args.storePath ? `store ${abbreviatePath(args.storePath)}` : undefined, + ] + .filter(Boolean) + .join(" • "); + + const contextLine = `Context: ${formatTokens( + totalTokens, + contextTokens ?? null, + )}${entry?.abortedLastRun ? " • last run aborted" : ""}`; + + const optionsLine = `Options: thinking=${thinkLevel} | verbose=${verboseLevel} (set with /think , /verbose on|off)`; + + const agentLine = `Agent: ${agentProbe.ok ? "ready" : "check"} — ${agentProbe.label}${agentProbe.detail ? ` (${agentProbe.detail})` : ""}${model ? ` • model ${model}` : ""}`; + + const helpersLine = "Shortcuts: /new reset | /restart relink"; + + return [ "⚙️ Status", webLine, agentLine, contextLine, sessionLine, optionsLine, helpersLine ].join( + "\n", + ); +}