auto-reply: enrich chat status
parent
1f0ee9837b
commit
e2c6546b61
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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
|
### 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.
|
- New `clawdis agent` command plus a persistent Pi RPC worker (auto-started) enables direct agent chats; `clawdis status` renders a colored session/recipient table.
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,12 @@ clawdis relay # Start listening
|
||||||
| `clawdis send` | Send a message |
|
| `clawdis send` | Send a message |
|
||||||
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
|
||||||
| `clawdis relay` | Start auto-reply loop |
|
| `clawdis relay` | Start auto-reply loop |
|
||||||
| `clawdis status` | Show recent messages |
|
| `clawdis status` | Web session health + session store summary |
|
||||||
| `clawdis heartbeat` | Trigger a heartbeat |
|
| `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
|
### 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:<jid>`.
|
- Direct chats now share a canonical session key `main` by default (configurable via `inbound.reply.session.mainKey`). Groups stay isolated as `group:<jid>`.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
||||||
|
|
||||||
## Quick checks
|
## Quick checks
|
||||||
- `pnpm clawdis status --json` — confirms creds exist (`web.linked`), shows auth age (`authAgeMs`), heartbeat interval, and where the session store lives.
|
- `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 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.
|
- `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`.
|
- Logs: tail `/tmp/clawdis/clawdis.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ Clawdis treats **one session as primary**. By default the canonical key is `main
|
||||||
## Inspecting
|
## Inspecting
|
||||||
- `pnpm clawdis status` — shows store path and recent sessions.
|
- `pnpm clawdis status` — shows store path and recent sessions.
|
||||||
- `pnpm clawdis sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
- `pnpm clawdis sessions --json` — dumps every entry (filter with `--active <minutes>`).
|
||||||
|
- 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.
|
- JSONL transcripts can be opened directly to review full turns.
|
||||||
|
|
||||||
## Tips
|
## Tips
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ Or use the `process` tool to background long commands.
|
||||||
```bash
|
```bash
|
||||||
# Check status
|
# Check status
|
||||||
clawdis status
|
clawdis status
|
||||||
|
# Or from chat: send /status for agent + context usage
|
||||||
|
|
||||||
# View recent connection events
|
# View recent connection events
|
||||||
tail -100 /tmp/clawdis/clawdis.log | grep "connection\|disconnect\|logout"
|
tail -100 /tmp/clawdis/clawdis.log | grep "connection\|disconnect\|logout"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import * as tauRpc from "../process/tau-rpc.js";
|
import * as tauRpc from "../process/tau-rpc.js";
|
||||||
import { getReplyFromConfig } from "./reply.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 = {
|
const baseCfg = {
|
||||||
inbound: {
|
inbound: {
|
||||||
reply: {
|
reply: {
|
||||||
|
|
@ -52,6 +59,23 @@ describe("trigger handling", () => {
|
||||||
expect(runner).not.toHaveBeenCalled();
|
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 () => {
|
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||||
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
const rpcMock = vi.spyOn(tauRpc, "runPiRpc").mockResolvedValue({
|
||||||
stdout:
|
stdout:
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import {
|
||||||
type MsgContext,
|
type MsgContext,
|
||||||
type TemplateContext,
|
type TemplateContext,
|
||||||
} from "./templating.js";
|
} from "./templating.js";
|
||||||
|
import { buildStatusMessage } from "./status.js";
|
||||||
|
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
|
||||||
|
import { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
||||||
import {
|
import {
|
||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
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 =
|
const abortRequested =
|
||||||
reply?.mode === "command" && isAbortTrigger(rawBodyNormalized);
|
reply?.mode === "command" && isAbortTrigger(rawBodyNormalized);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<WarelayConfig["inbound"]>["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 <level>, /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",
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue