426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1409 lines
45 KiB
TypeScript
1409 lines
45 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* `trajectory` — read-only CLI for inspecting the per-trajectory JSON files
|
|
* the runtime recorder writes to `${ELIZA_TRAJECTORY_DIR}` (default
|
|
* `./trajectories/`).
|
|
*
|
|
* Spec lives in `research/native-tool-calling/PLAN.md` §18.3.
|
|
*
|
|
* The schema this CLI reads matches `RecordedTrajectory` / `RecordedStage` in
|
|
* §18.1. Agent B owns the runtime side that produces the files; this tool
|
|
* never imports from `runtime/trajectory-recorder.ts` so it stays usable when
|
|
* that side hasn't landed yet. The schema is duplicated here verbatim from
|
|
* the plan as a typed contract.
|
|
*
|
|
* Subcommands:
|
|
* list - Tabular index of trajectories.
|
|
* print - Full per-stage transcript.
|
|
* stats - Compact metrics summary.
|
|
* failures - Filter to stages where success === false.
|
|
* diff - Byte/segment diff of prompts between two stages.
|
|
* replay - Print the prompt that was sent at a given stage.
|
|
* export - Write a self-contained markdown/html/json report.
|
|
* validate - Structural validation for complete, reviewable trajectories.
|
|
* compare - Cache/batching/cost deltas between two trajectories.
|
|
* aggregate - Cross-trajectory rollup.
|
|
* providers - Per-provider token/cost attribution for model calls.
|
|
*/
|
|
|
|
import type { Dirent } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { computeCallCostUsd, formatUsd } from "./lib/cost-table";
|
|
import {
|
|
compareTrajectories,
|
|
type ValidationIssue,
|
|
validateTrajectory,
|
|
validateTrajectoryExportReport,
|
|
} from "./lib/trajectory-validate";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schema (mirrors PLAN.md §18.1; do not import — Agent B owns the producer)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ChatMessage {
|
|
role: string;
|
|
content: unknown;
|
|
}
|
|
|
|
interface ToolDefinition {
|
|
name?: string;
|
|
description?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface ToolCallRecord {
|
|
id?: string;
|
|
name?: string;
|
|
args?: Record<string, unknown>;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface UsageBreakdown {
|
|
promptTokens: number;
|
|
completionTokens: number;
|
|
cacheReadInputTokens?: number;
|
|
cacheCreationInputTokens?: number;
|
|
totalTokens: number;
|
|
}
|
|
|
|
interface ModelCallRecord {
|
|
modelType: string;
|
|
modelName?: string;
|
|
provider: string;
|
|
prompt: string;
|
|
messages?: ChatMessage[];
|
|
tools?: ToolDefinition[];
|
|
toolChoice?: unknown;
|
|
response: string;
|
|
toolCalls?: ToolCallRecord[];
|
|
usage?: UsageBreakdown;
|
|
finishReason?: string;
|
|
costUsd?: number;
|
|
providerOrder?: string[];
|
|
providerAttributions?: ProviderAttributionRecord[];
|
|
}
|
|
|
|
interface ProviderAttributionRecord {
|
|
providerName: string;
|
|
sha256: string;
|
|
tokenCount: number;
|
|
position: number;
|
|
spanStart?: number;
|
|
spanEnd?: number;
|
|
}
|
|
|
|
interface ToolStageRecord {
|
|
name: string;
|
|
args: Record<string, unknown>;
|
|
result: unknown;
|
|
success: boolean;
|
|
durationMs: number;
|
|
}
|
|
|
|
interface EvaluationRecord {
|
|
success: boolean;
|
|
decision: string;
|
|
thought?: string;
|
|
messageToUser?: string;
|
|
copyToClipboard?: unknown;
|
|
recommendedToolCallId?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface CacheStageRecord {
|
|
segmentHashes: string[];
|
|
prefixHash: string;
|
|
diffFromPriorStage?: { added: number; unchanged: number; removed: number };
|
|
}
|
|
|
|
interface RecordedStage {
|
|
stageId: string;
|
|
kind:
|
|
| "messageHandler"
|
|
| "planner"
|
|
| "tool"
|
|
| "evaluation"
|
|
| "subPlanner"
|
|
| "compaction";
|
|
iteration?: number;
|
|
parentStageId?: string;
|
|
startedAt: number;
|
|
endedAt: number;
|
|
latencyMs: number;
|
|
model?: ModelCallRecord;
|
|
tool?: ToolStageRecord;
|
|
evaluation?: EvaluationRecord;
|
|
cache?: CacheStageRecord;
|
|
}
|
|
|
|
interface RecordedTrajectoryMetrics {
|
|
totalLatencyMs: number;
|
|
totalPromptTokens: number;
|
|
totalCompletionTokens: number;
|
|
totalCacheReadTokens: number;
|
|
totalCacheCreationTokens: number;
|
|
totalCostUsd: number;
|
|
plannerIterations: number;
|
|
toolCallsExecuted: number;
|
|
toolCallFailures: number;
|
|
evaluatorFailures: number;
|
|
finalDecision?: "FINISH" | "CONTINUE" | "max_iterations" | "error";
|
|
}
|
|
|
|
interface RecordedTrajectory {
|
|
trajectoryId: string;
|
|
agentId: string;
|
|
roomId?: string;
|
|
rootMessage: { id: string; text: string; sender?: string };
|
|
startedAt: number;
|
|
endedAt?: number;
|
|
status: "running" | "finished" | "errored";
|
|
stages: RecordedStage[];
|
|
metrics: RecordedTrajectoryMetrics;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IO helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const TRAJECTORY_DIR =
|
|
process.env.ELIZA_TRAJECTORY_DIR ??
|
|
path.resolve(process.cwd(), "trajectories");
|
|
|
|
interface TrajectoryFile {
|
|
id: string;
|
|
filePath: string;
|
|
mtimeMs: number;
|
|
}
|
|
|
|
async function listTrajectoryFiles(): Promise<TrajectoryFile[]> {
|
|
const out: TrajectoryFile[] = [];
|
|
const stack: string[] = [TRAJECTORY_DIR];
|
|
|
|
try {
|
|
await fs.access(TRAJECTORY_DIR);
|
|
} catch {
|
|
return out;
|
|
}
|
|
|
|
while (stack.length > 0) {
|
|
const dir = stack.pop();
|
|
if (!dir) continue;
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[];
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
stack.push(full);
|
|
continue;
|
|
}
|
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
let mtimeMs = 0;
|
|
try {
|
|
const stat = await fs.stat(full);
|
|
mtimeMs = stat.mtimeMs;
|
|
} catch {
|
|
continue;
|
|
}
|
|
out.push({
|
|
id: entry.name.replace(/\.json$/, ""),
|
|
filePath: full,
|
|
mtimeMs,
|
|
});
|
|
}
|
|
}
|
|
|
|
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
return out;
|
|
}
|
|
|
|
async function loadTrajectory(
|
|
idOrPath: string,
|
|
): Promise<{ trajectory: RecordedTrajectory; filePath: string } | null> {
|
|
// Try direct path first.
|
|
if (idOrPath.endsWith(".json")) {
|
|
try {
|
|
const raw = await fs.readFile(idOrPath, "utf8");
|
|
return { trajectory: JSON.parse(raw), filePath: idOrPath };
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
const files = await listTrajectoryFiles();
|
|
const match = files.find((f) => f.id === idOrPath);
|
|
if (!match) return null;
|
|
const raw = await fs.readFile(match.filePath, "utf8");
|
|
return { trajectory: JSON.parse(raw), filePath: match.filePath };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pretty-printing helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ttyEnabled(): boolean {
|
|
if (process.env.NO_COLOR) return false;
|
|
const argv = process.argv;
|
|
if (argv.includes("--no-tty")) return false;
|
|
return process.stdout.isTTY === true;
|
|
}
|
|
|
|
function color(code: string, text: string): string {
|
|
if (!ttyEnabled()) return text;
|
|
return `\x1b[${code}m${text}\x1b[0m`;
|
|
}
|
|
|
|
const c = {
|
|
dim: (t: string) => color("2", t),
|
|
bold: (t: string) => color("1", t),
|
|
red: (t: string) => color("31", t),
|
|
green: (t: string) => color("32", t),
|
|
yellow: (t: string) => color("33", t),
|
|
cyan: (t: string) => color("36", t),
|
|
gray: (t: string) => color("90", t),
|
|
};
|
|
|
|
function formatTimestamp(ms: number): string {
|
|
if (!Number.isFinite(ms) || ms <= 0) return "—";
|
|
return new Date(ms).toISOString().replace("T", " ").replace(/\..+$/, "");
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (!Number.isFinite(ms) || ms < 0) return "—";
|
|
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
return `${(ms / 1000).toFixed(2)}s`;
|
|
}
|
|
|
|
function truncate(text: string, max: number): string {
|
|
if (text.length <= max) return text;
|
|
return `${text.slice(0, max)}${c.dim("…")}`;
|
|
}
|
|
|
|
function stageHeader(stage: RecordedStage, index: number): string {
|
|
const kindLabel = stage.kind;
|
|
const iter = stage.iteration ? ` iter ${stage.iteration}` : "";
|
|
const toolName = stage.tool ? `: ${stage.tool.name}` : "";
|
|
|
|
const lat = formatDuration(stage.latencyMs);
|
|
const cost = stage.model?.costUsd
|
|
? ` · ${formatUsd(stage.model.costUsd)}`
|
|
: "";
|
|
const usage = stage.model?.usage;
|
|
const cacheNote = usage?.cacheReadInputTokens
|
|
? ` · cache hit ${usage.cacheReadInputTokens}/${usage.promptTokens} tokens`
|
|
: "";
|
|
|
|
return c.bold(
|
|
`Stage ${index + 1} [${kindLabel}${iter}${toolName}] ${lat}${cost}${cacheNote}`,
|
|
);
|
|
}
|
|
|
|
function rule(): string {
|
|
return c.dim("─".repeat(72));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: list
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ListOptions {
|
|
agent?: string;
|
|
since?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
async function cmdList(opts: ListOptions): Promise<void> {
|
|
const files = await listTrajectoryFiles();
|
|
if (files.length === 0) {
|
|
console.log(c.dim(`No trajectories found in ${TRAJECTORY_DIR}.`));
|
|
return;
|
|
}
|
|
|
|
const rows: Array<{
|
|
id: string;
|
|
started: string;
|
|
duration: string;
|
|
stages: number;
|
|
tools: number;
|
|
cost: string;
|
|
decision: string;
|
|
agent: string;
|
|
}> = [];
|
|
|
|
for (const file of files.slice(0, opts.limit ?? 20)) {
|
|
try {
|
|
const raw = await fs.readFile(file.filePath, "utf8");
|
|
const traj = JSON.parse(raw) as RecordedTrajectory;
|
|
|
|
if (opts.agent && traj.agentId !== opts.agent) continue;
|
|
if (opts.since && (traj.startedAt ?? 0) < opts.since) continue;
|
|
|
|
const decision = traj.metrics?.finalDecision ?? traj.status;
|
|
rows.push({
|
|
id: traj.trajectoryId,
|
|
started: formatTimestamp(traj.startedAt),
|
|
duration: formatDuration(traj.metrics?.totalLatencyMs ?? 0),
|
|
stages: traj.stages.length,
|
|
tools: traj.metrics?.toolCallsExecuted ?? 0,
|
|
cost: formatUsd(traj.metrics?.totalCostUsd ?? 0),
|
|
decision,
|
|
agent: traj.agentId ?? "—",
|
|
});
|
|
} catch (err) {
|
|
console.error(
|
|
c.yellow(`skip ${file.filePath}: ${(err as Error).message}`),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
console.log(c.dim("No trajectories matched the filter."));
|
|
return;
|
|
}
|
|
|
|
const widths = {
|
|
id: Math.max(2, ...rows.map((r) => r.id.length), 12),
|
|
started: 19,
|
|
duration: 8,
|
|
stages: 6,
|
|
tools: 5,
|
|
cost: 8,
|
|
decision: Math.max(8, ...rows.map((r) => r.decision.length)),
|
|
agent: Math.max(8, ...rows.map((r) => r.agent.length)),
|
|
};
|
|
|
|
const header = `${"id".padEnd(widths.id)} ${"started".padEnd(widths.started)} ${"dur".padEnd(widths.duration)} ${"stages".padEnd(widths.stages)} ${"tools".padEnd(widths.tools)} ${"cost".padEnd(widths.cost)} ${"decision".padEnd(widths.decision)} ${"agent".padEnd(widths.agent)}`;
|
|
console.log(c.bold(header));
|
|
console.log(c.dim("-".repeat(header.length)));
|
|
for (const row of rows) {
|
|
console.log(
|
|
`${row.id.padEnd(widths.id)} ${row.started.padEnd(widths.started)} ${row.duration.padEnd(widths.duration)} ${String(row.stages).padEnd(widths.stages)} ${String(row.tools).padEnd(widths.tools)} ${row.cost.padEnd(widths.cost)} ${row.decision.padEnd(widths.decision)} ${row.agent.padEnd(widths.agent)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: print
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface PrintOptions {
|
|
stageId?: string;
|
|
full: boolean;
|
|
}
|
|
|
|
function printStage(
|
|
stage: RecordedStage,
|
|
idx: number,
|
|
opts: PrintOptions,
|
|
): void {
|
|
const promptLimit = opts.full ? Number.POSITIVE_INFINITY : 2000;
|
|
const responseLimit = opts.full ? Number.POSITIVE_INFINITY : 2000;
|
|
|
|
console.log(stageHeader(stage, idx));
|
|
console.log(rule());
|
|
|
|
if (stage.model) {
|
|
const m = stage.model;
|
|
const usage = m.usage;
|
|
console.log(
|
|
`${c.dim("model:")} ${m.modelName ?? m.modelType} ${c.dim(`(${m.provider})`)}`,
|
|
);
|
|
if (usage) {
|
|
console.log(
|
|
`${c.dim("usage:")} ${usage.promptTokens} in · ${usage.completionTokens} out` +
|
|
(usage.cacheReadInputTokens
|
|
? ` · ${usage.cacheReadInputTokens} cache-read`
|
|
: "") +
|
|
(usage.cacheCreationInputTokens
|
|
? ` · ${usage.cacheCreationInputTokens} cache-create`
|
|
: ""),
|
|
);
|
|
}
|
|
if (m.tools && m.tools.length > 0) {
|
|
console.log(
|
|
`${c.dim("tools:")} ${m.tools.map((t) => t.name ?? "<anon>").join(", ")}`,
|
|
);
|
|
}
|
|
if (m.prompt) {
|
|
console.log(c.dim("prompt:"));
|
|
console.log(truncate(m.prompt, promptLimit));
|
|
}
|
|
if (m.response) {
|
|
console.log(c.dim("response:"));
|
|
console.log(truncate(m.response, responseLimit));
|
|
}
|
|
if (m.toolCalls && m.toolCalls.length > 0) {
|
|
console.log(c.dim("toolCalls:"));
|
|
for (const call of m.toolCalls) {
|
|
console.log(
|
|
` ${c.cyan(call.name ?? "<no-name>")} ${JSON.stringify(call.args ?? {})}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (stage.tool) {
|
|
const t = stage.tool;
|
|
const tag = t.success ? c.green("success") : c.red("FAILURE");
|
|
console.log(
|
|
`${c.dim("tool:")} ${t.name} · ${tag} · ${formatDuration(t.durationMs)}`,
|
|
);
|
|
console.log(`${c.dim("args:")} ${JSON.stringify(t.args)}`);
|
|
console.log(
|
|
`${c.dim("result:")} ${truncate(JSON.stringify(t.result), opts.full ? Number.POSITIVE_INFINITY : 1500)}`,
|
|
);
|
|
}
|
|
|
|
if (stage.evaluation) {
|
|
const e = stage.evaluation;
|
|
const tag = e.success ? c.green("success: true") : c.red("success: false");
|
|
console.log(
|
|
`${c.dim("eval:")} ${tag} · decision: ${c.cyan(e.decision)}`,
|
|
);
|
|
if (e.thought) console.log(`${c.dim("thought:")} ${e.thought}`);
|
|
if (e.messageToUser)
|
|
console.log(`${c.dim("user:")} ${e.messageToUser}`);
|
|
}
|
|
|
|
if (stage.cache?.diffFromPriorStage) {
|
|
const d = stage.cache.diffFromPriorStage;
|
|
console.log(
|
|
`${c.dim("cache:")} +${d.added} ~${d.unchanged} -${d.removed} segments`,
|
|
);
|
|
}
|
|
|
|
console.log("");
|
|
}
|
|
|
|
async function cmdPrint(idOrPath: string, opts: PrintOptions): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
|
|
console.log(c.bold(`Trajectory ${t.trajectoryId}`));
|
|
console.log(
|
|
`${c.dim("agent:")} ${t.agentId} ${c.dim("room:")} ${t.roomId ?? "—"}`,
|
|
);
|
|
console.log(
|
|
`${c.dim("from:")} ${t.rootMessage?.sender ?? "user"}: ${t.rootMessage?.text ?? "—"}`,
|
|
);
|
|
console.log(
|
|
`${c.dim("status:")} ${t.status} ${c.dim("started:")} ${formatTimestamp(t.startedAt)} ${c.dim("ended:")} ${t.endedAt ? formatTimestamp(t.endedAt) : "—"}`,
|
|
);
|
|
console.log(
|
|
`${c.dim("metrics:")} ${formatDuration(t.metrics?.totalLatencyMs ?? 0)} · ${formatUsd(t.metrics?.totalCostUsd ?? 0)} · stages ${t.stages.length} · tool calls ${t.metrics?.toolCallsExecuted ?? 0} · final ${t.metrics?.finalDecision ?? "—"}`,
|
|
);
|
|
console.log("");
|
|
|
|
const stages = opts.stageId
|
|
? t.stages.filter((s) => s.stageId === opts.stageId)
|
|
: t.stages;
|
|
if (opts.stageId && stages.length === 0) {
|
|
console.error(c.red(`Stage "${opts.stageId}" not found in trajectory.`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
stages.forEach((stage, idx) => {
|
|
printStage(stage, idx, opts);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: stats
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cmdStats(idOrPath: string): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
const m = t.metrics;
|
|
|
|
const stageCounts = new Map<string, number>();
|
|
for (const s of t.stages) {
|
|
stageCounts.set(s.kind, (stageCounts.get(s.kind) ?? 0) + 1);
|
|
}
|
|
const stageBreakdown = Array.from(stageCounts.entries())
|
|
.map(([k, v]) => `${v} ${k}`)
|
|
.join(", ");
|
|
|
|
const decisionCounts = new Map<string, number>();
|
|
for (const s of t.stages) {
|
|
if (s.evaluation?.decision) {
|
|
decisionCounts.set(
|
|
s.evaluation.decision,
|
|
(decisionCounts.get(s.evaluation.decision) ?? 0) + 1,
|
|
);
|
|
}
|
|
}
|
|
const decisionList = Array.from(decisionCounts.entries())
|
|
.map(([k, v]) => `${k} (${v})`)
|
|
.join(", ");
|
|
|
|
const cacheRates: number[] = [];
|
|
for (const s of t.stages) {
|
|
if (s.model?.usage && s.model.usage.promptTokens > 0) {
|
|
cacheRates.push(
|
|
(s.model.usage.cacheReadInputTokens ?? 0) /
|
|
Math.max(1, s.model.usage.promptTokens),
|
|
);
|
|
}
|
|
}
|
|
const meanCacheRate = cacheRates.length
|
|
? (cacheRates.reduce((a, b) => a + b, 0) / cacheRates.length) * 100
|
|
: 0;
|
|
|
|
console.log(c.bold(`Trajectory ${t.trajectoryId}`));
|
|
console.log(
|
|
`agent ${t.agentId} · started ${formatTimestamp(t.startedAt)} · ${formatDuration(m?.totalLatencyMs ?? 0)} total`,
|
|
);
|
|
console.log(`Stages: ${t.stages.length} (${stageBreakdown || "—"})`);
|
|
console.log(
|
|
`Tokens: ${m?.totalPromptTokens ?? 0} input (${m?.totalCacheReadTokens ?? 0} cache-read, ${m?.totalCacheCreationTokens ?? 0} cache-created), ${m?.totalCompletionTokens ?? 0} output`,
|
|
);
|
|
console.log(`Cost: ${formatUsd(m?.totalCostUsd ?? 0)}`);
|
|
console.log(
|
|
`Tool calls: ${m?.toolCallsExecuted ?? 0} executed (${(m?.toolCallsExecuted ?? 0) - (m?.toolCallFailures ?? 0)} success, ${m?.toolCallFailures ?? 0} failed)`,
|
|
);
|
|
console.log(`Evaluator decisions: ${decisionList || "—"}`);
|
|
console.log(`Evaluator success=false: ${m?.evaluatorFailures ?? 0} stages`);
|
|
console.log(`Cache hit rate (avg): ${meanCacheRate.toFixed(1)}%`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: failures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cmdFailures(idOrPath: string): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
const failed = t.stages.filter(
|
|
(s) => s.tool?.success === false || s.evaluation?.success === false,
|
|
);
|
|
|
|
if (failed.length === 0) {
|
|
console.log(c.green(`No failed stages in trajectory ${t.trajectoryId}.`));
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
c.bold(
|
|
`Trajectory ${t.trajectoryId} · ${failed.length} failed ${failed.length === 1 ? "stage" : "stages"}`,
|
|
),
|
|
);
|
|
console.log("");
|
|
|
|
for (const stage of failed) {
|
|
printStage(stage, t.stages.indexOf(stage), {
|
|
full: true,
|
|
stageId: undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: diff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DiffOptions {
|
|
stages: [string, string];
|
|
}
|
|
|
|
async function cmdDiff(idOrPath: string, opts: DiffOptions): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
const a = t.stages.find((s) => s.stageId === opts.stages[0]);
|
|
const b = t.stages.find((s) => s.stageId === opts.stages[1]);
|
|
if (!a || !b) {
|
|
console.error(
|
|
c.red(
|
|
`Stage(s) not found. a=${opts.stages[0]} (${a ? "ok" : "missing"}), b=${opts.stages[1]} (${b ? "ok" : "missing"})`,
|
|
),
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
console.log(c.bold("Stage A"));
|
|
console.log(
|
|
` ${a.stageId} ${a.kind}${a.iteration ? ` iter ${a.iteration}` : ""}`,
|
|
);
|
|
console.log(c.bold("Stage B"));
|
|
console.log(
|
|
` ${b.stageId} ${b.kind}${b.iteration ? ` iter ${b.iteration}` : ""}`,
|
|
);
|
|
console.log("");
|
|
|
|
const promptA = a.model?.prompt ?? "";
|
|
const promptB = b.model?.prompt ?? "";
|
|
|
|
let common = 0;
|
|
const min = Math.min(promptA.length, promptB.length);
|
|
for (; common < min; common++) {
|
|
if (promptA[common] !== promptB[common]) break;
|
|
}
|
|
console.log(
|
|
`Common prefix: ${common} chars (~${Math.round(common / 4)} tokens)`,
|
|
);
|
|
console.log(
|
|
`A length: ${promptA.length} chars · B length: ${promptB.length} chars`,
|
|
);
|
|
|
|
if (a.cache && b.cache) {
|
|
const aSet = new Set(a.cache.segmentHashes);
|
|
const bSet = new Set(b.cache.segmentHashes);
|
|
const added = b.cache.segmentHashes.filter((h) => !aSet.has(h));
|
|
const removed = a.cache.segmentHashes.filter((h) => !bSet.has(h));
|
|
const unchanged = a.cache.segmentHashes.filter((h) => bSet.has(h));
|
|
console.log(
|
|
`Segment-level: +${added.length} added, ~${unchanged.length} unchanged, -${removed.length} removed`,
|
|
);
|
|
}
|
|
|
|
const tail = promptB.slice(common);
|
|
if (tail.length > 0) {
|
|
console.log("");
|
|
console.log(c.dim("B-only suffix (truncated):"));
|
|
console.log(truncate(tail, 1000));
|
|
}
|
|
console.log("");
|
|
console.log(
|
|
c.dim("Cache implication: B can hit A's cached prefix where shared."),
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: replay
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cmdReplay(idOrPath: string, stageId?: string): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
const stage = stageId
|
|
? t.stages.find((s) => s.stageId === stageId)
|
|
: t.stages.find((s) => s.model?.prompt);
|
|
|
|
if (!stage) {
|
|
console.error(c.red("No matching stage with a model prompt."));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (!stage.model?.prompt) {
|
|
console.error(c.red(`Stage ${stage.stageId} has no recorded prompt.`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
process.stdout.write(stage.model.prompt);
|
|
if (!stage.model.prompt.endsWith("\n")) process.stdout.write("\n");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: export
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ExportOptions {
|
|
format: "markdown" | "html" | "json";
|
|
out?: string;
|
|
}
|
|
|
|
function exportMarkdown(t: RecordedTrajectory): string {
|
|
const lines: string[] = [];
|
|
lines.push(`# Trajectory ${t.trajectoryId}`);
|
|
lines.push("");
|
|
lines.push(`- agent: \`${t.agentId}\``);
|
|
lines.push(`- room: \`${t.roomId ?? "—"}\``);
|
|
lines.push(`- started: ${formatTimestamp(t.startedAt)}`);
|
|
lines.push(`- ended: ${t.endedAt ? formatTimestamp(t.endedAt) : "—"}`);
|
|
lines.push(`- status: ${t.status}`);
|
|
lines.push(
|
|
`- total: ${formatDuration(t.metrics?.totalLatencyMs ?? 0)} · ${formatUsd(t.metrics?.totalCostUsd ?? 0)}`,
|
|
);
|
|
lines.push(`- root message: ${t.rootMessage?.text ?? "—"}`);
|
|
lines.push("");
|
|
|
|
t.stages.forEach((stage, idx) => {
|
|
lines.push(
|
|
`## Stage ${idx + 1}: ${stage.kind}${stage.iteration ? ` iter ${stage.iteration}` : ""} (${stage.stageId})`,
|
|
);
|
|
lines.push("");
|
|
lines.push(`- latency: ${formatDuration(stage.latencyMs)}`);
|
|
if (stage.model) {
|
|
lines.push(
|
|
`- model: \`${stage.model.modelName ?? stage.model.modelType}\` (${stage.model.provider})`,
|
|
);
|
|
if (stage.model.usage) {
|
|
lines.push(
|
|
`- usage: ${stage.model.usage.promptTokens} in · ${stage.model.usage.completionTokens} out · cache-read ${stage.model.usage.cacheReadInputTokens ?? 0}`,
|
|
);
|
|
}
|
|
lines.push("```");
|
|
lines.push("PROMPT:");
|
|
lines.push(stage.model.prompt);
|
|
lines.push("");
|
|
lines.push("RESPONSE:");
|
|
lines.push(stage.model.response);
|
|
lines.push("```");
|
|
lines.push("");
|
|
lines.push("```");
|
|
lines.push("MESSAGES:");
|
|
lines.push(JSON.stringify(stage.model.messages ?? [], null, 2));
|
|
lines.push("TOOLS:");
|
|
lines.push(JSON.stringify(stage.model.tools ?? [], null, 2));
|
|
lines.push("TOOL_CALLS:");
|
|
lines.push(JSON.stringify(stage.model.toolCalls ?? [], null, 2));
|
|
lines.push("```");
|
|
}
|
|
if (stage.tool) {
|
|
lines.push(
|
|
`- tool \`${stage.tool.name}\` ${stage.tool.success ? "ok" : "FAIL"} ${formatDuration(stage.tool.durationMs)}`,
|
|
);
|
|
lines.push("```json");
|
|
lines.push(
|
|
JSON.stringify(
|
|
{ args: stage.tool.args, result: stage.tool.result },
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
lines.push("```");
|
|
}
|
|
if (stage.evaluation) {
|
|
lines.push(
|
|
`- evaluation: success=${stage.evaluation.success}, decision=${stage.evaluation.decision}`,
|
|
);
|
|
if (stage.evaluation.thought)
|
|
lines.push(` - thought: ${stage.evaluation.thought}`);
|
|
if (stage.evaluation.messageToUser)
|
|
lines.push(` - messageToUser: ${stage.evaluation.messageToUser}`);
|
|
}
|
|
lines.push("");
|
|
});
|
|
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function htmlEscape(text: string): string {
|
|
return text
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function exportHtml(t: RecordedTrajectory): string {
|
|
const stages = t.stages
|
|
.map((stage, idx) => {
|
|
const promptBlock = stage.model?.prompt
|
|
? `<details><summary>prompt (${stage.model.prompt.length} chars)</summary><pre>${htmlEscape(stage.model.prompt)}</pre></details>`
|
|
: "";
|
|
const responseBlock = stage.model?.response
|
|
? `<details><summary>response</summary><pre>${htmlEscape(stage.model.response)}</pre></details>`
|
|
: "";
|
|
const toolBlock = stage.tool
|
|
? `<pre>tool ${htmlEscape(stage.tool.name)} ${stage.tool.success ? "ok" : "FAIL"}\nargs: ${htmlEscape(JSON.stringify(stage.tool.args))}\nresult: ${htmlEscape(JSON.stringify(stage.tool.result))}</pre>`
|
|
: "";
|
|
const evalBlock = stage.evaluation
|
|
? `<pre>eval success=${stage.evaluation.success} decision=${htmlEscape(stage.evaluation.decision)}\n${htmlEscape(stage.evaluation.thought ?? "")}</pre>`
|
|
: "";
|
|
return `<section><h2>Stage ${idx + 1}: ${htmlEscape(stage.kind)}${stage.iteration ? ` iter ${stage.iteration}` : ""}</h2><p>${formatDuration(stage.latencyMs)}</p>${promptBlock}${responseBlock}${toolBlock}${evalBlock}</section>`;
|
|
})
|
|
.join("\n");
|
|
|
|
return `<!doctype html><html><head><meta charset="utf-8"><title>${htmlEscape(t.trajectoryId)}</title><style>body{font-family:system-ui,sans-serif;max-width:960px;margin:2em auto;padding:0 1em}pre{white-space:pre-wrap;background:#f4f4f4;padding:0.5em;border-radius:4px}h2{margin-top:2em;border-bottom:1px solid #ddd;padding-bottom:0.2em}</style></head><body><h1>Trajectory ${htmlEscape(t.trajectoryId)}</h1><p>agent: ${htmlEscape(t.agentId)} · ${formatDuration(t.metrics?.totalLatencyMs ?? 0)} · ${formatUsd(t.metrics?.totalCostUsd ?? 0)}</p>${stages}</body></html>`;
|
|
}
|
|
|
|
async function cmdExport(idOrPath: string, opts: ExportOptions): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
const t = loaded.trajectory;
|
|
let body: string;
|
|
if (opts.format === "markdown") body = exportMarkdown(t);
|
|
else if (opts.format === "html") body = exportHtml(t);
|
|
else body = JSON.stringify(t, null, 2);
|
|
|
|
if (opts.format === "markdown" || opts.format === "json") {
|
|
const reportValidation = validateTrajectoryExportReport(
|
|
opts.format,
|
|
body,
|
|
t,
|
|
);
|
|
if (!reportValidation.ok) {
|
|
printValidationIssues(reportValidation.issues);
|
|
throw new Error(`export ${opts.format} report validation failed`);
|
|
}
|
|
}
|
|
|
|
if (opts.out) {
|
|
await fs.writeFile(opts.out, body, "utf8");
|
|
console.log(c.dim(`Wrote ${opts.out} (${body.length} bytes).`));
|
|
} else {
|
|
process.stdout.write(body);
|
|
if (!body.endsWith("\n")) process.stdout.write("\n");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: validate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ValidateOptions {
|
|
expectedStages?: string[];
|
|
expectedContexts?: string[];
|
|
json: boolean;
|
|
strictMessages: boolean;
|
|
}
|
|
|
|
function printValidationIssues(issues: ValidationIssue[]): void {
|
|
for (const issue of issues) {
|
|
const label =
|
|
issue.severity === "error" ? c.red("error") : c.yellow("warning");
|
|
console.error(`${label} ${issue.path}: ${issue.message}`);
|
|
}
|
|
}
|
|
|
|
async function cmdValidate(
|
|
idOrPath: string,
|
|
opts: ValidateOptions,
|
|
): Promise<void> {
|
|
const loaded = await loadTrajectory(idOrPath);
|
|
if (!loaded) {
|
|
console.error(c.red(`No trajectory found for "${idOrPath}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const result = validateTrajectory(loaded.trajectory, {
|
|
expectedStages: opts.expectedStages,
|
|
expectedContexts: opts.expectedContexts,
|
|
requireMessageArrays: opts.strictMessages,
|
|
});
|
|
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(result, null, 2));
|
|
process.stdout.write("\n");
|
|
} else {
|
|
const status = result.ok ? c.green("ok") : c.red("failed");
|
|
console.log(
|
|
`${c.bold(`Trajectory ${loaded.trajectory.trajectoryId}`)} validation ${status}`,
|
|
);
|
|
console.log(
|
|
`Stages: ${result.stageKinds.join(" -> ") || "—"} · model calls ${result.rollup.modelCallStages} · tools ${result.rollup.toolCallsExecuted} (${result.rollup.toolResultSuccesses} success, ${result.rollup.toolResultFailures} failed)`,
|
|
);
|
|
console.log(
|
|
`Tokens: ${result.rollup.totalPromptTokens} in, ${result.rollup.totalCompletionTokens} out, ${result.rollup.totalCacheReadTokens} cache-read, ${result.rollup.totalCacheCreationTokens} cache-created`,
|
|
);
|
|
console.log(
|
|
`Evaluators: ${result.rollup.evaluatorStages} (${result.rollup.evaluatorSuccesses} success, ${result.rollup.evaluatorFailures} failed) · Cost: ${formatUsd(result.rollup.totalCostUsd)}`,
|
|
);
|
|
if (result.selectedContexts.length > 0) {
|
|
console.log(`Contexts: ${result.selectedContexts.join(", ")}`);
|
|
}
|
|
if (result.issues.length > 0) {
|
|
console.log("");
|
|
printValidationIssues(result.issues);
|
|
}
|
|
}
|
|
|
|
if (!result.ok) process.exitCode = 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: compare
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function cmdCompare(
|
|
idA: string,
|
|
idB: string,
|
|
asJson: boolean,
|
|
): Promise<void> {
|
|
const loadedA = await loadTrajectory(idA);
|
|
const loadedB = await loadTrajectory(idB);
|
|
if (!loadedA || !loadedB) {
|
|
if (!loadedA) console.error(c.red(`No trajectory found for "${idA}".`));
|
|
if (!loadedB) console.error(c.red(`No trajectory found for "${idB}".`));
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const summary = compareTrajectories(loadedA.trajectory, loadedB.trajectory);
|
|
if (asJson) {
|
|
process.stdout.write(JSON.stringify(summary, null, 2));
|
|
process.stdout.write("\n");
|
|
return;
|
|
}
|
|
|
|
const fmtDelta = (n: number, suffix = "") =>
|
|
`${n >= 0 ? "+" : ""}${Number.isInteger(n) ? n : n.toFixed(6)}${suffix}`;
|
|
console.log(c.bold(`Compare ${summary.idA} -> ${summary.idB}`));
|
|
console.log(
|
|
`Cost: ${formatUsd(summary.a.totalCostUsd)} -> ${formatUsd(summary.b.totalCostUsd)} (${fmtDelta(summary.delta.totalCostUsd, " USD")})`,
|
|
);
|
|
console.log(
|
|
`Prompt tokens: ${summary.a.totalPromptTokens} -> ${summary.b.totalPromptTokens} (${fmtDelta(summary.delta.totalPromptTokens)})`,
|
|
);
|
|
console.log(
|
|
`Completion tokens: ${summary.a.totalCompletionTokens} -> ${summary.b.totalCompletionTokens} (${fmtDelta(summary.delta.totalCompletionTokens)})`,
|
|
);
|
|
console.log(
|
|
`Cache read tokens: ${summary.a.totalCacheReadTokens} -> ${summary.b.totalCacheReadTokens} (${fmtDelta(summary.delta.totalCacheReadTokens)})`,
|
|
);
|
|
console.log(
|
|
`Cache hit rate: ${(summary.cacheHitRateA * 100).toFixed(1)}% -> ${(summary.cacheHitRateB * 100).toFixed(1)}% (${fmtDelta(summary.cacheHitRateDelta * 100, " pp")})`,
|
|
);
|
|
console.log(
|
|
`Batching proxy: model calls ${summary.a.modelCallStages} -> ${summary.b.modelCallStages} (${fmtDelta(summary.estimatedBatchingDelta.modelCalls)}), planner iterations ${summary.a.plannerIterations} -> ${summary.b.plannerIterations} (${fmtDelta(summary.estimatedBatchingDelta.plannerIterations)}), stages ${loadedA.trajectory.stages.length} -> ${loadedB.trajectory.stages.length} (${fmtDelta(summary.estimatedBatchingDelta.stages)})`,
|
|
);
|
|
console.log(
|
|
`Tools: ${summary.a.toolCallsExecuted} -> ${summary.b.toolCallsExecuted} (${fmtDelta(summary.estimatedBatchingDelta.toolCalls)})`,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: aggregate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface AggregateOptions {
|
|
since?: number;
|
|
agent?: string;
|
|
}
|
|
|
|
async function cmdAggregate(opts: AggregateOptions): Promise<void> {
|
|
const files = await listTrajectoryFiles();
|
|
const trajectories: RecordedTrajectory[] = [];
|
|
for (const file of files) {
|
|
try {
|
|
const raw = await fs.readFile(file.filePath, "utf8");
|
|
const t = JSON.parse(raw) as RecordedTrajectory;
|
|
if (!Array.isArray(t.stages) || !t.metrics || !t.trajectoryId) continue;
|
|
if (opts.agent && t.agentId !== opts.agent) continue;
|
|
if (opts.since && (t.startedAt ?? 0) < opts.since) continue;
|
|
trajectories.push(t);
|
|
} catch {
|
|
// skip
|
|
}
|
|
}
|
|
|
|
if (trajectories.length === 0) {
|
|
console.log(c.dim("No trajectories matched."));
|
|
return;
|
|
}
|
|
|
|
const durations = trajectories
|
|
.map((t) => t.metrics?.totalLatencyMs ?? 0)
|
|
.sort((a, b) => a - b);
|
|
const median =
|
|
durations.length === 0
|
|
? 0
|
|
: (durations[Math.floor(durations.length / 2)] ?? 0);
|
|
const p95 = durations[Math.floor(durations.length * 0.95)] ?? 0;
|
|
const totalCost = trajectories.reduce((sum, traj) => {
|
|
const recorded = traj.metrics?.totalCostUsd;
|
|
if (typeof recorded === "number" && recorded > 0) return sum + recorded;
|
|
// Fallback: re-derive from per-stage usage when the recorder did not
|
|
// pre-compute. Lets us compare cost across runs even on older trajectories.
|
|
const derived = traj.stages.reduce((stageSum, stage) => {
|
|
if (stage.model?.usage) {
|
|
return (
|
|
stageSum +
|
|
computeCallCostUsd(stage.model.modelName, stage.model.usage)
|
|
);
|
|
}
|
|
return stageSum;
|
|
}, 0);
|
|
return sum + derived;
|
|
}, 0);
|
|
|
|
const cacheRates: number[] = [];
|
|
const failureCounts = new Map<string, number>();
|
|
let evaluatorTotal = 0;
|
|
let evaluatorOk = 0;
|
|
|
|
for (const t of trajectories) {
|
|
for (const stage of t.stages) {
|
|
if (stage.model?.usage && stage.model.usage.promptTokens > 0) {
|
|
cacheRates.push(
|
|
(stage.model.usage.cacheReadInputTokens ?? 0) /
|
|
Math.max(1, stage.model.usage.promptTokens),
|
|
);
|
|
}
|
|
if (stage.tool && !stage.tool.success) {
|
|
failureCounts.set(
|
|
stage.tool.name,
|
|
(failureCounts.get(stage.tool.name) ?? 0) + 1,
|
|
);
|
|
}
|
|
if (stage.evaluation) {
|
|
evaluatorTotal++;
|
|
if (stage.evaluation.success) evaluatorOk++;
|
|
}
|
|
}
|
|
}
|
|
const meanCache =
|
|
cacheRates.length === 0
|
|
? 0
|
|
: (cacheRates.reduce((a, b) => a + b, 0) / cacheRates.length) * 100;
|
|
|
|
const topFailures = Array.from(failureCounts.entries())
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5);
|
|
|
|
console.log(c.bold(`${trajectories.length} trajectories analyzed`));
|
|
console.log(
|
|
`Median duration: ${formatDuration(median)} · p95: ${formatDuration(p95)}`,
|
|
);
|
|
console.log(`Total cost: ${formatUsd(totalCost)}`);
|
|
console.log(`Average cache hit rate: ${meanCache.toFixed(1)}%`);
|
|
if (topFailures.length > 0) {
|
|
console.log(
|
|
`Top failing actions: ${topFailures.map(([name, n]) => `${name} (${n})`).join(", ")}`,
|
|
);
|
|
}
|
|
if (evaluatorTotal > 0) {
|
|
console.log(
|
|
`Evaluator success rate: ${((evaluatorOk / evaluatorTotal) * 100).toFixed(1)}%`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Subcommand: providers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ProviderRollupRow {
|
|
providerName: string;
|
|
calls: number;
|
|
tokens: number;
|
|
costUsd: number;
|
|
spans: number;
|
|
sha256: string;
|
|
}
|
|
|
|
function providerRows(trajectory: RecordedTrajectory): ProviderRollupRow[] {
|
|
const rows = new Map<string, ProviderRollupRow>();
|
|
for (const stage of trajectory.stages) {
|
|
const model = stage.model;
|
|
const attributions = model?.providerAttributions ?? [];
|
|
if (!model || attributions.length === 0) continue;
|
|
const totalProviderTokens = attributions.reduce(
|
|
(total, entry) => total + Math.max(0, entry.tokenCount || 0),
|
|
0,
|
|
);
|
|
for (const entry of attributions) {
|
|
const current = rows.get(entry.providerName) ?? {
|
|
providerName: entry.providerName,
|
|
calls: 0,
|
|
tokens: 0,
|
|
costUsd: 0,
|
|
spans: 0,
|
|
sha256: entry.sha256,
|
|
};
|
|
current.calls += 1;
|
|
current.tokens += Math.max(0, entry.tokenCount || 0);
|
|
if (
|
|
typeof model.costUsd === "number" &&
|
|
Number.isFinite(model.costUsd) &&
|
|
totalProviderTokens > 0
|
|
) {
|
|
current.costUsd +=
|
|
model.costUsd *
|
|
(Math.max(0, entry.tokenCount || 0) / totalProviderTokens);
|
|
}
|
|
if (
|
|
typeof entry.spanStart === "number" &&
|
|
typeof entry.spanEnd === "number"
|
|
) {
|
|
current.spans += 1;
|
|
}
|
|
rows.set(entry.providerName, current);
|
|
}
|
|
}
|
|
return [...rows.values()].sort(
|
|
(left, right) =>
|
|
right.tokens - left.tokens ||
|
|
left.providerName.localeCompare(right.providerName),
|
|
);
|
|
}
|
|
|
|
async function cmdProviders(id: string): Promise<void> {
|
|
const loaded = await loadTrajectory(id);
|
|
if (!loaded) throw new Error(`Trajectory not found: ${id}`);
|
|
const rows = providerRows(loaded.trajectory);
|
|
if (rows.length === 0) {
|
|
console.log(c.dim("No provider attribution records found."));
|
|
return;
|
|
}
|
|
const widths = {
|
|
provider: Math.max(8, ...rows.map((row) => row.providerName.length)),
|
|
calls: 5,
|
|
tokens: Math.max(6, ...rows.map((row) => String(row.tokens).length)),
|
|
cost: 10,
|
|
spans: 5,
|
|
sha: 12,
|
|
};
|
|
const header = `${"provider".padEnd(widths.provider)} ${"calls".padEnd(widths.calls)} ${"tokens".padEnd(widths.tokens)} ${"cost".padEnd(widths.cost)} ${"spans".padEnd(widths.spans)} sha256`;
|
|
console.log(c.bold(header));
|
|
console.log(c.dim("-".repeat(header.length)));
|
|
for (const row of rows) {
|
|
console.log(
|
|
`${row.providerName.padEnd(widths.provider)} ${String(row.calls).padEnd(widths.calls)} ${String(row.tokens).padEnd(widths.tokens)} ${formatUsd(row.costUsd).padEnd(widths.cost)} ${String(row.spans).padEnd(widths.spans)} ${row.sha256.slice(0, widths.sha)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Argv parsing (lightweight; no dependency)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface ParsedFlags {
|
|
positional: string[];
|
|
flags: Map<string, string | true>;
|
|
}
|
|
|
|
function parseArgs(argv: string[]): ParsedFlags {
|
|
const positional: string[] = [];
|
|
const flags = new Map<string, string | true>();
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const token = argv[i] ?? "";
|
|
if (token.startsWith("--")) {
|
|
const eq = token.indexOf("=");
|
|
if (eq >= 0) {
|
|
flags.set(token.slice(2, eq), token.slice(eq + 1));
|
|
} else {
|
|
const next = argv[i + 1];
|
|
if (next !== undefined && !next.startsWith("--")) {
|
|
flags.set(token.slice(2), next);
|
|
i++;
|
|
} else {
|
|
flags.set(token.slice(2), true);
|
|
}
|
|
}
|
|
} else {
|
|
positional.push(token);
|
|
}
|
|
}
|
|
return { positional, flags };
|
|
}
|
|
|
|
function flagString(
|
|
flags: Map<string, string | true>,
|
|
key: string,
|
|
): string | undefined {
|
|
const v = flags.get(key);
|
|
return typeof v === "string" ? v : undefined;
|
|
}
|
|
|
|
function flagInt(
|
|
flags: Map<string, string | true>,
|
|
key: string,
|
|
): number | undefined {
|
|
const v = flagString(flags, key);
|
|
if (v === undefined) return undefined;
|
|
const n = Number.parseInt(v, 10);
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
|
|
function flagDate(
|
|
flags: Map<string, string | true>,
|
|
key: string,
|
|
): number | undefined {
|
|
const v = flagString(flags, key);
|
|
if (v === undefined) return undefined;
|
|
const ms = Date.parse(v);
|
|
return Number.isFinite(ms) ? ms : undefined;
|
|
}
|
|
|
|
function flagStringList(
|
|
flags: Map<string, string | true>,
|
|
key: string,
|
|
): string[] | undefined {
|
|
const v = flagString(flags, key);
|
|
if (v === undefined) return undefined;
|
|
const values = v
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
return values.length > 0 ? values : undefined;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Help
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function printHelp(): void {
|
|
console.log(`trajectory <command> [args]
|
|
|
|
Commands:
|
|
list [--agent <id>] [--since <iso>] [--limit 20]
|
|
print <id> [--stage <id>] [--full]
|
|
stats <id>
|
|
failures <id>
|
|
diff <id> --stages a,b
|
|
replay <id> [--stage <id>]
|
|
export <id> --format markdown|html|json [--out <path>]
|
|
validate <id-or-path> [--expected-stages a,b] [--expected-contexts x,y] [--strict-messages] [--json]
|
|
compare <idA> <idB> [--json]
|
|
providers <id>
|
|
aggregate [--since <iso>] [--agent <id>]
|
|
|
|
Trajectories are read from \${ELIZA_TRAJECTORY_DIR} (default ./trajectories/).`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main(): Promise<void> {
|
|
const argv = process.argv.slice(2);
|
|
const cmd = argv[0];
|
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
printHelp();
|
|
return;
|
|
}
|
|
|
|
const { positional, flags } = parseArgs(argv.slice(1));
|
|
|
|
switch (cmd) {
|
|
case "list": {
|
|
await cmdList({
|
|
agent: flagString(flags, "agent"),
|
|
since: flagDate(flags, "since"),
|
|
limit: flagInt(flags, "limit"),
|
|
});
|
|
return;
|
|
}
|
|
case "print": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`print` requires a trajectory id");
|
|
await cmdPrint(id, {
|
|
stageId: flagString(flags, "stage"),
|
|
full: flags.has("full") && flags.get("full") !== "false",
|
|
});
|
|
return;
|
|
}
|
|
case "stats": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`stats` requires a trajectory id");
|
|
await cmdStats(id);
|
|
return;
|
|
}
|
|
case "failures": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`failures` requires a trajectory id");
|
|
await cmdFailures(id);
|
|
return;
|
|
}
|
|
case "diff": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`diff` requires a trajectory id");
|
|
const stages = flagString(flags, "stages");
|
|
if (!stages?.includes(",")) {
|
|
throw new Error("`diff` requires --stages a,b");
|
|
}
|
|
const [a, b] = stages.split(",");
|
|
if (!a || !b) throw new Error("--stages must be `a,b`");
|
|
await cmdDiff(id, { stages: [a, b] });
|
|
return;
|
|
}
|
|
case "replay": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`replay` requires a trajectory id");
|
|
await cmdReplay(id, flagString(flags, "stage"));
|
|
return;
|
|
}
|
|
case "export": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`export` requires a trajectory id");
|
|
const format = (flagString(flags, "format") ?? "markdown") as
|
|
| "markdown"
|
|
| "html"
|
|
| "json";
|
|
if (!["markdown", "html", "json"].includes(format)) {
|
|
throw new Error(`Unknown format: ${format}`);
|
|
}
|
|
await cmdExport(id, { format, out: flagString(flags, "out") });
|
|
return;
|
|
}
|
|
case "validate": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`validate` requires a trajectory id or path");
|
|
await cmdValidate(id, {
|
|
expectedStages: flagStringList(flags, "expected-stages"),
|
|
expectedContexts: flagStringList(flags, "expected-contexts"),
|
|
json: flags.has("json") && flags.get("json") !== "false",
|
|
strictMessages:
|
|
flags.has("strict-messages") &&
|
|
flags.get("strict-messages") !== "false",
|
|
});
|
|
return;
|
|
}
|
|
case "compare": {
|
|
const idA = positional[0];
|
|
const idB = positional[1];
|
|
if (!idA || !idB)
|
|
throw new Error("`compare` requires two trajectory ids or paths");
|
|
await cmdCompare(
|
|
idA,
|
|
idB,
|
|
flags.has("json") && flags.get("json") !== "false",
|
|
);
|
|
return;
|
|
}
|
|
case "providers": {
|
|
const id = positional[0];
|
|
if (!id) throw new Error("`providers` requires a trajectory id");
|
|
await cmdProviders(id);
|
|
return;
|
|
}
|
|
case "aggregate": {
|
|
await cmdAggregate({
|
|
since: flagDate(flags, "since"),
|
|
agent: flagString(flags, "agent"),
|
|
});
|
|
return;
|
|
}
|
|
default: {
|
|
console.error(c.red(`Unknown command: ${cmd}`));
|
|
printHelp();
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(c.red(`error: ${(err as Error).message}`));
|
|
process.exitCode = 1;
|
|
});
|