221 lines
6.5 KiB
TypeScript
221 lines
6.5 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
|
|
import { lookupContextTokens } from "../agents/context.js";
|
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
|
import {
|
|
derivePromptTokens,
|
|
normalizeUsage,
|
|
type UsageLike,
|
|
} from "../agents/usage.js";
|
|
import type { ClawdisConfig } from "../config/config.js";
|
|
import {
|
|
resolveSessionTranscriptPath,
|
|
type SessionEntry,
|
|
type SessionScope,
|
|
} from "../config/sessions.js";
|
|
import type { ThinkLevel, VerboseLevel } from "./thinking.js";
|
|
|
|
type AgentConfig = NonNullable<ClawdisConfig["agent"]>;
|
|
|
|
type StatusArgs = {
|
|
agent: AgentConfig;
|
|
workspaceDir?: string;
|
|
sessionEntry?: SessionEntry;
|
|
sessionKey?: string;
|
|
sessionScope?: SessionScope;
|
|
storePath?: string;
|
|
resolvedThink?: ThinkLevel;
|
|
resolvedVerbose?: VerboseLevel;
|
|
now?: number;
|
|
webLinked?: boolean;
|
|
webAuthAgeMs?: number | null;
|
|
heartbeatSeconds?: 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`;
|
|
|
|
const abbreviatePath = (p?: string) => {
|
|
if (!p) return undefined;
|
|
const home = os.homedir();
|
|
if (p.startsWith(home)) return p.replace(home, "~");
|
|
return p;
|
|
};
|
|
|
|
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,
|
|
):
|
|
| {
|
|
input: number;
|
|
output: number;
|
|
promptTokens: number;
|
|
total: number;
|
|
model?: string;
|
|
}
|
|
| undefined => {
|
|
// Transcripts always live at: ~/.clawdis/sessions/<SessionId>.jsonl
|
|
if (!sessionId) return undefined;
|
|
const logPath = resolveSessionTranscriptPath(sessionId);
|
|
if (!fs.existsSync(logPath)) return undefined;
|
|
|
|
try {
|
|
const lines = fs.readFileSync(logPath, "utf-8").split(/\n+/);
|
|
let input = 0;
|
|
let output = 0;
|
|
let promptTokens = 0;
|
|
let model: string | undefined;
|
|
let lastUsage: ReturnType<typeof normalizeUsage> | undefined;
|
|
|
|
for (const line of lines) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const parsed = JSON.parse(line) as {
|
|
message?: {
|
|
usage?: UsageLike;
|
|
model?: string;
|
|
};
|
|
usage?: UsageLike;
|
|
model?: string;
|
|
};
|
|
const usageRaw = parsed.message?.usage ?? parsed.usage;
|
|
const usage = normalizeUsage(usageRaw);
|
|
if (usage) lastUsage = usage;
|
|
model = parsed.message?.model ?? parsed.model ?? model;
|
|
} catch {
|
|
// ignore bad lines
|
|
}
|
|
}
|
|
|
|
if (!lastUsage) return undefined;
|
|
input = lastUsage.input ?? 0;
|
|
output = lastUsage.output ?? 0;
|
|
promptTokens =
|
|
derivePromptTokens(lastUsage) ?? lastUsage.total ?? input + output;
|
|
const total = lastUsage.total ?? promptTokens + output;
|
|
if (promptTokens === 0 && total === 0) return undefined;
|
|
return { input, output, promptTokens, 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.agent?.model ?? DEFAULT_MODEL;
|
|
let contextTokens =
|
|
entry?.contextTokens ??
|
|
args.agent?.contextTokens ??
|
|
lookupContextTokens(model) ??
|
|
DEFAULT_CONTEXT_TOKENS;
|
|
|
|
let totalTokens =
|
|
entry?.totalTokens ??
|
|
(entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
const thinkLevel = args.resolvedThink ?? args.agent?.thinkingDefault ?? "off";
|
|
const verboseLevel =
|
|
args.resolvedVerbose ?? args.agent?.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 groupActivationLine = args.sessionKey?.startsWith("group:")
|
|
? `Group activation: ${entry?.groupActivation ?? "mention"}`
|
|
: undefined;
|
|
|
|
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, /model <id>)`;
|
|
|
|
const modelLabel = args.agent?.provider?.trim()
|
|
? `${args.agent.provider}/${args.agent?.model ?? model}`
|
|
: model
|
|
? model
|
|
: "unknown";
|
|
|
|
const agentLine = `Agent: embedded pi • ${modelLabel}`;
|
|
|
|
const workspaceLine = args.workspaceDir
|
|
? `Workspace: ${abbreviatePath(args.workspaceDir)}`
|
|
: undefined;
|
|
|
|
const helpersLine = "Shortcuts: /new reset | /restart relink";
|
|
|
|
return [
|
|
"⚙️ Status",
|
|
webLine,
|
|
agentLine,
|
|
workspaceLine,
|
|
contextLine,
|
|
sessionLine,
|
|
groupActivationLine,
|
|
optionsLine,
|
|
helpersLine,
|
|
].join("\n");
|
|
}
|