openclaw/src/auto-reply/status.ts
2025-12-08 17:05:27 +01:00

255 lines
7.4 KiB
TypeScript

import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
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 | null | undefined,
contextTokens: number | null,
) => {
const ctx = contextTokens ?? null;
if (total == null) {
const ctxLabel = ctx ? formatKTokens(ctx) : "?";
return `unknown/${ctxLabel}`;
}
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}%)` : ""}`;
};
const readUsageFromSessionLog = (
sessionId?: string,
storePath?: string,
):
| {
input: number;
output: number;
total: number;
model?: string;
}
| undefined => {
// Prefer the coding-agent session log (pi-mono) if present.
// Path resolution rules (priority):
// 1) Store directory sibling file <sessionId>.jsonl
// 2) PI coding agent dir: ~/.pi/agent/sessions/<sessionId>.jsonl
if (!sessionId) return undefined;
const candidatePaths: string[] = [];
if (storePath) {
const dir = path.dirname(storePath);
candidatePaths.push(path.join(dir, `${sessionId}.jsonl`));
}
const piDir = path.join(os.homedir(), ".pi", "agent", "sessions");
candidatePaths.push(path.join(piDir, `${sessionId}.jsonl`));
const logPath = candidatePaths.find((p) => fs.existsSync(p));
if (!logPath) return undefined;
try {
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
let input = 0;
let output = 0;
let model: string | undefined;
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as {
message?: {
usage?: { input?: number; output?: number; total?: number };
model?: string;
};
usage?: { input?: number; output?: number; total?: number };
model?: string;
};
const usage = parsed.message?.usage ?? parsed.usage;
if (usage) {
input += usage.input ?? 0;
output += usage.output ?? 0;
}
model = parsed.message?.model ?? parsed.model ?? model;
} catch {
// ignore bad lines
}
}
const total = input + output;
if (total === 0) return undefined;
return { input, output, total, model };
} catch {
return undefined;
}
};
export function buildStatusMessage(args: StatusArgs): string {
const now = args.now ?? Date.now();
const entry = args.sessionEntry;
let model = entry?.model ?? args.reply?.agent?.model ?? DEFAULT_MODEL;
let contextTokens =
entry?.contextTokens ??
args.reply?.agent?.contextTokens ??
lookupContextTokens(model) ??
DEFAULT_CONTEXT_TOKENS;
let totalTokens =
entry?.totalTokens ??
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
// Fallback: derive usage from the session transcript if the store lacks it
if (!totalTokens || totalTokens === 0) {
const logUsage = readUsageFromSessionLog(entry?.sessionId, args.storePath);
if (logUsage) {
totalTokens = logUsage.total;
if (!model) model = logUsage.model ?? model;
if (!contextTokens && logUsage.model) {
contextTokens = lookupContextTokens(logUsage.model) ?? contextTokens;
}
}
}
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");
}