Add bundled pi default and session token reporting

This commit is contained in:
Peter Steinberger
2025-12-05 22:33:09 +01:00
parent fe87160b19
commit 690113dd73
15 changed files with 427 additions and 6 deletions

34
src/agents/context.ts Normal file
View File

@@ -0,0 +1,34 @@
// Lazy-load pi-ai model metadata so we can infer context windows when the agent
// reports a model id. pi-coding-agent depends on @mariozechner/pi-ai, so it
// should be present whenever CLAWDIS is installed from npm.
type ModelEntry = { id: string; contextWindow?: number };
const MODEL_CACHE = new Map<string, number>();
const loadPromise = (async () => {
try {
const piAi = (await import("@mariozechner/pi-ai")) as {
getProviders: () => string[];
getModels: (provider: string) => ModelEntry[];
};
const providers = piAi.getProviders();
for (const p of providers) {
const models = piAi.getModels(p) as ModelEntry[];
for (const m of models) {
if (!m?.id) continue;
if (typeof m.contextWindow === "number" && m.contextWindow > 0) {
MODEL_CACHE.set(m.id, m.contextWindow);
}
}
}
} catch {
// If pi-ai isn't available, leave cache empty; lookup will fall back.
}
})();
export function lookupContextTokens(modelId?: string): number | undefined {
if (!modelId) return undefined;
// Best-effort: kick off loading, but don't block.
void loadPromise;
return MODEL_CACHE.get(modelId);
}

5
src/agents/defaults.ts Normal file
View File

@@ -0,0 +1,5 @@
// Defaults for agent metadata when upstream does not supply them.
// Model id uses pi-ai's built-in Anthropic catalog.
export const DEFAULT_MODEL = "claude-opus-4-5";
// Context window: Opus 4.5 supports ~200k tokens (per pi-ai models.generated.ts).
export const DEFAULT_CONTEXT_TOKENS = 200_000;

26
src/agents/pi-path.ts Normal file
View File

@@ -0,0 +1,26 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
// Resolve the bundled pi/tau binary path from the installed dependency.
export function resolveBundledPiBinary(): string | null {
try {
const require = createRequire(import.meta.url);
const pkgPath = require.resolve(
"@mariozechner/pi-coding-agent/package.json",
);
const pkgDir = path.dirname(pkgPath);
// Prefer compiled binary if present, else fall back to dist/cli.js (has shebang).
const binCandidates = [
path.join(pkgDir, "dist", "pi"),
path.join(pkgDir, "dist", "cli.js"),
path.join(pkgDir, "bin", "tau-dev.mjs"),
];
for (const candidate of binCandidates) {
if (fs.existsSync(candidate)) return candidate;
}
} catch {
// Dependency missing or resolution failed.
}
return null;
}

View File

@@ -1,4 +1,7 @@
import crypto from "node:crypto";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import { resolveBundledPiBinary } from "../agents/pi-path.js";
import { loadConfig, type WarelayConfig } from "../config/config.js";
import {
DEFAULT_IDLE_MINUTES,
@@ -121,6 +124,28 @@ function stripMentions(
return result.replace(/\s+/g, " ").trim();
}
function makeDefaultPiReply() {
const piBin = resolveBundledPiBinary() ?? "pi";
const defaultContext =
lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS;
return {
mode: "command" as const,
command: [piBin, "--mode", "rpc", "{{BodyStripped}}"],
agent: {
kind: "pi" as const,
model: DEFAULT_MODEL,
contextTokens: defaultContext,
format: "json" as const,
},
session: {
scope: "per-sender" as const,
resetTriggers: [DEFAULT_RESET_TRIGGER],
idleMinutes: DEFAULT_IDLE_MINUTES,
},
timeoutSeconds: 600,
};
}
export async function getReplyFromConfig(
ctx: MsgContext,
opts?: GetReplyOptions,
@@ -129,7 +154,7 @@ export async function getReplyFromConfig(
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
// Choose reply from config: static text or external command stdout.
const cfg = configOverride ?? loadConfig();
const reply = cfg.inbound?.reply;
const reply = cfg.inbound?.reply ?? makeDefaultPiReply();
const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1);
const timeoutMs = timeoutSeconds * 1000;
let started = false;
@@ -718,6 +743,49 @@ export async function getReplyFromConfig(
);
}
}
const usage = meta.agentMeta?.usage;
const model =
meta.agentMeta?.model ||
reply?.agent?.model ||
sessionEntry?.model ||
DEFAULT_MODEL;
const contextTokens =
reply?.agent?.contextTokens ??
lookupContextTokens(model) ??
sessionEntry?.contextTokens ??
DEFAULT_CONTEXT_TOKENS;
if (usage) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const total = usage.total ?? input + output;
sessionEntry = {
...entry,
inputTokens: (entry.inputTokens ?? 0) + input,
outputTokens: (entry.outputTokens ?? 0) + output,
totalTokens: (entry.totalTokens ?? 0) + total,
model,
contextTokens: contextTokens ?? entry.contextTokens,
updatedAt: Date.now(),
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
} else if (model || contextTokens) {
const entry = sessionEntry ?? sessionStore[sessionKey];
if (entry) {
sessionEntry = {
...entry,
model: model ?? entry.model,
contextTokens: contextTokens ?? entry.contextTokens,
};
sessionStore[sessionKey] = sessionEntry;
await saveSessionStore(storePath, sessionStore);
}
}
}
if (meta.agentMeta && isVerbose()) {
logVerbose(`Agent meta: ${JSON.stringify(meta.agentMeta)}`);

View File

@@ -2,6 +2,7 @@ import chalk from "chalk";
import { Command } from "commander";
import { agentCommand } from "../commands/agent.js";
import { sendCommand } from "../commands/send.js";
import { sessionsCommand } from "../commands/sessions.js";
import { statusCommand } from "../commands/status.js";
import { loadConfig } from "../config/config.js";
import { danger, info, setVerbose } from "../globals.js";
@@ -490,6 +491,42 @@ Examples:
}
});
program
.command("sessions")
.description("List stored conversation sessions")
.option("--json", "Output as JSON", false)
.option("--verbose", "Verbose logging", false)
.option(
"--store <path>",
"Path to session store (default: resolved from config)",
)
.option(
"--active <minutes>",
"Only show sessions updated within the past N minutes",
)
.addHelpText(
"after",
`
Examples:
clawdis sessions # list all sessions
clawdis sessions --active 120 # only last 2 hours
clawdis sessions --json # machine-readable output
clawdis sessions --store ./tmp/sessions.json
Shows token usage per session when the agent reports it; set inbound.reply.agent.contextTokens to see % of your model window.`,
)
.action(async (opts) => {
setVerbose(Boolean(opts.verbose));
await sessionsCommand(
{
json: Boolean(opts.json),
store: opts.store as string | undefined,
active: opts.active as string | undefined,
},
defaultRuntime,
);
});
program
.command("relay:tmux")
.description(

167
src/commands/sessions.ts Normal file
View File

@@ -0,0 +1,167 @@
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { info } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
type SessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
updatedAt: number | null;
ageMs: number | null;
sessionId?: string;
systemSent?: boolean;
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
model?: string;
contextTokens?: number;
};
const formatAge = (ms: number | null | undefined) => {
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`;
};
function classifyKey(key: string): SessionRow["kind"] {
if (key === "global") return "global";
if (key.startsWith("group:")) return "group";
if (key === "unknown") return "unknown";
return "direct";
}
function toRows(store: Record<string, SessionEntry>): SessionRow[] {
return Object.entries(store)
.map(([key, entry]) => {
const updatedAt = entry?.updatedAt ?? null;
return {
key,
kind: classifyKey(key),
updatedAt,
ageMs: updatedAt ? Date.now() - updatedAt : null,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
model: entry?.model,
contextTokens: entry?.contextTokens,
} satisfies SessionRow;
})
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
export async function sessionsCommand(
opts: { json?: boolean; store?: string; active?: string },
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const configContextTokens =
cfg.inbound?.reply?.agent?.contextTokens ??
lookupContextTokens(cfg.inbound?.reply?.agent?.model) ??
DEFAULT_CONTEXT_TOKENS;
const configModel = cfg.inbound?.reply?.agent?.model ?? DEFAULT_MODEL;
const storePath = resolveStorePath(
opts.store ?? cfg.inbound?.reply?.session?.store,
);
const store = loadSessionStore(storePath);
const activeMinutes = opts.active
? Number.parseInt(String(opts.active), 10)
: undefined;
if (
opts.active !== undefined &&
(Number.isNaN(activeMinutes) || activeMinutes <= 0)
) {
runtime.error("--active must be a positive integer (minutes)");
runtime.exit(1);
return;
}
const rows = toRows(store).filter((row) => {
if (!activeMinutes) return true;
if (!row.updatedAt) return false;
return Date.now() - row.updatedAt <= activeMinutes * 60_000;
});
if (opts.json) {
runtime.log(
JSON.stringify(
{
path: storePath,
count: rows.length,
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => ({
...r,
contextTokens:
r.contextTokens ??
lookupContextTokens(r.model) ??
configContextTokens ??
null,
model: r.model ?? configModel ?? null,
})),
},
null,
2,
),
);
return;
}
runtime.log(info(`Session store: ${storePath}`));
runtime.log(info(`Sessions listed: ${rows.length}`));
if (activeMinutes) {
runtime.log(info(`Filtered to last ${activeMinutes} minute(s)`));
}
if (rows.length === 0) {
runtime.log("No sessions found.");
return;
}
for (const row of rows) {
const model = row.model ?? configModel;
const contextTokens =
row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const input = row.inputTokens ?? 0;
const output = row.outputTokens ?? 0;
const total = row.totalTokens ?? input + output;
const pct = contextTokens
? `${Math.min(100, Math.round((total / contextTokens) * 100))}%`
: null;
const parts = [
`${row.key} [${row.kind}]`,
row.updatedAt ? formatAge(Date.now() - row.updatedAt) : "age unknown",
];
if (row.sessionId) parts.push(`id ${row.sessionId}`);
if (row.thinkingLevel) parts.push(`think=${row.thinkingLevel}`);
if (row.verboseLevel) parts.push(`verbose=${row.verboseLevel}`);
if (row.systemSent) parts.push("systemSent");
if (row.abortedLastRun) parts.push("aborted");
if (total > 0) {
const tokenStr = `tokens in:${input} out:${output} total:${total}`;
parts.push(
contextTokens ? `${tokenStr} (${pct} of ${contextTokens})` : tokenStr,
);
}
if (model) parts.push(`model=${model}`);
runtime.log(`- ${parts.join(" | ")}`);
}
}

View File

@@ -82,6 +82,8 @@ export type WarelayConfig = {
kind: AgentKind;
format?: "text" | "json";
identityPrefix?: string;
model?: string;
contextTokens?: number;
};
};
};
@@ -141,6 +143,8 @@ const ReplySchema = z
kind: z.literal("pi"),
format: z.union([z.literal("text"), z.literal("json")]).optional(),
identityPrefix: z.string().optional(),
model: z.string().optional(),
contextTokens: z.number().int().positive().optional(),
})
.optional(),
})

View File

@@ -15,6 +15,11 @@ export type SessionEntry = {
abortedLastRun?: boolean;
thinkingLevel?: string;
verboseLevel?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
model?: string;
contextTokens?: number;
};
export const SESSION_STORE_DEFAULT = path.join(

View File

@@ -148,6 +148,11 @@ export async function ensureFunnel(
runtime.error(
"Failed to enable Tailscale Funnel. Is it allowed on your tailnet?",
);
runtime.error(
info(
"Tip: Funnel is optional for CLAWDIS. You can keep running the web relay without it: `pnpm clawdis relay`",
),
);
if (isVerbose()) {
if (stdout.trim()) runtime.error(chalk.gray(`stdout: ${stdout.trim()}`));
if (stderr.trim()) runtime.error(chalk.gray(`stderr: ${stderr.trim()}`));