diff --git a/scripts/read-tool-stats.mjs b/scripts/read-tool-stats.mjs
index a2c288e4f..07bedd8f9 100755
--- a/scripts/read-tool-stats.mjs
+++ b/scripts/read-tool-stats.mjs
@@ -10,11 +10,13 @@ const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
const DEFAULT_ACTIVE_READ_TOOL_PATH = path.join(process.cwd(), "packages/coding-agent/src/core/tools/read.ts");
const DEFAULT_TOP = 20;
const CHART_WIDTH = 40;
+const REPORT_TIME_ZONE = "Europe/Berlin";
function parseArgs(argv) {
const options = {
sessionsDir: DEFAULT_SESSIONS_DIR,
json: false,
+ text: false,
includeRecords: false,
modelFilter: undefined,
top: DEFAULT_TOP,
@@ -29,6 +31,7 @@ function parseArgs(argv) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--json") options.json = true;
+ else if (arg === "--text") options.text = true;
else if (arg === "--include-records") options.includeRecords = true;
else if (arg === "--model") options.modelFilter = argv[++i];
else if (arg === "--top") {
@@ -60,7 +63,8 @@ Options:
--all-sessions Disable the automatic since filter
--auto-since-path
Use birth time of this file for the automatic since filter
--bucket Time bucket for trend chart (default: week)
- --json Print JSON summary instead of human report
+ --json Print JSON summary instead of HTML report
+ --text Print plain text report instead of HTML
--include-records Include raw records in JSON output
-h, --help Show this help
`);
@@ -79,21 +83,40 @@ function formatIso(ms) {
return new Date(ms).toISOString();
}
-function formatDay(ms) {
- return new Date(ms).toISOString().slice(0, 10);
+function getTimeZoneParts(ms) {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: REPORT_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ hourCycle: "h23",
+ weekday: "short",
+ }).formatToParts(new Date(ms));
+ return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
}
-function startOfUtcWeek(date) {
- const day = date.getUTCDay();
- const daysSinceMonday = day === 0 ? 6 : day - 1;
- const start = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
- return new Date(start - daysSinceMonday * 24 * 60 * 60 * 1000);
+function formatDay(ms) {
+ const parts = getTimeZoneParts(ms);
+ return `${parts.year}-${parts.month}-${parts.day}`;
+}
+
+function startOfReportTimeZoneWeek(ms) {
+ const parts = getTimeZoneParts(ms);
+ const dayIndex = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(parts.weekday ?? "Mon");
+ const localMidnightAsUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
+ return localMidnightAsUtc - Math.max(dayIndex, 0) * 24 * 60 * 60 * 1000;
}
function getTimeBucket(ms, bucket) {
if (!Number.isFinite(ms)) return "[unknown]";
if (bucket === "day") return formatDay(ms);
- return formatDay(startOfUtcWeek(new Date(ms)).getTime());
+ return formatDay(startOfReportTimeZoneWeek(ms));
+}
+
+function getHourOfDayBucket(ms) {
+ if (!Number.isFinite(ms)) return "[unknown]";
+ return `${getTimeZoneParts(ms).hour}:00`;
}
async function resolveAutoSinceMs(options) {
@@ -173,9 +196,13 @@ function summarizeTimeBuckets(records, bucket) {
}
function summarizeNormalizedTimeBuckets(records, bucket) {
+ return summarizeNormalizedTimeBucketsByKey(records, (record) => getTimeBucket(record.timestampMs, bucket));
+}
+
+function summarizeNormalizedTimeBucketsByKey(records, keyFn) {
const bucketGroups = new Map();
for (const record of records) {
- const bucketKey = getTimeBucket(record.timestampMs, bucket);
+ const bucketKey = keyFn(record);
if (!bucketGroups.has(bucketKey)) bucketGroups.set(bucketKey, []);
bucketGroups.get(bucketKey).push(record);
}
@@ -235,6 +262,8 @@ function buildSummary(records, meta, options) {
const providerStats = summarizeGroups(records, (record) => record.providerModel);
const timeStats = summarizeTimeBuckets(records, options.bucket);
const normalizedTimeStats = summarizeNormalizedTimeBuckets(records, options.bucket);
+ const timeOfDayStats = summarizeGroups(records, (record) => getHourOfDayBucket(record.timestampMs)).sort((a, b) => a.key.localeCompare(b.key));
+ const normalizedTimeOfDayStats = summarizeNormalizedTimeBucketsByKey(records, (record) => getHourOfDayBucket(record.timestampMs));
const timeStatsByProvider = providerStats.map((provider) => ({
providerModel: provider.key,
...provider,
@@ -246,6 +275,14 @@ function buildSummary(records, meta, options) {
records.filter((record) => record.providerModel === provider.key),
options.bucket
),
+ timeOfDayStats: summarizeGroups(
+ records.filter((record) => record.providerModel === provider.key),
+ (record) => getHourOfDayBucket(record.timestampMs)
+ ).sort((a, b) => a.key.localeCompare(b.key)),
+ normalizedTimeOfDayStats: summarizeNormalizedTimeBucketsByKey(
+ records.filter((record) => record.providerModel === provider.key),
+ (record) => getHourOfDayBucket(record.timestampMs)
+ ),
}));
return {
filters: { model: options.modelFilter ?? null, bucket: options.bucket },
@@ -269,14 +306,45 @@ function buildSummary(records, meta, options) {
providerStats,
timeStats,
normalizedTimeStats,
+ timeOfDayStats,
+ normalizedTimeOfDayStats,
timeStatsByProvider,
examples: records.slice(0, options.top),
};
}
+function buildHumanReport(summary) {
+ const lines = [];
+ const originalLog = console.log;
+ console.log = (line = "") => lines.push(String(line));
+ try {
+ printHumanReport(summary);
+ } finally {
+ console.log = originalLog;
+ }
+ return lines.join("\n") + "\n";
+}
+
+function escapeHtml(text) {
+ return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
+
+function printHtmlReport(summary) {
+ const text = buildHumanReport(summary);
+ console.log(`
+
+Read tool stats
+
+${escapeHtml(text)}`);
+}
+
function printHumanReport(summary) {
- const { scan, counts, timeStats, normalizedTimeStats, timeStatsByProvider, filters, examples } = summary;
+ const { scan, counts, timeStats, normalizedTimeStats, timeOfDayStats, normalizedTimeOfDayStats, timeStatsByProvider, filters } = summary;
console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`);
+ console.log(`Report timezone: ${REPORT_TIME_ZONE} (CET/CEST)`);
if (scan.since) {
console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`);
console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`);
@@ -295,6 +363,20 @@ function printHumanReport(summary) {
);
}
+ console.log("\nBy time of day");
+ for (const group of timeOfDayStats) {
+ console.log(
+ ` ${group.key} reads=${formatInt(group.reads).padStart(5)} full=${formatPercent(group.full, group.reads).padStart(6)} partial=${formatPercent(group.partial, group.reads).padStart(6)} ${bar(group.partial, group.reads)}`
+ );
+ }
+
+ console.log("\nBy time of day, session-normalized");
+ for (const group of normalizedTimeOfDayStats) {
+ console.log(
+ ` ${group.key} sessions=${formatInt(group.sessions).padStart(4)} reads/session=${formatRate(group.readsPerSession).padStart(5)} full/session=${formatRate(group.fullPerSession).padStart(5)} partial/session=${formatRate(group.partialPerSession).padStart(5)} medianSessionPartial=${group.medianSessionPartialRate === null ? "n/a" : formatPercent(group.medianSessionPartialRate, 1).padStart(6)} ${bar(group.medianSessionPartialRate ?? 0, 1)}`
+ );
+ }
+
console.log(`\nBy ${filters.bucket}, session-normalized`);
for (const group of normalizedTimeStats) {
console.log(
@@ -320,14 +402,18 @@ function printHumanReport(summary) {
` ${bucket.key} sessions=${formatInt(bucket.sessions).padStart(4)} reads/session=${formatRate(bucket.readsPerSession).padStart(5)} full/session=${formatRate(bucket.fullPerSession).padStart(5)} partial/session=${formatRate(bucket.partialPerSession).padStart(5)} medianSessionPartial=${bucket.medianSessionPartialRate === null ? "n/a" : formatPercent(bucket.medianSessionPartialRate, 1).padStart(6)} ${bar(bucket.medianSessionPartialRate ?? 0, 1)}`
);
}
- }
-
- console.log(`\nFirst ${formatInt(examples.length)} read calls`);
- for (let i = 0; i < examples.length; i++) {
- const example = examples[i];
- const range = example.mode === "full" ? "full" : `partial offset=${example.offset ?? "unset"} limit=${example.limit ?? "unset"}`;
- console.log(` ${i + 1}. ${example.providerModel} ${range}`);
- console.log(` path: ${example.path}`);
+ console.log(" By time of day");
+ for (const bucket of group.timeOfDayStats) {
+ console.log(
+ ` ${bucket.key} reads=${formatInt(bucket.reads).padStart(5)} full=${formatPercent(bucket.full, bucket.reads).padStart(6)} partial=${formatPercent(bucket.partial, bucket.reads).padStart(6)} ${bar(bucket.partial, bucket.reads)}`
+ );
+ }
+ console.log(" By time of day, session-normalized");
+ for (const bucket of group.normalizedTimeOfDayStats) {
+ console.log(
+ ` ${bucket.key} sessions=${formatInt(bucket.sessions).padStart(4)} reads/session=${formatRate(bucket.readsPerSession).padStart(5)} full/session=${formatRate(bucket.fullPerSession).padStart(5)} partial/session=${formatRate(bucket.partialPerSession).padStart(5)} medianSessionPartial=${bucket.medianSessionPartialRate === null ? "n/a" : formatPercent(bucket.medianSessionPartialRate, 1).padStart(6)} ${bar(bucket.medianSessionPartialRate ?? 0, 1)}`
+ );
+ }
}
if (scan.malformedLines > 0) {
@@ -406,7 +492,11 @@ async function main() {
console.log(JSON.stringify(options.includeRecords ? { summary, records: filteredRecords } : { summary }, null, 2));
return;
}
- printHumanReport(summary);
+ if (options.text) {
+ printHumanReport(summary);
+ return;
+ }
+ printHtmlReport(summary);
}
main().catch((error) => {
diff --git a/scripts/session-context-stats.mjs b/scripts/session-context-stats.mjs
new file mode 100755
index 000000000..e9ae425c3
--- /dev/null
+++ b/scripts/session-context-stats.mjs
@@ -0,0 +1,405 @@
+#!/usr/bin/env node
+
+import { createReadStream } from "node:fs";
+import { promises as fs } from "node:fs";
+import { homedir } from "node:os";
+import path from "node:path";
+import { createInterface } from "node:readline";
+
+const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
+const MODELS_GENERATED_PATH = path.join(process.cwd(), "packages/ai/src/models.generated.ts");
+const MODELS_CONFIG_PATH = path.join(homedir(), ".pi/agent/models.json");
+const REPORT_TIME_ZONE = "Europe/Berlin";
+const CHART_WIDTH = 40;
+
+function parseArgs(argv) {
+ const options = { sessionsDir: DEFAULT_SESSIONS_DIR, json: false, text: false, allSessions: false, since: undefined, modelFilter: undefined, modelPrefixes: [], bashContains: [], cwd: process.cwd(), help: false };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === "--help" || arg === "-h") options.help = true;
+ else if (arg === "--json") options.json = true;
+ else if (arg === "--text") options.text = true;
+ else if (arg === "--sessions-dir") options.sessionsDir = argv[++i];
+ else if (arg === "--since") options.since = argv[++i];
+ else if (arg === "--all-sessions") options.allSessions = true;
+ else if (arg === "--model") options.modelFilter = argv[++i];
+ else if (arg === "--model-prefix") options.modelPrefixes.push(argv[++i]);
+ else if (arg === "--bash-contains") options.bashContains.push(argv[++i]);
+ else if (arg === "--git-commit-or-push") options.bashContains.push("git commit", "git push");
+ else if (arg === "--cwd") options.cwd = argv[++i];
+ else if (arg === "--all-cwds") options.cwd = undefined;
+ else throw new Error(`Unknown argument: ${arg}`);
+ }
+ return options;
+}
+
+function printHelp() {
+ console.log(`Usage: node scripts/session-context-stats.mjs [options]
+
+Options:
+ --sessions-dir Sessions directory (default: ~/.pi/agent/sessions)
+ --model Filter provider/model by substring
+ --model-prefix Include provider/model prefixes, repeatable, e.g. openai-codex/
+ --bash-contains Include only sessions with bash tool calls containing text, repeatable
+ --git-commit-or-push Shortcut for --bash-contains "git commit" --bash-contains "git push"
+ --cwd Include only sessions whose cwd is this path (default: current cwd)
+ --all-cwds Include sessions from all cwd values
+ --since Only scan session files created at or after this ISO time
+ --all-sessions Scan all sessions (default already scans all)
+ --json Print JSON instead of HTML report
+ --text Print plain text instead of HTML report
+ -h, --help Show this help
+`);
+}
+
+function parseSessionFileTimestamp(sessionFile) {
+ const rawTimestamp = path.basename(sessionFile).split("_")[0];
+ if (!rawTimestamp) return null;
+ const ms = Date.parse(rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z"));
+ return Number.isFinite(ms) ? ms : null;
+}
+
+function getTimeZoneParts(ms) {
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: REPORT_TIME_ZONE,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).formatToParts(new Date(ms));
+ return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
+}
+
+function formatDay(ms) {
+ const parts = getTimeZoneParts(ms);
+ return `${parts.year}-${parts.month}-${parts.day}`;
+}
+
+function formatInt(value) {
+ return new Intl.NumberFormat("en-US").format(Math.round(value));
+}
+
+function formatNumber(value) {
+ return Number.isFinite(value) ? value.toFixed(2) : "n/a";
+}
+
+function formatPercent(value) {
+ return Number.isFinite(value) ? `${value.toFixed(1)}%` : "n/a";
+}
+
+function bar(percent) {
+ const clamped = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0;
+ const filled = Math.round((clamped / 100) * CHART_WIDTH);
+ return `${"█".repeat(filled)}${"░".repeat(CHART_WIDTH - filled)}`;
+}
+
+function median(values) {
+ const finite = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
+ if (finite.length === 0) return null;
+ const middle = Math.floor(finite.length / 2);
+ return finite.length % 2 === 0 ? (finite[middle - 1] + finite[middle]) / 2 : finite[middle];
+}
+
+async function* walkJsonlFiles(dir) {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) yield* walkJsonlFiles(fullPath);
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) yield fullPath;
+ }
+}
+
+async function loadContextWindows() {
+ const windows = new Map();
+ const sources = [];
+ let text = "";
+ try {
+ text = await fs.readFile(MODELS_GENERATED_PATH, "utf8");
+ sources.push(MODELS_GENERATED_PATH);
+ } catch {
+ // Optional in non-repo usage.
+ }
+ const providerRegex = /\n\t"([^"]+)": \{([\s\S]*?\n\t)\},/g;
+ let providerMatch;
+ while ((providerMatch = providerRegex.exec(text)) !== null) {
+ const provider = providerMatch[1];
+ const body = providerMatch[2];
+ const modelRegex = /\n\t\t"([^"]+)": \{[\s\S]*?contextWindow: (\d+),/g;
+ let modelMatch;
+ while ((modelMatch = modelRegex.exec(body)) !== null) {
+ windows.set(`${provider}/${modelMatch[1]}`, Number(modelMatch[2]));
+ }
+ }
+
+ try {
+ const config = JSON.parse(await fs.readFile(MODELS_CONFIG_PATH, "utf8"));
+ sources.push(MODELS_CONFIG_PATH);
+ const providers = config?.providers && typeof config.providers === "object" ? config.providers : {};
+ for (const [providerName, provider] of Object.entries(providers)) {
+ const overrides = provider?.modelOverrides && typeof provider.modelOverrides === "object" ? provider.modelOverrides : {};
+ for (const [modelId, override] of Object.entries(overrides)) {
+ if (typeof override?.contextWindow === "number") windows.set(`${providerName}/${modelId}`, override.contextWindow);
+ }
+ if (Array.isArray(provider?.models)) {
+ for (const model of provider.models) {
+ if (typeof model?.id === "string" && typeof model.contextWindow === "number") windows.set(`${providerName}/${model.id}`, model.contextWindow);
+ }
+ }
+ }
+ } catch {
+ // Optional user config.
+ }
+
+ return { windows, sources };
+}
+
+function contextTokens(usage) {
+ if (!usage || typeof usage !== "object") return null;
+ const totalTokens = Number(usage.totalTokens ?? 0);
+ if (Number.isFinite(totalTokens) && totalTokens > 0) return totalTokens;
+ const input = Number(usage.input ?? 0);
+ const output = Number(usage.output ?? 0);
+ const cacheRead = Number(usage.cacheRead ?? 0);
+ const cacheWrite = Number(usage.cacheWrite ?? 0);
+ const value = input + output + cacheRead + cacheWrite;
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+async function scanSessions(sessionsDir, sinceMs, contextWindows, cwdFilter) {
+ const windows = contextWindows.windows;
+ const sessions = [];
+ const meta = { sessionsDir, sessionFilesScanned: 0, sessionFilesIncluded: 0, sessionFilesSkippedOlderThanSince: 0, malformedLines: 0 };
+ for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
+ meta.sessionFilesScanned++;
+ const fileTimestampMs = parseSessionFileTimestamp(sessionFile);
+ if (sinceMs !== null && fileTimestampMs !== null && fileTimestampMs < sinceMs) {
+ meta.sessionFilesSkippedOlderThanSince++;
+ continue;
+ }
+ meta.sessionFilesIncluded++;
+ const session = {
+ sessionFile,
+ cwd: null,
+ startMs: fileTimestampMs ?? 0,
+ endMs: fileTimestampMs ?? 0,
+ providerModel: "[unknown]/[unknown]",
+ assistantMessages: 0,
+ userMessages: 0,
+ compactions: 0,
+ seenCompaction: false,
+ maxPromptTokens: null,
+ preFirstCompactionTokens: null,
+ maxContextUsagePercent: null,
+ preFirstCompactionUsagePercent: null,
+ contextWindow: null,
+ bashCommands: [],
+ over80: false,
+ over90: false,
+ over100: false,
+ };
+ const input = createReadStream(sessionFile, { encoding: "utf8" });
+ const rl = createInterface({ input, crlfDelay: Infinity });
+ for await (const line of rl) {
+ if (!line.trim()) continue;
+ let entry;
+ try {
+ entry = JSON.parse(line);
+ } catch {
+ meta.malformedLines++;
+ continue;
+ }
+ if (entry.type === "session" && typeof entry.cwd === "string") session.cwd = entry.cwd;
+ const entryMs = Date.parse(entry.timestamp ?? "");
+ if (Number.isFinite(entryMs)) {
+ if (!session.startMs || entryMs < session.startMs) session.startMs = entryMs;
+ if (!session.endMs || entryMs > session.endMs) session.endMs = entryMs;
+ }
+ if (entry.type === "compaction") {
+ session.compactions++;
+ if (typeof entry.tokensBefore === "number") {
+ session.maxPromptTokens = Math.max(session.maxPromptTokens ?? 0, entry.tokensBefore);
+ if (!session.seenCompaction) session.preFirstCompactionTokens = entry.tokensBefore;
+ }
+ session.seenCompaction = true;
+ continue;
+ }
+ if (entry.type !== "message" || !entry.message) continue;
+ const message = entry.message;
+ if (message.role === "assistant" && Array.isArray(message.content)) {
+ for (const block of message.content) {
+ if (block?.type !== "toolCall" || block.name !== "bash") continue;
+ const command = typeof block.arguments?.command === "string" ? block.arguments.command : "";
+ if (command) session.bashCommands.push(command);
+ }
+ }
+ if (message.role === "user") session.userMessages++;
+ if (message.role !== "assistant") continue;
+ session.assistantMessages++;
+ const provider = typeof message.provider === "string" ? message.provider : "[unknown]";
+ const model = typeof message.model === "string" ? message.model : "[unknown]";
+ session.providerModel = `${provider}/${model}`;
+ const contextWindow = windows.get(session.providerModel) ?? null;
+ if (contextWindow !== null) session.contextWindow = contextWindow;
+ const tokens = contextTokens(message.usage);
+ if (tokens !== null) {
+ session.maxPromptTokens = Math.max(session.maxPromptTokens ?? 0, tokens);
+ if (!session.seenCompaction) session.preFirstCompactionTokens = tokens;
+ }
+ }
+ if (session.maxPromptTokens !== null && session.contextWindow !== null) {
+ session.maxContextUsagePercent = (session.maxPromptTokens / session.contextWindow) * 100;
+ session.over80 = session.maxContextUsagePercent >= 80;
+ session.over90 = session.maxContextUsagePercent >= 90;
+ session.over100 = session.maxContextUsagePercent >= 100;
+ }
+ if (session.preFirstCompactionTokens !== null && session.contextWindow !== null) {
+ session.preFirstCompactionUsagePercent = (session.preFirstCompactionTokens / session.contextWindow) * 100;
+ }
+ if (!cwdFilter || path.resolve(session.cwd ?? "") === cwdFilter) sessions.push(session);
+ }
+ return { sessions, meta };
+}
+
+function summarizeGroups(sessions, keyFn) {
+ const groups = new Map();
+ for (const session of sessions) {
+ const key = keyFn(session);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(session);
+ }
+ return [...groups.entries()].map(([key, group]) => summarizeSessionGroup(key, group)).sort((a, b) => a.key.localeCompare(b.key));
+}
+
+function summarizeSessionGroup(key, group) {
+ const withUsage = group.filter((session) => session.maxContextUsagePercent !== null);
+ const withTokens = group.filter((session) => session.maxPromptTokens !== null);
+ const withPreCompactionUsage = group.filter((session) => session.preFirstCompactionUsagePercent !== null);
+ const compactions = group.filter((session) => session.compactions > 0).length;
+ const totalCompactions = group.reduce((sum, session) => sum + session.compactions, 0);
+ const contextWindows = [...new Set(group.map((session) => session.contextWindow).filter((value) => value !== null))].sort((a, b) => a - b);
+ return {
+ key,
+ sessions: group.length,
+ contextWindows,
+ assistantMessages: group.reduce((sum, session) => sum + session.assistantMessages, 0),
+ avgTurns: group.reduce((sum, session) => sum + session.userMessages, 0) / group.length,
+ avgDurationMinutes: group.reduce((sum, session) => sum + Math.max(0, session.endMs - session.startMs) / 60000, 0) / group.length,
+ avgMaxPromptTokens: withTokens.length === 0 ? null : withTokens.reduce((sum, session) => sum + (session.maxPromptTokens ?? 0), 0) / withTokens.length,
+ medianMaxPromptTokens: median(withTokens.map((session) => session.maxPromptTokens)),
+ avgMaxContextUsagePercent: withUsage.length === 0 ? null : withUsage.reduce((sum, session) => sum + (session.maxContextUsagePercent ?? 0), 0) / withUsage.length,
+ medianMaxContextUsagePercent: median(withUsage.map((session) => session.maxContextUsagePercent)),
+ medianPreFirstCompactionUsagePercent: median(withPreCompactionUsage.map((session) => session.preFirstCompactionUsagePercent)),
+ contextKnownSessions: withUsage.length,
+ sessionsWithCompaction: compactions,
+ totalCompactions,
+ compactionRate: (compactions / group.length) * 100,
+ over80: group.filter((session) => session.over80).length,
+ over90: group.filter((session) => session.over90).length,
+ over100: group.filter((session) => session.over100).length,
+ };
+}
+
+function buildSummary(sessions, meta, options) {
+ const lowerPrefixes = options.modelPrefixes.map((prefix) => prefix.toLowerCase());
+ const bashContains = options.bashContains.map((text) => text.toLowerCase());
+ const filtered = sessions.filter((session) => {
+ const providerModel = session.providerModel.toLowerCase();
+ if (options.modelFilter && !providerModel.includes(options.modelFilter.toLowerCase())) return false;
+ if (lowerPrefixes.length > 0 && !lowerPrefixes.some((prefix) => providerModel.startsWith(prefix))) return false;
+ if (bashContains.length > 0) {
+ const commands = session.bashCommands.map((command) => command.toLowerCase());
+ if (!bashContains.some((text) => commands.some((command) => command.includes(text)))) return false;
+ }
+ return true;
+ });
+ return {
+ filters: { model: options.modelFilter ?? null, modelPrefixes: options.modelPrefixes, bashContains: options.bashContains, cwd: options.cwd ? path.resolve(options.cwd) : null },
+ scan: { ...meta, timezone: REPORT_TIME_ZONE },
+ totals: summarizeSessionGroup("total", filtered),
+ byDay: summarizeGroups(filtered, (session) => formatDay(session.startMs)),
+ byModel: summarizeGroups(filtered, (session) => session.providerModel).sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key)),
+ byModelDay: summarizeGroups(filtered, (session) => session.providerModel).sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key)).map((model) => ({
+ ...model,
+ byDay: summarizeGroups(
+ filtered.filter((session) => session.providerModel === model.key),
+ (session) => formatDay(session.startMs)
+ ),
+ })),
+ };
+}
+
+function lineForGroup(group, indent = " ") {
+ return `${indent}${group.key} sessions=${formatInt(group.sessions).padStart(4)} avgTurns=${formatNumber(group.avgTurns).padStart(5)} avgMin=${formatNumber(group.avgDurationMinutes).padStart(6)} avgMaxTok=${group.avgMaxPromptTokens === null ? "n/a" : formatInt(group.avgMaxPromptTokens).padStart(7)} medMaxCtx=${group.medianMaxContextUsagePercent === null ? "n/a" : formatPercent(group.medianMaxContextUsagePercent).padStart(6)} medPreCompactCtx=${group.medianPreFirstCompactionUsagePercent === null ? "n/a" : formatPercent(group.medianPreFirstCompactionUsagePercent).padStart(6)} avgCtx=${group.avgMaxContextUsagePercent === null ? "n/a" : formatPercent(group.avgMaxContextUsagePercent).padStart(6)} compact=${formatPercent(group.compactionRate).padStart(6)} over90=${formatInt(group.over90).padStart(3)} ${bar(group.medianMaxContextUsagePercent ?? 0)}`;
+}
+
+function buildTextReport(summary) {
+ const lines = [];
+ lines.push(`Scanned ${formatInt(summary.scan.sessionFilesIncluded)} session files in ${summary.scan.sessionsDir}`);
+ lines.push(`Report timezone: ${summary.scan.timezone} (CET/CEST)`);
+ lines.push(`Context window sources: ${summary.scan.contextWindowSources.join(", ") || "none"}`);
+ if (summary.filters.model) lines.push(`Filters: model contains "${summary.filters.model}"`);
+ if (summary.filters.modelPrefixes.length > 0) lines.push(`Filters: model prefixes = ${summary.filters.modelPrefixes.join(", ")}`);
+ if (summary.filters.bashContains.length > 0) lines.push(`Filters: bash contains any of = ${summary.filters.bashContains.join(", ")}`);
+ if (summary.filters.cwd) lines.push(`Filters: cwd = ${summary.filters.cwd}`);
+ lines.push("Context usage parses full session JSONL files. max context uses max assistant usage.totalTokens per session, falling back to input + output + cacheRead + cacheWrite, plus compaction tokensBefore. medPreCompactCtx uses the last assistant usage before the first compaction, or the first compaction tokensBefore when present, divided by model contextWindow from packages/ai/src/models.generated.ts when known.");
+ lines.push("");
+ lines.push("Totals");
+ lines.push(lineForGroup(summary.totals));
+ lines.push("");
+ lines.push("By day");
+ for (const group of summary.byDay) lines.push(lineForGroup(group));
+ lines.push("");
+ lines.push("By model");
+ for (const group of summary.byModel) lines.push(lineForGroup(group));
+ lines.push("");
+ lines.push("By model, then by day");
+ for (const model of summary.byModelDay) {
+ lines.push("");
+ const contextWindowLabel = model.contextWindows.length === 0 ? "unknown" : model.contextWindows.map((value) => formatInt(value)).join(", ");
+ lines.push(`${model.key} contextWindow=${contextWindowLabel}`);
+ lines.push(lineForGroup(model, " total "));
+ for (const group of model.byDay) lines.push(lineForGroup(group, " "));
+ }
+ if (summary.scan.malformedLines > 0) {
+ lines.push("");
+ lines.push(`Malformed lines skipped: ${formatInt(summary.scan.malformedLines)}`);
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function escapeHtml(text) {
+ return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
+}
+
+function printHtmlReport(summary) {
+ console.log(`
+
+Session context stats
+
+${escapeHtml(buildTextReport(summary))}`);
+}
+
+async function main() {
+ const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ return;
+ }
+ const sinceMs = options.since ? Date.parse(options.since) : null;
+ if (options.since && !Number.isFinite(sinceMs)) throw new Error(`Invalid --since value: ${options.since}`);
+ const contextWindows = await loadContextWindows();
+ const cwdFilter = options.cwd ? path.resolve(options.cwd) : undefined;
+ const { sessions, meta } = await scanSessions(path.resolve(options.sessionsDir), sinceMs, contextWindows, cwdFilter);
+ const summary = buildSummary(sessions, { ...meta, contextWindowSources: contextWindows.sources }, options);
+ if (options.json) console.log(JSON.stringify(summary, null, 2));
+ else if (options.text) console.log(buildTextReport(summary));
+ else printHtmlReport(summary);
+}
+
+main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});