agent-ecosystem/src/renderer/utils/sessionAnalyzer.ts
Paul Holstein ec9b3412fa refactor: wire session analyzer and CostSection to shared pricing
Replace the hardcoded MODEL_PRICING table and costUsd() function in
sessionAnalyzer.ts with calculateMessageCost() and getDisplayPricing()
from the shared pricing module. Re-export getDisplayPricing as getPricing
for backward compat with CostSection. Replace the ModelPricing interface
with a type re-export of DisplayPricing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:10:42 -05:00

1346 lines
44 KiB
TypeScript

/**
* Session analyzer — TypeScript port of scripts/analyze-session.py.
*
* Takes a SessionDetail (already parsed by the main process) and produces
* a SessionReport with structured metrics, cost analysis, friction signals,
* conversation tree analysis, idle gap detection, and more.
*
* Runs entirely in the renderer process — no IPC needed.
*/
import {
computeCacheEfficiencyAssessment,
computeCacheRatioAssessment,
computeCostPerCommitAssessment,
computeCostPerLineAssessment,
computeIdleAssessment,
computeOverheadAssessment,
computeRedundancyAssessment,
computeSubagentCostShareAssessment,
computeThrashingAssessment,
computeToolHealthAssessment,
detectModelMismatch,
detectSwitchPattern,
} from '@renderer/utils/reportAssessments';
import { calculateMessageCost } from '@shared/utils/pricing';
import type {
AgentTreeNode,
FrictionCorrection,
GitCommit,
IdleGap,
KeyEvent,
ModelSwitch,
ModelTokenStats,
OutOfScopeFindings,
SessionReport,
SkillInvocation,
SubagentBasicEntry,
SubagentEntry,
TestSnapshot,
ThinkingBlockAnalysis,
ToolError,
ToolSuccessRate,
UserQuestion,
} from '@renderer/types/sessionReport';
import type {
ContentBlock,
ParsedMessage,
Process,
SessionDetail,
TextContent,
ThinkingContent,
ToolCall,
} from '@shared/types';
// Re-export getDisplayPricing as getPricing for backward compat with CostSection
export { getDisplayPricing as getPricing } from '@shared/utils/pricing';
// =============================================================================
// Helpers
// =============================================================================
function isTextBlock(block: ContentBlock): block is TextContent {
return block.type === 'text';
}
function isThinkingBlock(block: ContentBlock): block is ThinkingContent {
return block.type === 'thinking';
}
function extractTextContent(msg: ParsedMessage): string {
const { content } = msg;
if (typeof content === 'string') return content;
if (Array.isArray(content)) {
return content
.filter(isTextBlock)
.map((block) => block.text)
.join(' ');
}
return '';
}
function formatDuration(totalSeconds: number): string {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = Math.floor(totalSeconds % 60);
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${m}:${String(s).padStart(2, '0')}`;
}
// Friction keyword patterns
const FRICTION_PATTERNS: [RegExp, string][] = [
[/\bno,/i, 'no,'],
[/\bwrong\b/i, 'wrong'],
[/\bactually\b/i, 'actually'],
[/\bundo\b/i, 'undo'],
[/\brevert\b/i, 'revert'],
[/that's not\b/i, "that's not"],
[/\binstead,/i, 'instead,'],
[/\bwait,/i, 'wait,'],
[/\bnevermind\b/i, 'nevermind'],
[/I don't want\b/i, "I don't want"],
];
// Permission denial keywords (case-insensitive substring match)
const PERMISSION_KEYWORDS = [
'permission denied',
'not allowed',
'requires approval',
'cannot execute',
'access denied',
'operation not permitted',
'eacces',
'eperm',
'user rejected',
'user denied',
'needs_user_approval',
];
function isPermissionDenial(text: string): boolean {
const lower = text.toLowerCase();
return PERMISSION_KEYWORDS.some((kw) => lower.includes(kw));
}
/**
* Extract a number immediately before a keyword in text.
* E.g., extractNumberBefore("42 passed", "passed") => 42
*/
function extractNumberBefore(text: string, keyword: string): number | null {
const idx = text.toLowerCase().indexOf(keyword.toLowerCase());
if (idx <= 0) return null;
const before = text.slice(Math.max(0, idx - 15), idx).trim();
const parts = before.split(/\s+/);
const numStr = parts[parts.length - 1];
if (numStr && /^\d+$/.test(numStr)) return parseInt(numStr, 10);
return null;
}
/**
* Parse test summary from command output.
* Returns [passed, failed] or null if no match.
*/
function parseTestSummary(text: string): [number, number] | null {
// Try "passed"/"failed" keywords — treat missing count as 0
// (runners often omit "0 failed" when all tests pass)
const passed = extractNumberBefore(text, ' passed');
const failed = extractNumberBefore(text, ' failed');
if (passed != null || failed != null) return [passed ?? 0, failed ?? 0];
// Try "passing"/"failing" keywords (mocha-style)
const passing = extractNumberBefore(text, ' passing');
const failing = extractNumberBefore(text, ' failing');
if (passing != null || failing != null) return [passing ?? 0, failing ?? 0];
return null;
}
// Thinking block analysis signals
const THINKING_SIGNALS: Record<string, RegExp> = {
alternatives: /\balternative(?:ly|s)?\b|\binstead\b|\bother approach\b|\bcould also\b/i,
uncertainty: /\bnot sure\b|\buncertain\b|\bmight be\b|\bpossibly\b|\bI think\b.*\bbut\b/i,
errors_noticed: /\bbug\b|\berror\b|\bwrong\b|\bincorrect\b|\bfail\b|\bbroken\b/i,
planning: /\bfirst.*then\b|\bstep \d\b|\bplan\b|\bapproach\b|\bstrategy\b/i,
direction_change: /\bwait\b|\bactually\b|\bon second thought\b|\blet me reconsider\b|\bhmm\b/i,
};
// "Work" tools (non-Skill) for startup overhead detection
const NON_SKILL_TOOLS = new Set([
'Read',
'Write',
'Edit',
'Bash',
'Grep',
'Glob',
'Task',
'WebFetch',
'WebSearch',
'NotebookEdit',
]);
// =============================================================================
// Main Analyzer
// =============================================================================
export function analyzeSession(detail: SessionDetail): SessionReport {
const { session, messages } = detail;
// --- Session Overview ---
const timestamps = messages.filter((m) => m.timestamp).map((m) => m.timestamp);
const firstTs = timestamps.length > 0 ? timestamps[0] : null;
const lastTs = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null;
const durationMs = firstTs && lastTs ? lastTs.getTime() - firstTs.getTime() : 0;
const durationSeconds = durationMs / 1000;
// Context consumption interpretation
const ctxConsumption = session.contextConsumption ?? 0;
let ctxConsumptionPct: number | null = null;
let ctxAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null = null;
if (ctxConsumption <= 1) {
ctxConsumptionPct = ctxConsumption ? Math.round(ctxConsumption * 1000) / 10 : 0;
if (ctxConsumption > 0.8) ctxAssessment = 'critical';
else if (ctxConsumption > 0.6) ctxAssessment = 'high';
else if (ctxConsumption > 0.4) ctxAssessment = 'moderate';
else ctxAssessment = 'healthy';
}
// ===================================================================
// SINGLE-PASS ACCUMULATORS
// ===================================================================
// Token usage by model
const modelStats = new Map<string, ModelTokenStats>();
const getModelStats = (model: string): ModelTokenStats => {
let stats = modelStats.get(model);
if (!stats) {
stats = {
apiCalls: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreation: 0,
cacheRead: 0,
costUsd: 0,
};
modelStats.set(model, stats);
}
return stats;
};
// Cache economics
let totalCacheCreation = 0;
let totalCacheRead = 0;
let coldStartDetected = false;
let firstAssistantWithUsageSeen = false;
// Message type counts
const typeCounts = new Map<string, number>();
// Tool usage counts
const toolCounts = new Map<string, number>();
// Tool call index: toolUseId -> [messageIndex, toolCall]
const toolCallIndex = new Map<string, [number, ToolCall]>();
// Tool errors
const errors: ToolError[] = [];
const errorsByTool = new Map<string, number>();
// Permission denials
const permissionDenials: ToolError[] = [];
// Key events
const keyEvents: KeyEvent[] = [];
// Thinking blocks
let thinkingCount = 0;
const thinkingAnalysis: ThinkingBlockAnalysis[] = [];
// Git branches
const branches = new Set<string>();
// Friction signals
const corrections: FrictionCorrection[] = [];
let userMessageCount = 0;
// Thrashing detection
const bashPrefixGroups = new Map<string, number>();
const fileEditIndices = new Map<string, number[]>();
// Startup overhead
let firstWorkToolSeen = false;
let startupMessages = 0;
let startupTokens = 0;
// Token density timeline
const assistantMsgData: [Date, number][] = [];
// Conversation tree
const uuidToIdx = new Map<string, number>();
const parentMap = new Map<string, string | null>();
let sidechainCount = 0;
const childrenMap = new Map<string, string[]>();
// Idle gap detection
let lastAssistantTs: Date | null = null;
const idleGaps: IdleGap[] = [];
const IDLE_THRESHOLD_SEC = 60;
// Model switch detection
let lastModel: string | null = null;
const modelSwitches: ModelSwitch[] = [];
// Working directory tracking
const cwdSet = new Set<string>();
const cwdChanges: { from: string; to: string; messageIndex: number }[] = [];
let lastCwd: string | null = null;
// Test progression
const testSnapshots: TestSnapshot[] = [];
// Cost tracking
let parentCost = 0;
// Git activity
const gitCommits: GitCommit[] = [];
let gitPushCount = 0;
const gitBranchCreations: string[] = [];
let linesAddedTotal = 0;
let linesRemovedTotal = 0;
// File read redundancy
const fileReadCounts = new Map<string, number>();
// First user message length
let firstUserMessageLength = 0;
let firstUserSeen = false;
// Skills invoked
const skillsInvoked: SkillInvocation[] = [];
// Bash commands
const bashCmds: string[] = [];
// Subagents list (backward compat)
const subagentsList: SubagentBasicEntry[] = [];
// Lifecycle tasks
const lifecycleTasks: string[] = [];
// User questions
const userQuestions: UserQuestion[] = [];
// Out-of-scope findings
const OUT_OF_SCOPE_KEYWORDS = [
'pre-existing',
'out of scope',
'tech debt',
'anti-pattern',
'existed before',
];
const outOfScopeFindings: OutOfScopeFindings[] = [];
// Agent tree metadata
const agentTreeNodes: AgentTreeNode[] = [];
// Compact summary count
let compactSummaryCount = 0;
// ===================================================================
// SINGLE PASS
// ===================================================================
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
const msgType = m.type ?? 'unknown';
typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1);
const msgUuid = m.uuid ?? '';
const msgParent = m.parentUuid ?? '';
const msgTs = m.timestamp;
// --- Conversation tree ---
if (msgUuid) {
uuidToIdx.set(msgUuid, i);
parentMap.set(msgUuid, msgParent || null);
if (msgParent) {
const children = childrenMap.get(msgParent);
if (children) children.push(msgUuid);
else childrenMap.set(msgParent, [msgUuid]);
}
}
if (m.isSidechain) sidechainCount++;
// --- Working directory tracking ---
const msgCwd = m.cwd ?? '';
if (msgCwd) {
cwdSet.add(msgCwd);
if (lastCwd && msgCwd !== lastCwd) {
cwdChanges.push({ from: lastCwd, to: msgCwd, messageIndex: i });
}
lastCwd = msgCwd;
}
// --- Token usage, cache economics, and cost ---
// Skip sidechain messages to avoid double-counting (subagent costs are
// accounted for separately via processSubagentCost).
if (m.usage && m.model && !m.isSidechain && m.model !== '<synthetic>') {
const model = m.model;
const u = m.usage;
const inpTok = u.input_tokens ?? 0;
const outTok = u.output_tokens ?? 0;
const cc = u.cache_creation_input_tokens ?? 0;
const cr = u.cache_read_input_tokens ?? 0;
const stats = getModelStats(model);
stats.apiCalls += 1;
stats.inputTokens += inpTok;
stats.outputTokens += outTok;
stats.cacheCreation += cc;
stats.cacheRead += cr;
const callCost = calculateMessageCost(model, inpTok, outTok, cr, cc);
stats.costUsd += callCost;
parentCost += callCost;
totalCacheCreation += cc;
totalCacheRead += cr;
// Cold start detection
if (msgType === 'assistant' && !firstAssistantWithUsageSeen) {
firstAssistantWithUsageSeen = true;
if (cc > 0 && cr === 0) coldStartDetected = true;
}
}
// --- Git branches ---
if (m.gitBranch) branches.add(m.gitBranch);
// --- Compact summaries ---
if (m.isCompactSummary) compactSummaryCount++;
// --- Agent tree metadata ---
if (m.agentId) {
// agentType/teamName/parentToolUseId may exist on raw data but not typed in ParsedMessage
const raw = m as unknown as Record<string, unknown>;
agentTreeNodes.push({
agentId: m.agentId,
agentType: (raw.agentType as string) ?? 'unknown',
teamName: (raw.teamName as string) ?? '',
parentToolUseId: (raw.parentToolUseId as string) ?? '',
messageIndex: i,
});
}
// --- Thinking blocks (with content analysis) ---
if (Array.isArray(m.content)) {
for (const block of m.content) {
if (isThinkingBlock(block)) {
thinkingCount++;
const thinkText = block.thinking ?? '';
const signalsFound: Record<string, boolean> = {};
for (const [signalName, pattern] of Object.entries(THINKING_SIGNALS)) {
if (pattern.test(thinkText)) signalsFound[signalName] = true;
}
if (Object.keys(signalsFound).length > 0 || thinkingCount <= 5) {
thinkingAnalysis.push({
messageIndex: i,
preview: thinkText.slice(0, 200).replace(/\n/g, ' ').trim(),
charLength: thinkText.length,
signals: signalsFound,
});
}
}
}
}
// --- Model switch detection ---
if (msgType === 'assistant' && m.model) {
const currentModel = m.model;
if (lastModel && currentModel !== lastModel) {
modelSwitches.push({
from: lastModel,
to: currentModel,
messageIndex: i,
timestamp: msgTs ?? null,
});
}
lastModel = currentModel;
}
// --- Idle gap detection ---
if (msgType === 'assistant' && msgTs) {
lastAssistantTs = msgTs;
}
if (msgType === 'user' && msgTs && lastAssistantTs) {
const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000;
if (gap > IDLE_THRESHOLD_SEC) {
idleGaps.push({
gapSeconds: Math.round(gap * 10) / 10,
gapHuman: formatDuration(Math.floor(gap)),
afterMessageIndex: i,
});
}
}
// --- First user message length (prompt quality) ---
if (msgType === 'user' && !firstUserSeen && !m.isMeta) {
const contentText = extractTextContent(m);
if (contentText.trim()) {
firstUserMessageLength = contentText.length;
firstUserSeen = true;
}
}
// --- Tool calls (assistant messages) ---
for (const tc of m.toolCalls) {
const toolName = tc.name;
toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
if (tc.id) toolCallIndex.set(tc.id, [i, tc]);
const inp = tc.input ?? {};
// Bash commands
if (toolName === 'Bash') {
const cmd = typeof inp.command === 'string' ? inp.command : '';
const cmdTrunc = cmd.slice(0, 200);
bashCmds.push(cmdTrunc);
// Thrashing: bash prefix groups
const prefix = cmd.slice(0, 40);
bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1);
// Git activity
if (cmd.includes('git commit')) {
const heredocMatch = /cat\s+<<['"]?EOF['"]?\n(.+?)(?:\n|$)/.exec(cmd);
let preview: string;
if (heredocMatch) {
preview = heredocMatch[1].trim().slice(0, 80);
} else {
const msgMatch = /-m\s+["'](.+?)["']/.exec(cmd);
preview = msgMatch ? msgMatch[1].slice(0, 80) : cmd.slice(0, 80);
}
gitCommits.push({ messagePreview: preview, messageIndex: i });
}
if (cmd.includes('git push')) gitPushCount++;
if (cmd.includes('git checkout -b') || cmd.includes('git switch -c')) {
const branchMatch = /git (?:checkout -b|switch -c)\s+(\S+)/.exec(cmd);
if (branchMatch) gitBranchCreations.push(branchMatch[1]);
}
}
// Skills
if (toolName === 'Skill') {
skillsInvoked.push({
skill: (inp.skill as string) ?? 'unknown',
argsPreview: (typeof inp.args === 'string'
? inp.args
: JSON.stringify(inp.args ?? '')
).slice(0, 120),
});
}
// Task (subagent list)
if (toolName === 'Task') {
subagentsList.push({
description: (inp.description as string) ?? 'unknown',
subagentType: (inp.subagent_type as string) ?? 'unknown',
model: (inp.model as string) ?? 'default (inherits parent)',
runInBackground: (inp.run_in_background as boolean) ?? false,
});
}
// TaskCreate
if (toolName === 'TaskCreate') {
lifecycleTasks.push((inp.subject as string) ?? 'unknown');
}
// AskUserQuestion
if (toolName === 'AskUserQuestion') {
const questions = inp.questions as { question?: string; options?: { label?: string }[] }[];
if (Array.isArray(questions)) {
for (const q of questions) {
userQuestions.push({
question: q.question ?? '',
options: Array.isArray(q.options) ? q.options.map((o) => o.label ?? '') : [],
});
}
}
}
// File reads
if (toolName === 'Read') {
const filePath = (inp.file_path as string) ?? '';
if (filePath) {
fileReadCounts.set(filePath, (fileReadCounts.get(filePath) ?? 0) + 1);
}
}
// Write/Edit for thrashing
if (toolName === 'Write' || toolName === 'Edit') {
const fp = (inp.file_path as string) ?? '';
if (fp) {
const indices = fileEditIndices.get(fp);
if (indices) indices.push(i);
else fileEditIndices.set(fp, [i]);
}
}
// Startup overhead: track first non-Skill tool call
if (!firstWorkToolSeen && NON_SKILL_TOOLS.has(toolName)) {
firstWorkToolSeen = true;
}
}
// --- Startup overhead: count assistant messages before first work tool ---
if (msgType === 'assistant' && !firstWorkToolSeen) {
startupMessages++;
if (m.usage) {
startupTokens += m.usage.output_tokens ?? 0;
startupTokens += m.usage.input_tokens ?? 0;
startupTokens += m.usage.cache_creation_input_tokens ?? 0;
startupTokens += m.usage.cache_read_input_tokens ?? 0;
}
}
// --- Token density timeline ---
if (msgType === 'assistant' && msgTs && m.usage) {
const totalMsgTokens =
(m.usage.input_tokens ?? 0) +
(m.usage.output_tokens ?? 0) +
(m.usage.cache_creation_input_tokens ?? 0) +
(m.usage.cache_read_input_tokens ?? 0);
assistantMsgData.push([msgTs, totalMsgTokens]);
}
// --- Timing / key events ---
if (msgTs) {
let label: string | null = null;
if (msgType === 'user' && typeof m.content === 'string') {
const content = m.content;
if (content.includes('start feature')) {
label = `User: ${content.slice(0, 60)}`;
} else if (content.includes('being continued')) {
label = 'Context compaction/continuation';
}
}
for (const tc of m.toolCalls) {
if (tc.name === 'Skill') {
label = `Skill: ${(tc.input.skill as string) ?? ''}`;
} else if (tc.name === 'Task') {
const inpTc = tc.input ?? {};
label = `Task: ${(inpTc.description as string) ?? ''} (${(inpTc.subagent_type as string) ?? ''})`;
}
}
if (label) {
keyEvents.push({ timestamp: msgTs, label });
}
}
// --- Friction signals (user messages) ---
if (msgType === 'user' && !m.isMeta) {
const contentText = extractTextContent(m);
if (contentText.trim()) {
userMessageCount++;
for (const [regex, keyword] of FRICTION_PATTERNS) {
if (regex.test(contentText)) {
corrections.push({
messageIndex: i,
keyword,
preview: contentText.slice(0, 120).replace(/\n/g, ' '),
});
break;
}
}
}
}
// --- Out-of-scope findings (assistant messages) ---
if (msgType === 'assistant') {
const contentText = extractTextContent(m);
const contentLower = contentText.toLowerCase();
for (const kw of OUT_OF_SCOPE_KEYWORDS) {
const kwIdx = contentLower.indexOf(kw.toLowerCase());
if (kwIdx >= 0) {
const start = Math.max(0, kwIdx - 80);
const end = Math.min(contentText.length, kwIdx + 300);
outOfScopeFindings.push({
keyword: kw,
messageIndex: i,
snippet: contentText.slice(start, end).replace(/\n/g, ' ').trim(),
});
break;
}
}
}
// --- Tool results ---
for (const tr of m.toolResults) {
const toolUseId = tr.toolUseId;
if (!toolUseId) continue;
const contentStr = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content);
// Tool errors
if (tr.isError) {
let toolName = 'unknown';
let toolInput = '';
const indexed = toolCallIndex.get(toolUseId);
if (indexed) {
const [, tc] = indexed;
toolName = tc.name ?? 'unknown';
toolInput = JSON.stringify(tc.input ?? {}).slice(0, 300);
}
const errorEntry: ToolError = {
tool: toolName,
inputPreview: toolInput,
error: contentStr.slice(0, 500),
messageIndex: i,
isPermissionDenial: false,
};
if (isPermissionDenial(contentStr)) {
errorEntry.isPermissionDenial = true;
permissionDenials.push(errorEntry);
}
errors.push(errorEntry);
errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1);
}
// Bash exit code errors
if (
!tr.isError &&
(contentStr.includes('Exit code 1') || contentStr.includes('Exit code 127'))
) {
const indexed = toolCallIndex.get(toolUseId);
if (indexed) {
const [, tc] = indexed;
if (tc.name === 'Bash') {
const bashError: ToolError = {
tool: 'Bash (non-zero exit)',
inputPreview: JSON.stringify(tc.input ?? {}).slice(0, 300),
error: contentStr.slice(0, 500),
messageIndex: i,
isPermissionDenial: false,
};
if (isPermissionDenial(contentStr)) {
bashError.isPermissionDenial = true;
permissionDenials.push(bashError);
}
errors.push(bashError);
errorsByTool.set(
'Bash (non-zero exit)',
(errorsByTool.get('Bash (non-zero exit)') ?? 0) + 1
);
}
}
}
// --- Test progression: parse test output from bash results ---
const indexedForTest = toolCallIndex.get(toolUseId);
if (indexedForTest) {
const [, tcOrig] = indexedForTest;
if (tcOrig.name === 'Bash') {
const testResult = parseTestSummary(contentStr);
if (testResult) {
const [passed, failed] = testResult;
testSnapshots.push({
messageIndex: i,
passed,
failed,
total: passed + failed,
raw: contentStr.slice(0, 200).replace(/\n/g, ' '),
});
}
}
}
// --- Lines changed: parse git diff --stat output ---
const indexedForDiff = toolCallIndex.get(toolUseId);
if (indexedForDiff) {
const [, tcOrig] = indexedForDiff;
if (tcOrig.name === 'Bash') {
const rawCmd = tcOrig.input?.command;
const cmdText = typeof rawCmd === 'string' ? rawCmd : '';
if (cmdText.includes('git diff') || cmdText.includes('git show')) {
const insertionIdx = contentStr.indexOf(' insertion');
const deletionIdx = contentStr.indexOf(' deletion');
if (insertionIdx > 0) {
const numStr = contentStr
.slice(Math.max(0, insertionIdx - 10), insertionIdx)
.trim()
.split(/\s+/)
.pop();
if (numStr && /^\d+$/.test(numStr)) linesAddedTotal += parseInt(numStr, 10);
}
if (deletionIdx > 0) {
const numStr = contentStr
.slice(Math.max(0, deletionIdx - 10), deletionIdx)
.trim()
.split(/\s+/)
.pop();
if (numStr && /^\d+$/.test(numStr)) linesRemovedTotal += parseInt(numStr, 10);
}
}
}
}
}
}
// ===================================================================
// POST-PASS AGGREGATION
// ===================================================================
// --- Token usage ---
let totalInputTokens = 0;
let totalOutputTokens = 0;
let totalCacheCreationTokens = 0;
let totalCacheReadTokens = 0;
const byModel: Record<string, ModelTokenStats> = {};
for (const [model, stats] of modelStats) {
stats.costUsd = Math.round(stats.costUsd * 10000) / 10000;
byModel[model] = stats;
totalInputTokens += stats.inputTokens;
totalOutputTokens += stats.outputTokens;
totalCacheCreationTokens += stats.cacheCreation;
totalCacheReadTokens += stats.cacheRead;
}
let grandTotal =
totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens;
// --- Cost analysis ---
const commitCount = gitCommits.length;
const linesChanged = linesAddedTotal + linesRemovedTotal;
// --- Subagent metrics from detail.processes ---
const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => {
const desc = proc.description ?? 'unknown';
// Extract actual model from subagent messages (first assistant message with a model field)
const subagentModel =
proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ??
'default (inherits parent)';
// Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream)
const computedCost = calculateMessageCost(
subagentModel,
proc.metrics.inputTokens,
proc.metrics.outputTokens,
proc.metrics.cacheReadTokens,
proc.metrics.cacheCreationTokens
);
return {
description: desc,
subagentType: proc.subagentType ?? 'unknown',
model: subagentModel,
totalTokens: proc.metrics.totalTokens,
totalDurationMs: proc.durationMs,
totalToolUseCount: proc.messages.reduce(
(sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length,
0
),
costUsd: computedCost,
modelMismatch: detectModelMismatch(desc, subagentModel),
};
});
const saFromProcesses = {
count: subagentEntries.length,
totalTokens: subagentEntries.reduce((sum, a) => sum + a.totalTokens, 0),
totalDurationMs: subagentEntries.reduce((sum, a) => sum + a.totalDurationMs, 0),
totalToolUseCount: subagentEntries.reduce((sum, a) => sum + a.totalToolUseCount, 0),
totalCostUsd:
Math.round(subagentEntries.reduce((sum, a) => sum + a.costUsd, 0) * 10000) / 10000,
byAgent: subagentEntries,
};
// --- Tool usage with success rates ---
const toolSuccessRates: Record<string, ToolSuccessRate> = {};
const sortedToolCounts = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]);
const countsRecord: Record<string, number> = {};
for (const [tool, count] of sortedToolCounts) {
countsRecord[tool] = count;
const errCount = errorsByTool.get(tool) ?? 0;
const successPct = count ? Math.round(((count - errCount) / count) * 1000) / 10 : 0;
toolSuccessRates[tool] = {
totalCalls: count,
errors: errCount,
successRatePct: successPct,
assessment: computeToolHealthAssessment(successPct),
};
}
// Overall tool health: worst assessment among tools with >5 calls
const significantTools = Object.values(toolSuccessRates).filter((t) => t.totalCalls > 5);
type THAssessment = 'healthy' | 'degraded' | 'unreliable';
const overallToolHealth: THAssessment =
significantTools.length > 0
? significantTools.reduce<THAssessment>((worst, t) => {
const order = { healthy: 0, degraded: 1, unreliable: 2 } as const;
return order[t.assessment] > order[worst] ? t.assessment : worst;
}, 'healthy')
: computeToolHealthAssessment(100);
// --- Key events timing ---
for (let j = 1; j < keyEvents.length; j++) {
const prevDt = keyEvents[j - 1].timestamp;
const currDt = keyEvents[j].timestamp;
const delta = (currDt.getTime() - prevDt.getTime()) / 1000;
keyEvents[j].deltaSeconds = Math.round(delta * 10) / 10;
keyEvents[j].deltaHuman = formatDuration(Math.floor(delta));
}
// --- Thinking blocks signal aggregation ---
const signalTotals: Record<string, number> = {};
for (const ta of thinkingAnalysis) {
for (const sig of Object.keys(ta.signals)) {
signalTotals[sig] = (signalTotals[sig] ?? 0) + 1;
}
}
// --- Cache economics ---
const cacheTotalCreationAndRead = totalCacheCreation + totalCacheRead;
const cacheEfficiency = cacheTotalCreationAndRead
? Math.round((totalCacheRead / cacheTotalCreationAndRead) * 10000) / 100
: 0;
const cacheRwRatio = totalCacheCreation
? Math.round((totalCacheRead / totalCacheCreation) * 10) / 10
: 0;
// --- File read redundancy ---
let totalReads = 0;
const redundantFiles: Record<string, number> = {};
for (const [path, count] of fileReadCounts) {
totalReads += count;
if (count > 2) redundantFiles[path] = count;
}
const uniqueFiles = fileReadCounts.size;
// --- Token density timeline ---
const quartiles: { q: number; avgTokens: number; messageCount: number }[] = [];
if (assistantMsgData.length > 0) {
const n = assistantMsgData.length;
const qSize = Math.max(1, Math.floor(n / 4));
for (let q = 0; q < 4; q++) {
const startIdx = q * qSize;
const endIdx = q === 3 ? n : (q + 1) * qSize;
const chunk = assistantMsgData.slice(startIdx, endIdx);
if (chunk.length > 0) {
const avgTokens = Math.round(chunk.reduce((sum, [, t]) => sum + t, 0) / chunk.length);
quartiles.push({ q: q + 1, avgTokens, messageCount: chunk.length });
} else {
quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 });
}
}
} else {
for (let q = 0; q < 4; q++) {
quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 });
}
}
// --- Conversation tree analysis ---
const depthMemo = new Map<string, number>();
function getDepth(uuid: string, visited = new Set<string>()): number {
if (depthMemo.has(uuid)) return depthMemo.get(uuid)!;
if (visited.has(uuid)) {
depthMemo.set(uuid, 0);
return 0;
}
visited.add(uuid);
const parent = parentMap.get(uuid);
if (!parent) {
depthMemo.set(uuid, 0);
return 0;
}
const depth = 1 + getDepth(parent, visited);
depthMemo.set(uuid, depth);
return depth;
}
let maxDepth = 0;
for (const uuid of parentMap.keys()) {
const d = getDepth(uuid);
if (d > maxDepth) maxDepth = d;
}
// Branch points: parents with multiple children
const branchPoints = new Map<string, string[]>();
for (const [parent, children] of childrenMap) {
if (children.length > 1) branchPoints.set(parent, children);
}
const branchDetails = [...branchPoints.entries()]
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 10)
.map(([p, c]) => ({
parentUuid: p.slice(0, 12) + '...',
childCount: c.length,
parentMessageIndex: uuidToIdx.get(p),
}));
// --- Idle gap analysis ---
const totalIdle = idleGaps.reduce((sum, g) => sum + g.gapSeconds, 0);
const wallClock = durationSeconds;
const activeTime = wallClock > 0 ? wallClock - totalIdle : 0;
// --- Thrashing signals ---
const bashNearDuplicates = [...bashPrefixGroups.entries()]
.filter(([, count]) => count > 2)
.sort((a, b) => b[1] - a[1])
.map(([prefix, count]) => ({ prefix, count }));
const editReworkFiles = [...fileEditIndices.entries()]
.filter(([, indices]) => indices.length >= 3)
.map(([filePath, editIndices]) => ({ filePath, editIndices }));
// --- Model switches ---
const modelsUsed =
modelSwitches.length > 0
? [...new Set([...modelSwitches.map((s) => s.from), ...modelSwitches.map((s) => s.to)])]
: [...modelStats.keys()];
// --- Test progression trajectory ---
let trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data' = 'insufficient_data';
if (testSnapshots.length >= 2) {
const first = testSnapshots[0];
const last = testSnapshots[testSnapshots.length - 1];
if (last.passed > first.passed) trajectory = 'improving';
else if (last.passed < first.passed) trajectory = 'regressing';
else trajectory = 'stable';
}
// --- Prompt quality assessment ---
const correctionCount = corrections.length;
const frictionRate = userMessageCount
? Math.round((correctionCount / userMessageCount) * 10000) / 10000
: 0;
type PromptAssessment =
| 'underspecified'
| 'verbose_but_unclear'
| 'well_specified'
| 'moderate_friction';
let assessment: PromptAssessment;
let promptNote: string;
if (firstUserMessageLength < 100 && correctionCount >= 2) {
assessment = 'underspecified';
promptNote =
'Short initial prompt with multiple corrections suggests the task needed more upfront specification.';
} else if (firstUserMessageLength > 500 && correctionCount >= 3) {
assessment = 'verbose_but_unclear';
promptNote =
'Initial prompt was detailed but still required corrections — consider restructuring for clarity.';
} else if (correctionCount <= 1) {
assessment = 'well_specified';
promptNote = 'Low friction — initial prompt effectively communicated intent.';
} else {
assessment = 'moderate_friction';
promptNote =
'Moderate friction detected — review correction patterns for improvement opportunities.';
}
// --- Message types ---
const messageTypes: Record<string, number> = {};
for (const [type, count] of typeCounts) {
messageTypes[type] = count;
}
// --- Subagent cost from processes ---
const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0);
const totalCost = parentCost + processSubagentCost;
// Add aggregated subagent row to costByModel and byModel for the cost table
if (subagentEntries.length > 0 && processSubagentCost > 0) {
const subagentTokenStats: ModelTokenStats = {
apiCalls: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreation: 0,
cacheRead: 0,
costUsd: 0,
};
for (const proc of detail.processes) {
subagentTokenStats.inputTokens += proc.metrics.inputTokens;
subagentTokenStats.outputTokens += proc.metrics.outputTokens;
subagentTokenStats.cacheCreation += proc.metrics.cacheCreationTokens;
subagentTokenStats.cacheRead += proc.metrics.cacheReadTokens;
// Count assistant messages with usage as API calls
subagentTokenStats.apiCalls += proc.messages.filter(
(m: ParsedMessage) => m.type === 'assistant' && m.usage
).length;
}
subagentTokenStats.costUsd = Math.round(processSubagentCost * 10000) / 10000;
const subagentLabel = 'Subagents (combined)';
byModel[subagentLabel] = subagentTokenStats;
modelStats.set(subagentLabel, subagentTokenStats);
// Update totals to include subagent tokens so the footer row stays consistent
totalInputTokens += subagentTokenStats.inputTokens;
totalOutputTokens += subagentTokenStats.outputTokens;
totalCacheCreationTokens += subagentTokenStats.cacheCreation;
totalCacheReadTokens += subagentTokenStats.cacheRead;
grandTotal =
totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens;
}
// --- Assessment computations ---
const costPerCommitVal =
commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null;
const costPerLineVal =
linesChanged > 0 ? Math.round((totalCost / linesChanged) * 1000000) / 1000000 : null;
const subagentCostSharePct =
totalCost > 0 ? Math.round((processSubagentCost / totalCost) * 10000) / 100 : null;
const readsPerUniqueFile = uniqueFiles ? Math.round((totalReads / uniqueFiles) * 100) / 100 : 0;
const startupPctOfTotal = grandTotal ? Math.round((startupTokens / grandTotal) * 10000) / 100 : 0;
const idlePct = wallClock > 0 ? Math.round((totalIdle / wallClock) * 1000) / 10 : 0;
const thrashingSignalCount = bashNearDuplicates.length + editReworkFiles.length;
// ===================================================================
// BUILD REPORT
// ===================================================================
const report: SessionReport = {
overview: {
sessionId: session.id,
projectId: session.projectId ?? 'unknown',
projectPath: session.projectPath ?? 'unknown',
firstMessage: session.firstMessage ?? 'unknown',
messageCount: session.messageCount ?? 0,
hasSubagents: session.hasSubagents ?? false,
contextConsumption: ctxConsumption,
contextConsumptionPct: ctxConsumptionPct,
contextAssessment: ctxAssessment,
compactionCount: session.compactionCount ?? 0,
gitBranch: session.gitBranch ?? 'unknown',
startTime: firstTs,
endTime: lastTs,
durationSeconds,
durationHuman: durationSeconds > 0 ? formatDuration(Math.floor(durationSeconds)) : 'unknown',
totalMessages: messages.length,
},
tokenUsage: {
byModel,
totals: {
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheCreation: totalCacheCreationTokens,
cacheRead: totalCacheReadTokens,
grandTotal,
cacheReadPct: grandTotal
? Math.round((totalCacheReadTokens / grandTotal) * 10000) / 100
: 0,
},
},
costAnalysis: {
parentCostUsd: Math.round(parentCost * 10000) / 10000,
subagentCostUsd: Math.round(processSubagentCost * 10000) / 10000,
totalSessionCostUsd: Math.round(totalCost * 10000) / 10000,
costByModel: Object.fromEntries(
[...modelStats.entries()].map(([model, stats]) => [
model,
Math.round(stats.costUsd * 10000) / 10000,
])
),
costPerCommit: costPerCommitVal,
costPerLineChanged: costPerLineVal,
costPerCommitAssessment:
costPerCommitVal != null ? computeCostPerCommitAssessment(costPerCommitVal) : null,
costPerLineAssessment:
costPerLineVal != null ? computeCostPerLineAssessment(costPerLineVal) : null,
subagentCostSharePct,
subagentCostShareAssessment:
subagentCostSharePct != null
? computeSubagentCostShareAssessment(subagentCostSharePct)
: null,
},
cacheEconomics: {
cacheRead: totalCacheRead,
cacheEfficiencyPct: cacheEfficiency,
coldStartDetected,
cacheReadToWriteRatio: cacheRwRatio,
cacheEfficiencyAssessment:
cacheTotalCreationAndRead > 0 ? computeCacheEfficiencyAssessment(cacheEfficiency) : null,
cacheRatioAssessment:
totalCacheCreation > 0 ? computeCacheRatioAssessment(cacheRwRatio) : null,
},
toolUsage: {
counts: countsRecord,
totalCalls: [...toolCounts.values()].reduce((sum, c) => sum + c, 0),
successRates: toolSuccessRates,
overallToolHealth,
},
subagentMetrics: saFromProcesses,
subagentsList,
errors: {
errors,
permissionDenials: {
count: permissionDenials.length,
denials: permissionDenials,
affectedTools: [...new Set(permissionDenials.map((d) => d.tool))],
},
},
gitActivity: {
commitCount: gitCommits.length,
commits: gitCommits,
pushCount: gitPushCount,
branchCreations: gitBranchCreations,
linesAdded: linesAddedTotal,
linesRemoved: linesRemovedTotal,
linesChanged,
},
frictionSignals: {
correctionCount,
corrections,
frictionRate,
},
thrashingSignals: {
bashNearDuplicates,
editReworkFiles,
thrashingAssessment: computeThrashingAssessment(thrashingSignalCount),
},
conversationTree: {
totalNodes: uuidToIdx.size,
maxDepth,
sidechainCount,
branchPoints: branchPoints.size,
branchDetails,
},
idleAnalysis: {
idleThresholdSeconds: IDLE_THRESHOLD_SEC,
idleGapCount: idleGaps.length,
totalIdleSeconds: Math.round(totalIdle * 10) / 10,
totalIdleHuman: formatDuration(Math.floor(totalIdle)),
wallClockSeconds: Math.round(wallClock * 10) / 10,
activeWorkingSeconds: Math.round(Math.max(activeTime, 0) * 10) / 10,
activeWorkingHuman: formatDuration(Math.floor(Math.max(activeTime, 0))),
idlePct,
longestGaps: [...idleGaps].sort((a, b) => b.gapSeconds - a.gapSeconds).slice(0, 5),
idleAssessment: computeIdleAssessment(idlePct),
},
modelSwitches: {
count: modelSwitches.length,
switches: modelSwitches,
modelsUsed,
switchPattern: detectSwitchPattern(modelSwitches),
},
workingDirectories: {
uniqueDirectories: [...cwdSet],
directoryCount: cwdSet.size,
changes: cwdChanges,
changeCount: cwdChanges.length,
isMultiDirectory: cwdSet.size > 1,
},
testProgression: {
snapshotCount: testSnapshots.length,
snapshots: testSnapshots,
trajectory,
firstSnapshot: testSnapshots.length > 0 ? testSnapshots[0] : null,
lastSnapshot: testSnapshots.length > 0 ? testSnapshots[testSnapshots.length - 1] : null,
},
startupOverhead: {
messagesBeforeFirstWork: startupMessages,
tokensBeforeFirstWork: startupTokens,
pctOfTotal: startupPctOfTotal,
overheadAssessment: computeOverheadAssessment(startupPctOfTotal),
},
tokenDensityTimeline: { quartiles },
promptQuality: {
firstMessageLengthChars: firstUserMessageLength,
userMessageCount,
correctionCount,
frictionRate,
assessment,
note: promptNote,
},
thinkingBlocks: {
count: thinkingCount,
analyzedCount: thinkingAnalysis.length,
signalSummary: signalTotals,
notableBlocks: thinkingAnalysis.slice(0, 20),
},
keyEvents,
messageTypes,
fileReadRedundancy: {
totalReads,
uniqueFiles,
readsPerUniqueFile,
redundantFiles,
redundancyAssessment: computeRedundancyAssessment(readsPerUniqueFile),
},
compaction: {
count: session.compactionCount ?? 0,
compactSummaryCount,
note:
(session.compactionCount ?? 0) > 0
? 'Session underwent compaction, which may have caused loss of earlier context. Check for repeated work after compaction events.'
: 'No compaction occurred — session stayed within context limits.',
},
gitBranches: [...branches],
skillsInvoked,
bashCommands: {
total: bashCmds.length,
unique: new Set(bashCmds).size,
repeated: Object.fromEntries(
[
...bashCmds
.reduce((acc, cmd) => acc.set(cmd, (acc.get(cmd) ?? 0) + 1), new Map<string, number>())
.entries(),
]
.filter(([, count]) => count > 1)
.sort((a, b) => b[1] - a[1])
),
},
lifecycleTasks,
userQuestions,
outOfScopeFindings,
agentTree: (() => {
const uniqueAgents = new Map<string, AgentTreeNode>();
for (const node of agentTreeNodes) {
if (!uniqueAgents.has(node.agentId)) uniqueAgents.set(node.agentId, node);
}
return {
agentCount: uniqueAgents.size,
agents: [...uniqueAgents.values()],
hasTeamMode: agentTreeNodes.some((n) => n.teamName),
teamNames: [...new Set(agentTreeNodes.filter((n) => n.teamName).map((n) => n.teamName))],
};
})(),
};
return report;
}