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>
1346 lines
44 KiB
TypeScript
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;
|
|
}
|