agent-profiler 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/db.js CHANGED
@@ -70,7 +70,10 @@ function migrateTableColumns(db, tableName, columnsToAdd) {
70
70
  }
71
71
  function migrateEventsSchema(db) {
72
72
  migrateTableColumns(db, "events", [
73
- { name: "workspace_path", sql: `ALTER TABLE events ADD COLUMN workspace_path TEXT` },
73
+ {
74
+ name: "workspace_path",
75
+ sql: `ALTER TABLE events ADD COLUMN workspace_path TEXT`,
76
+ },
74
77
  {
75
78
  name: "workspace_home_rel_path",
76
79
  sql: `ALTER TABLE events ADD COLUMN workspace_home_rel_path TEXT`,
@@ -79,7 +82,10 @@ function migrateEventsSchema(db) {
79
82
  name: "workspace_display_path",
80
83
  sql: `ALTER TABLE events ADD COLUMN workspace_display_path TEXT`,
81
84
  },
82
- { name: "git_repo_root", sql: `ALTER TABLE events ADD COLUMN git_repo_root TEXT` },
85
+ {
86
+ name: "git_repo_root",
87
+ sql: `ALTER TABLE events ADD COLUMN git_repo_root TEXT`,
88
+ },
83
89
  {
84
90
  name: "git_repo_root_home_rel_path",
85
91
  sql: `ALTER TABLE events ADD COLUMN git_repo_root_home_rel_path TEXT`,
@@ -88,18 +94,30 @@ function migrateEventsSchema(db) {
88
94
  name: "git_repo_root_display_path",
89
95
  sql: `ALTER TABLE events ADD COLUMN git_repo_root_display_path TEXT`,
90
96
  },
91
- { name: "git_repo_name", sql: `ALTER TABLE events ADD COLUMN git_repo_name TEXT` },
92
- { name: "git_branch", sql: `ALTER TABLE events ADD COLUMN git_branch TEXT` },
97
+ {
98
+ name: "git_repo_name",
99
+ sql: `ALTER TABLE events ADD COLUMN git_repo_name TEXT`,
100
+ },
101
+ {
102
+ name: "git_branch",
103
+ sql: `ALTER TABLE events ADD COLUMN git_branch TEXT`,
104
+ },
93
105
  {
94
106
  name: "interaction_kind",
95
107
  sql: `ALTER TABLE events ADD COLUMN interaction_kind TEXT`,
96
108
  },
97
- { name: "correlation_id", sql: `ALTER TABLE events ADD COLUMN correlation_id TEXT` },
109
+ {
110
+ name: "correlation_id",
111
+ sql: `ALTER TABLE events ADD COLUMN correlation_id TEXT`,
112
+ },
98
113
  {
99
114
  name: "tool_canonical_name",
100
115
  sql: `ALTER TABLE events ADD COLUMN tool_canonical_name TEXT`,
101
116
  },
102
- { name: "mcp_server", sql: `ALTER TABLE events ADD COLUMN mcp_server TEXT` },
117
+ {
118
+ name: "mcp_server",
119
+ sql: `ALTER TABLE events ADD COLUMN mcp_server TEXT`,
120
+ },
103
121
  { name: "mcp_tool", sql: `ALTER TABLE events ADD COLUMN mcp_tool TEXT` },
104
122
  {
105
123
  name: "payload_byte_length",
@@ -113,13 +131,22 @@ function migrateEventsSchema(db) {
113
131
  }
114
132
  function migrateInteractionSpansSchema(db) {
115
133
  migrateTableColumns(db, "interaction_spans", [
116
- { name: "turn_id", sql: `ALTER TABLE interaction_spans ADD COLUMN turn_id TEXT` },
134
+ {
135
+ name: "turn_id",
136
+ sql: `ALTER TABLE interaction_spans ADD COLUMN turn_id TEXT`,
137
+ },
117
138
  {
118
139
  name: "tool_canonical_name",
119
140
  sql: `ALTER TABLE interaction_spans ADD COLUMN tool_canonical_name TEXT`,
120
141
  },
121
- { name: "mcp_server", sql: `ALTER TABLE interaction_spans ADD COLUMN mcp_server TEXT` },
122
- { name: "mcp_tool", sql: `ALTER TABLE interaction_spans ADD COLUMN mcp_tool TEXT` },
142
+ {
143
+ name: "mcp_server",
144
+ sql: `ALTER TABLE interaction_spans ADD COLUMN mcp_server TEXT`,
145
+ },
146
+ {
147
+ name: "mcp_tool",
148
+ sql: `ALTER TABLE interaction_spans ADD COLUMN mcp_tool TEXT`,
149
+ },
123
150
  {
124
151
  name: "pre_event_id",
125
152
  sql: `ALTER TABLE interaction_spans ADD COLUMN pre_event_id INTEGER`,
@@ -168,9 +195,18 @@ function migrateInteractionSpansSchema(db) {
168
195
  name: "git_repo_name",
169
196
  sql: `ALTER TABLE interaction_spans ADD COLUMN git_repo_name TEXT`,
170
197
  },
171
- { name: "git_branch", sql: `ALTER TABLE interaction_spans ADD COLUMN git_branch TEXT` },
172
- { name: "started_at", sql: `ALTER TABLE interaction_spans ADD COLUMN started_at TEXT` },
173
- { name: "completed_at", sql: `ALTER TABLE interaction_spans ADD COLUMN completed_at TEXT` },
198
+ {
199
+ name: "git_branch",
200
+ sql: `ALTER TABLE interaction_spans ADD COLUMN git_branch TEXT`,
201
+ },
202
+ {
203
+ name: "started_at",
204
+ sql: `ALTER TABLE interaction_spans ADD COLUMN started_at TEXT`,
205
+ },
206
+ {
207
+ name: "completed_at",
208
+ sql: `ALTER TABLE interaction_spans ADD COLUMN completed_at TEXT`,
209
+ },
174
210
  ]);
175
211
  db.exec(`CREATE INDEX IF NOT EXISTS idx_events_workspace ON events(workspace_path, created_at)`);
176
212
  db.exec(`CREATE INDEX IF NOT EXISTS idx_events_interaction_kind ON events(interaction_kind, created_at)`);
@@ -351,27 +387,7 @@ export function getEventsForLatestSession(db) {
351
387
  ? db
352
388
  .prepare(`
353
389
  SELECT
354
- id,
355
- created_at AS createdAt,
356
- source,
357
- source_event AS sourceEvent,
358
- repo_path AS repoPath,
359
- session_id AS sessionId,
360
- turn_id AS turnId,
361
- model,
362
- role,
363
- estimated_input_tokens AS estimatedInputTokens,
364
- estimated_output_tokens AS estimatedOutputTokens,
365
- estimated_total_tokens AS estimatedTotalTokens,
366
- raw_payload AS rawPayload,
367
- workspace_path AS workspacePath,
368
- workspace_home_rel_path AS workspaceHomeRelPath,
369
- workspace_display_path AS workspaceDisplayPath,
370
- git_repo_root AS gitRepoRoot,
371
- git_repo_root_home_rel_path AS gitRepoRootHomeRelPath,
372
- git_repo_root_display_path AS gitRepoRootDisplayPath,
373
- git_repo_name AS gitRepoName,
374
- git_branch AS gitBranch
390
+ ${STORED_EVENT_SELECT}
375
391
  FROM events
376
392
  WHERE source = ? AND session_id = ?
377
393
  ORDER BY created_at ASC, id ASC
@@ -380,27 +396,7 @@ export function getEventsForLatestSession(db) {
380
396
  : db
381
397
  .prepare(`
382
398
  SELECT
383
- id,
384
- created_at AS createdAt,
385
- source,
386
- source_event AS sourceEvent,
387
- repo_path AS repoPath,
388
- session_id AS sessionId,
389
- turn_id AS turnId,
390
- model,
391
- role,
392
- estimated_input_tokens AS estimatedInputTokens,
393
- estimated_output_tokens AS estimatedOutputTokens,
394
- estimated_total_tokens AS estimatedTotalTokens,
395
- raw_payload AS rawPayload,
396
- workspace_path AS workspacePath,
397
- workspace_home_rel_path AS workspaceHomeRelPath,
398
- workspace_display_path AS workspaceDisplayPath,
399
- git_repo_root AS gitRepoRoot,
400
- git_repo_root_home_rel_path AS gitRepoRootHomeRelPath,
401
- git_repo_root_display_path AS gitRepoRootDisplayPath,
402
- git_repo_name AS gitRepoName,
403
- git_branch AS gitBranch
399
+ ${STORED_EVENT_SELECT}
404
400
  FROM events
405
401
  WHERE source = ? AND repo_path IS ?
406
402
  ORDER BY created_at DESC, id DESC
@@ -410,3 +406,117 @@ export function getEventsForLatestSession(db) {
410
406
  .reverse();
411
407
  return rows;
412
408
  }
409
+ const STORED_EVENT_SELECT = `
410
+ id,
411
+ created_at AS createdAt,
412
+ source,
413
+ source_event AS sourceEvent,
414
+ repo_path AS repoPath,
415
+ session_id AS sessionId,
416
+ turn_id AS turnId,
417
+ model,
418
+ role,
419
+ estimated_input_tokens AS estimatedInputTokens,
420
+ estimated_output_tokens AS estimatedOutputTokens,
421
+ estimated_total_tokens AS estimatedTotalTokens,
422
+ raw_payload AS rawPayload,
423
+ workspace_path AS workspacePath,
424
+ workspace_home_rel_path AS workspaceHomeRelPath,
425
+ workspace_display_path AS workspaceDisplayPath,
426
+ git_repo_root AS gitRepoRoot,
427
+ git_repo_root_home_rel_path AS gitRepoRootHomeRelPath,
428
+ git_repo_root_display_path AS gitRepoRootDisplayPath,
429
+ git_repo_name AS gitRepoName,
430
+ git_branch AS gitBranch
431
+ `;
432
+ export function getLatestSessionDescriptor(db) {
433
+ const latest = db
434
+ .prepare(`
435
+ SELECT source, session_id AS sessionId, repo_path AS repoPath
436
+ FROM events
437
+ ORDER BY created_at DESC
438
+ LIMIT 1
439
+ `)
440
+ .get();
441
+ return latest ?? null;
442
+ }
443
+ export function listRecentSessions(db, limit) {
444
+ const rows = db
445
+ .prepare(`
446
+ SELECT
447
+ source AS source,
448
+ session_id AS sessionId,
449
+ MAX(repo_path) AS repoPath,
450
+ MIN(created_at) AS startedAt,
451
+ MAX(created_at) AS endedAt,
452
+ COUNT(*) AS eventCount
453
+ FROM events
454
+ WHERE session_id IS NOT NULL AND LENGTH(TRIM(session_id)) > 0
455
+ GROUP BY source, session_id
456
+ ORDER BY endedAt DESC
457
+ LIMIT ?
458
+ `)
459
+ .all(limit);
460
+ return rows;
461
+ }
462
+ export function getEventsForSession(db, source, sessionId) {
463
+ const rows = db
464
+ .prepare(`
465
+ SELECT
466
+ ${STORED_EVENT_SELECT}
467
+ FROM events
468
+ WHERE source = ? AND session_id = ?
469
+ ORDER BY created_at ASC, id ASC
470
+ `)
471
+ .all(source, sessionId);
472
+ return rows;
473
+ }
474
+ /** Latest window used when session_id is absent (matches getEventsForLatestSession fallback). */
475
+ export function getEventsForLegacyRepoWindow(db, source, repoPath) {
476
+ const rows = db
477
+ .prepare(`
478
+ SELECT
479
+ ${STORED_EVENT_SELECT}
480
+ FROM events
481
+ WHERE source = ? AND repo_path IS ?
482
+ ORDER BY created_at DESC, id DESC
483
+ LIMIT 200
484
+ `)
485
+ .all(source, repoPath ?? null);
486
+ return rows.reverse();
487
+ }
488
+ export function getSessionTimeline(db, source, sessionId) {
489
+ const rows = db
490
+ .prepare(`
491
+ SELECT
492
+ id,
493
+ created_at AS createdAt,
494
+ role,
495
+ turn_id AS turnId,
496
+ estimated_total_tokens AS estimatedTotalTokens,
497
+ source_event AS sourceEvent
498
+ FROM events
499
+ WHERE source = ? AND session_id = ?
500
+ ORDER BY created_at ASC, id ASC
501
+ `)
502
+ .all(source, sessionId);
503
+ return rows;
504
+ }
505
+ export function getLegacyRepoTimeline(db, source, repoPath) {
506
+ const rows = db
507
+ .prepare(`
508
+ SELECT
509
+ id,
510
+ created_at AS createdAt,
511
+ role,
512
+ turn_id AS turnId,
513
+ estimated_total_tokens AS estimatedTotalTokens,
514
+ source_event AS sourceEvent
515
+ FROM events
516
+ WHERE source = ? AND repo_path IS ?
517
+ ORDER BY created_at DESC, id DESC
518
+ LIMIT 200
519
+ `)
520
+ .all(source, repoPath ?? null);
521
+ return rows.reverse();
522
+ }
@@ -21,7 +21,10 @@ export function parseMcpToolName(canonical) {
21
21
  if (t.startsWith("mcp__")) {
22
22
  const parts = t.split("__").filter(Boolean);
23
23
  if (parts.length >= 3) {
24
- return { mcpServer: parts[1] ?? null, mcpTool: parts.slice(2).join("__") || null };
24
+ return {
25
+ mcpServer: parts[1] ?? null,
26
+ mcpTool: parts.slice(2).join("__") || null,
27
+ };
25
28
  }
26
29
  }
27
30
  if (t.toLowerCase().startsWith("mcp:")) {
@@ -102,7 +105,9 @@ export function deriveIngestFields(source, hookEventName, rawPayload, rawJsonTex
102
105
  const payload = asRecord(rawPayload);
103
106
  const correlationId = extractCorrelationId(payload);
104
107
  let toolCanonicalName = extractToolCanonicalName(payload);
105
- if (!toolCanonicalName && payload.tool_input && typeof payload.tool_input === "object") {
108
+ if (!toolCanonicalName &&
109
+ payload.tool_input &&
110
+ typeof payload.tool_input === "object") {
106
111
  const ti = payload.tool_input;
107
112
  toolCanonicalName =
108
113
  pickFirstString([ti.command, ti.tool, ti.name])?.trim() ||
@@ -58,7 +58,9 @@ export function resolveHookWorkspacePath(normalizedRepoPath, rawPayload) {
58
58
  for (const c of candidates) {
59
59
  if (!c)
60
60
  continue;
61
- return path.isAbsolute(c) ? path.normalize(c) : path.resolve(process.cwd(), c);
61
+ return path.isAbsolute(c)
62
+ ? path.normalize(c)
63
+ : path.resolve(process.cwd(), c);
62
64
  }
63
65
  return path.resolve(process.cwd());
64
66
  }
@@ -84,7 +86,10 @@ function gitOutput(workspacePath, args) {
84
86
  export function resolveWorkspaceGitMeta(workspacePath) {
85
87
  const normalizedWorkspacePath = path.normalize(workspacePath);
86
88
  const workspaceHomePath = deriveHomePath(normalizedWorkspacePath);
87
- const inside = gitOutput(normalizedWorkspacePath, ["rev-parse", "--is-inside-work-tree"]);
89
+ const inside = gitOutput(normalizedWorkspacePath, [
90
+ "rev-parse",
91
+ "--is-inside-work-tree",
92
+ ]);
88
93
  if (inside !== "true") {
89
94
  return {
90
95
  workspacePath: normalizedWorkspacePath,
@@ -97,8 +102,15 @@ export function resolveWorkspaceGitMeta(workspacePath) {
97
102
  gitBranch: null,
98
103
  };
99
104
  }
100
- const root = gitOutput(normalizedWorkspacePath, ["rev-parse", "--show-toplevel"]);
101
- const branch = gitOutput(normalizedWorkspacePath, ["rev-parse", "--abbrev-ref", "HEAD"]);
105
+ const root = gitOutput(normalizedWorkspacePath, [
106
+ "rev-parse",
107
+ "--show-toplevel",
108
+ ]);
109
+ const branch = gitOutput(normalizedWorkspacePath, [
110
+ "rev-parse",
111
+ "--abbrev-ref",
112
+ "HEAD",
113
+ ]);
102
114
  const gitRepoRoot = root ? path.normalize(root) : null;
103
115
  const gitRepoRootHomePath = deriveHomePath(gitRepoRoot);
104
116
  const gitRepoName = gitRepoRoot ? path.basename(gitRepoRoot) : null;
@@ -0,0 +1,204 @@
1
+ import { runContextAudit } from "./contextAudit.js";
2
+ function parsePayload(rawPayload) {
3
+ try {
4
+ const parsed = JSON.parse(rawPayload);
5
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
6
+ return parsed;
7
+ }
8
+ }
9
+ catch {
10
+ // ignore malformed payloads
11
+ }
12
+ return {};
13
+ }
14
+ export function formatTokens(value) {
15
+ return `~${new Intl.NumberFormat("en-US").format(value)} tokens`;
16
+ }
17
+ function normalizeSnippet(value) {
18
+ return value.toLowerCase().replace(/\s+/g, " ").trim().slice(0, 160);
19
+ }
20
+ export const LARGEST_EVENTS_LIMIT = 10;
21
+ function sessionKeyLabel(first) {
22
+ if (first.sessionId && first.sessionId.trim().length > 0) {
23
+ return first.sessionId;
24
+ }
25
+ return first.repoPath ?? "(no-session-id)";
26
+ }
27
+ /**
28
+ * Builds the same report shape as the CLI `last` command from ordered session events.
29
+ */
30
+ export function analyzeSession(events, options) {
31
+ if (events.length === 0) {
32
+ return null;
33
+ }
34
+ const first = events[0];
35
+ const lastEv = events[events.length - 1];
36
+ const start = new Date(first.createdAt).getTime();
37
+ const end = new Date(lastEv.createdAt).getTime();
38
+ const durationMinutes = Math.max(0, Math.round((end - start) / 60000));
39
+ let input = 0;
40
+ let output = 0;
41
+ let total = 0;
42
+ let shellOutput = 0;
43
+ let toolResults = 0;
44
+ const turnIds = new Set();
45
+ let fileEdits = 0;
46
+ let shellCalls = 0;
47
+ let toolCalls = 0;
48
+ const fileEditCounts = new Map();
49
+ const redFlags = [];
50
+ let recommendations = [];
51
+ const shellFailureBuckets = new Map();
52
+ for (const event of events) {
53
+ input += event.estimatedInputTokens;
54
+ output += event.estimatedOutputTokens;
55
+ total += event.estimatedTotalTokens;
56
+ if (event.turnId)
57
+ turnIds.add(event.turnId);
58
+ if (event.role === "file_edit") {
59
+ fileEdits += 1;
60
+ const payload = parsePayload(event.rawPayload);
61
+ const file = (payload.filePath ??
62
+ payload.path ??
63
+ payload.relativePath);
64
+ if (file) {
65
+ fileEditCounts.set(file, (fileEditCounts.get(file) ?? 0) + 1);
66
+ }
67
+ }
68
+ if (event.role === "shell_command")
69
+ shellCalls += 1;
70
+ if (event.role === "shell_output") {
71
+ shellCalls += 1;
72
+ shellOutput += event.estimatedTotalTokens;
73
+ const payload = parsePayload(event.rawPayload);
74
+ const command = typeof payload.command === "string" ? payload.command : null;
75
+ const stderr = typeof payload.stderr === "string" ? payload.stderr : "";
76
+ const stdout = typeof payload.stdout === "string" ? payload.stdout : "";
77
+ const outputSnippet = normalizeSnippet(stderr || stdout);
78
+ const looksFailed = outputSnippet.includes("error") ||
79
+ outputSnippet.includes("failed") ||
80
+ outputSnippet.includes("exception") ||
81
+ outputSnippet.includes("traceback");
82
+ if (command && looksFailed) {
83
+ const key = `${normalizeSnippet(command)}|${outputSnippet}`;
84
+ const prev = shellFailureBuckets.get(key) ?? { runs: 0, tokenTotal: 0 };
85
+ shellFailureBuckets.set(key, {
86
+ runs: prev.runs + 1,
87
+ tokenTotal: prev.tokenTotal + event.estimatedTotalTokens,
88
+ });
89
+ }
90
+ }
91
+ if (event.role === "tool_call")
92
+ toolCalls += 1;
93
+ if (event.role === "tool_result" || event.role === "tool_failure")
94
+ toolResults += event.estimatedTotalTokens;
95
+ }
96
+ let score = 100;
97
+ const topChurn = [...fileEditCounts.entries()].sort((a, b) => b[1] - a[1])[0];
98
+ if (topChurn && topChurn[1] >= 5) {
99
+ redFlags.push({
100
+ severity: "HIGH",
101
+ title: "same-file churn",
102
+ detail: `${topChurn[0]} was edited ${topChurn[1]} times.`,
103
+ recommendation: "Add a focused repo rule or skill note for this file's recurring failure pattern.",
104
+ penalty: 15,
105
+ });
106
+ }
107
+ if (shellOutput > 4000) {
108
+ redFlags.push({
109
+ severity: "HIGH",
110
+ title: "shell output noise",
111
+ detail: `Shell output produced ${formatTokens(shellOutput)} in this session.`,
112
+ recommendation: "Capture key test/build failures once, then summarize repeated output.",
113
+ penalty: 12,
114
+ });
115
+ }
116
+ if (toolResults > 4000) {
117
+ redFlags.push({
118
+ severity: "MEDIUM",
119
+ title: "large tool result",
120
+ detail: `Tool results produced ${formatTokens(toolResults)} in this session.`,
121
+ recommendation: "Request narrower tool queries and summarize oversized tool responses.",
122
+ penalty: 10,
123
+ });
124
+ }
125
+ const worstFailureLoop = [...shellFailureBuckets.entries()].sort((a, b) => b[1].runs - a[1].runs || b[1].tokenTotal - a[1].tokenTotal)[0];
126
+ if (worstFailureLoop && worstFailureLoop[1].runs >= 3) {
127
+ redFlags.push({
128
+ severity: "HIGH",
129
+ title: "thrashing loop",
130
+ detail: `A similar failing shell command looped ${worstFailureLoop[1].runs} times (${formatTokens(worstFailureLoop[1].tokenTotal)}).`,
131
+ recommendation: "Pause after repeated failures, capture one root error, then adjust strategy.",
132
+ penalty: 14,
133
+ });
134
+ }
135
+ const largestPrompt = events
136
+ .filter((e) => e.role === "user_prompt")
137
+ .reduce((max, cur) => Math.max(max, cur.estimatedTotalTokens), 0);
138
+ if (largestPrompt > 8000) {
139
+ redFlags.push({
140
+ severity: "MEDIUM",
141
+ title: "oversized prompt",
142
+ detail: `Largest prompt was ${formatTokens(largestPrompt)}.`,
143
+ recommendation: "Split goals into smaller requests and load reference docs on demand.",
144
+ penalty: 8,
145
+ });
146
+ }
147
+ if (total > 12000 && fileEdits === 0) {
148
+ redFlags.push({
149
+ severity: "MEDIUM",
150
+ title: "low-signal session",
151
+ detail: `High observable usage (${formatTokens(total)}) with no file edits.`,
152
+ recommendation: "Push for earlier implementation checkpoints instead of extended analysis.",
153
+ penalty: 10,
154
+ });
155
+ }
156
+ const contextAudit = runContextAudit(options.contextAuditRoot);
157
+ if (contextAudit.totalEstimatedTokens > 6000) {
158
+ redFlags.push({
159
+ severity: "MEDIUM",
160
+ title: "context bloat",
161
+ detail: `Always-on instruction files estimate ${formatTokens(contextAudit.totalEstimatedTokens)}.`,
162
+ recommendation: "Move large static references into on-demand skills or targeted commands.",
163
+ penalty: 10,
164
+ });
165
+ }
166
+ for (const flag of redFlags)
167
+ score -= flag.penalty;
168
+ score = Math.max(0, score);
169
+ recommendations = [...new Set(redFlags.map((r) => r.recommendation))];
170
+ if (recommendations.length === 0) {
171
+ recommendations = [
172
+ "No major waste pattern detected. Keep prompts scoped and continue tracking trends.",
173
+ ];
174
+ }
175
+ const largestEvents = [...events]
176
+ .sort((a, b) => b.estimatedTotalTokens - a.estimatedTotalTokens)
177
+ .slice(0, LARGEST_EVENTS_LIMIT)
178
+ .map((e) => ({
179
+ role: e.role,
180
+ sourceEvent: e.sourceEvent,
181
+ estimatedTotalTokens: e.estimatedTotalTokens,
182
+ }));
183
+ return {
184
+ source: first.source,
185
+ sessionKey: sessionKeyLabel(first),
186
+ repo: first.repoPath ?? options.contextAuditRoot,
187
+ durationMinutes,
188
+ usage: { input, output, toolResults, shellOutput, total },
189
+ sessionShape: {
190
+ turns: turnIds.size,
191
+ fileEdits,
192
+ shellCalls,
193
+ toolCalls,
194
+ },
195
+ largestEvents,
196
+ efficiencyScore: score,
197
+ redFlags: redFlags.map((flag) => ({
198
+ severity: flag.severity,
199
+ title: flag.title,
200
+ detail: flag.detail,
201
+ })),
202
+ recommendations,
203
+ };
204
+ }