noctrace 0.5.0 → 0.6.0

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.
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
9
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
10
- <script type="module" crossorigin src="/assets/index-CfD3iTQS.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-nE1IMGzA.css">
10
+ <script type="module" crossorigin src="/assets/index-DqY0cF0g.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-C4vi082v.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
@@ -0,0 +1,172 @@
1
+ /** Special keywords that map directly to status or type filters */
2
+ const STATUS_KEYWORDS = new Set(['error', 'running', 'success']);
3
+ /**
4
+ * Parses a raw filter string into structured filter components.
5
+ * Tokens are space-separated. Unrecognised tokens become textTokens.
6
+ */
7
+ export function parseFilterString(filter) {
8
+ const result = {
9
+ textTokens: [],
10
+ typeFilters: [],
11
+ minDuration: null,
12
+ maxDuration: null,
13
+ statusFilters: [],
14
+ minTokens: null,
15
+ maxTokens: null,
16
+ };
17
+ if (!filter.trim())
18
+ return result;
19
+ const tokens = filter.trim().split(/\s+/);
20
+ for (const token of tokens) {
21
+ const lower = token.toLowerCase();
22
+ // type:xxx
23
+ if (lower.startsWith('type:')) {
24
+ const typeName = lower.slice(5);
25
+ if (typeName)
26
+ result.typeFilters.push(typeName);
27
+ continue;
28
+ }
29
+ // tokens:>NNN or tokens:<NNN
30
+ if (lower.startsWith('tokens:')) {
31
+ const rest = lower.slice(7);
32
+ if (rest.startsWith('>')) {
33
+ const n = parseTokenCount(rest.slice(1));
34
+ if (n !== null)
35
+ result.minTokens = n;
36
+ }
37
+ else if (rest.startsWith('<')) {
38
+ const n = parseTokenCount(rest.slice(1));
39
+ if (n !== null)
40
+ result.maxTokens = n;
41
+ }
42
+ continue;
43
+ }
44
+ // >NNNms or >NNNs or >NNN (seconds default)
45
+ if (lower.startsWith('>')) {
46
+ const ms = parseDuration(lower.slice(1));
47
+ if (ms !== null) {
48
+ result.minDuration = ms;
49
+ continue;
50
+ }
51
+ }
52
+ // <NNNms or <NNNs or <NNN (seconds default)
53
+ if (lower.startsWith('<')) {
54
+ const ms = parseDuration(lower.slice(1));
55
+ if (ms !== null) {
56
+ result.maxDuration = ms;
57
+ continue;
58
+ }
59
+ }
60
+ // Special status keywords (backward compat)
61
+ if (STATUS_KEYWORDS.has(lower)) {
62
+ result.statusFilters.push(lower);
63
+ continue;
64
+ }
65
+ // 'agent' keyword: treated as type filter for backward compat
66
+ if (lower === 'agent') {
67
+ result.typeFilters.push('agent');
68
+ continue;
69
+ }
70
+ // Everything else is free text
71
+ result.textTokens.push(lower);
72
+ }
73
+ return result;
74
+ }
75
+ /**
76
+ * Parses a duration string like "5s", "100ms", or "5" (default seconds).
77
+ * Returns milliseconds, or null if the string is not a valid duration.
78
+ */
79
+ function parseDuration(s) {
80
+ if (s.endsWith('ms')) {
81
+ const n = Number(s.slice(0, -2));
82
+ return isNaN(n) ? null : n;
83
+ }
84
+ if (s.endsWith('s')) {
85
+ const n = Number(s.slice(0, -1));
86
+ return isNaN(n) ? null : n * 1000;
87
+ }
88
+ // No unit — treat as seconds
89
+ const n = Number(s);
90
+ return isNaN(n) ? null : n * 1000;
91
+ }
92
+ /**
93
+ * Parses a token count string. Supports plain integers and k/m suffixes (e.g., "1k", "1.5m").
94
+ * Returns null if the string is not parseable.
95
+ */
96
+ function parseTokenCount(s) {
97
+ const lower = s.toLowerCase();
98
+ if (lower.endsWith('k')) {
99
+ const n = Number(lower.slice(0, -1));
100
+ return isNaN(n) ? null : Math.round(n * 1000);
101
+ }
102
+ if (lower.endsWith('m')) {
103
+ const n = Number(lower.slice(0, -1));
104
+ return isNaN(n) ? null : Math.round(n * 1_000_000);
105
+ }
106
+ const n = Number(lower);
107
+ return isNaN(n) ? null : n;
108
+ }
109
+ /**
110
+ * Tests whether a row matches a pre-parsed filter.
111
+ * All active filter fields are AND-ed; multiple typeFilters are OR-ed.
112
+ * Agent rows also match when any of their children match.
113
+ */
114
+ export function rowMatchesFilter(row, parsed) {
115
+ // Empty filter — everything matches
116
+ const hasAnyFilter = parsed.textTokens.length > 0 ||
117
+ parsed.typeFilters.length > 0 ||
118
+ parsed.minDuration !== null ||
119
+ parsed.maxDuration !== null ||
120
+ parsed.statusFilters.length > 0 ||
121
+ parsed.minTokens !== null ||
122
+ parsed.maxTokens !== null;
123
+ if (!hasAnyFilter)
124
+ return true;
125
+ // For agent rows, also return true if any child matches
126
+ if (row.type === 'agent' && row.children.length > 0) {
127
+ if (row.children.some((child) => rowMatchesFilter(child, parsed)))
128
+ return true;
129
+ }
130
+ return rowMatchesDirect(row, parsed);
131
+ }
132
+ /** Tests a single row (no child traversal) against the parsed filter. */
133
+ function rowMatchesDirect(row, parsed) {
134
+ // Status filter
135
+ if (parsed.statusFilters.length > 0) {
136
+ if (!parsed.statusFilters.includes(row.status))
137
+ return false;
138
+ }
139
+ // Type filter (OR among typeFilters)
140
+ if (parsed.typeFilters.length > 0) {
141
+ const rowTypeLower = row.toolName.toLowerCase();
142
+ const rowKind = row.type; // 'agent' | 'tool'
143
+ const matched = parsed.typeFilters.some((tf) => rowTypeLower === tf || rowTypeLower.includes(tf) || rowKind === tf);
144
+ if (!matched)
145
+ return false;
146
+ }
147
+ // Duration filter
148
+ if (parsed.minDuration !== null) {
149
+ if (row.duration === null || row.duration < parsed.minDuration)
150
+ return false;
151
+ }
152
+ if (parsed.maxDuration !== null) {
153
+ if (row.duration === null || row.duration > parsed.maxDuration)
154
+ return false;
155
+ }
156
+ // Token delta filter
157
+ if (parsed.minTokens !== null) {
158
+ if (row.tokenDelta < parsed.minTokens)
159
+ return false;
160
+ }
161
+ if (parsed.maxTokens !== null) {
162
+ if (row.tokenDelta > parsed.maxTokens)
163
+ return false;
164
+ }
165
+ // Free-text filter
166
+ if (parsed.textTokens.length > 0) {
167
+ const haystack = (row.toolName + ' ' + row.label).toLowerCase();
168
+ if (!parsed.textTokens.every((t) => haystack.includes(t)))
169
+ return false;
170
+ }
171
+ return true;
172
+ }
@@ -0,0 +1,76 @@
1
+ /** Collect all rows (including nested children) into a flat list. */
2
+ function flattenRows(rows) {
3
+ const result = [];
4
+ const walk = (list) => {
5
+ for (const row of list) {
6
+ result.push(row);
7
+ if (row.children.length > 0)
8
+ walk(row.children);
9
+ }
10
+ };
11
+ walk(rows);
12
+ return result;
13
+ }
14
+ /**
15
+ * Compute a percentile value from a sorted array of numbers.
16
+ *
17
+ * Uses the nearest-rank method: index = Math.floor((n - 1) * p).
18
+ * The array must be sorted in ascending order.
19
+ */
20
+ function percentile(sorted, p) {
21
+ if (sorted.length === 0)
22
+ return 0;
23
+ const idx = Math.floor((sorted.length - 1) * p);
24
+ return sorted[idx];
25
+ }
26
+ /**
27
+ * Compute per-tool latency statistics for a session.
28
+ *
29
+ * Flattens all rows (including nested children), skips rows without a
30
+ * completed duration, groups by normalized tool name (lowercased), and
31
+ * computes P50, P95, max, and total for each group. Also collects row IDs
32
+ * that exceed the provided slow threshold.
33
+ *
34
+ * @param rows - Top-level WaterfallRow array from the session parser.
35
+ * @param slowThresholdMs - Duration threshold in milliseconds above which a
36
+ * row is considered slow and included in `slowCallIds`.
37
+ * @returns {@link SessionLatencyStats} with tool breakdowns and slow call IDs.
38
+ */
39
+ export function computeLatencyStats(rows, slowThresholdMs) {
40
+ const flat = flattenRows(rows);
41
+ // Group durations and collect slow call IDs in one pass.
42
+ const groups = new Map();
43
+ const slowCallIds = [];
44
+ for (const row of flat) {
45
+ if (row.duration === null)
46
+ continue;
47
+ const key = row.toolName.toLowerCase();
48
+ if (!groups.has(key)) {
49
+ groups.set(key, { durations: [], originalName: row.toolName });
50
+ }
51
+ // Non-null assertion is safe: we just set it above if missing.
52
+ groups.get(key).durations.push(row.duration);
53
+ if (row.duration > slowThresholdMs) {
54
+ slowCallIds.push(row.id);
55
+ }
56
+ }
57
+ // Build per-tool stats.
58
+ const toolStats = [];
59
+ for (const [, { durations, originalName }] of groups) {
60
+ durations.sort((a, b) => a - b);
61
+ const total = durations.reduce((sum, d) => sum + d, 0);
62
+ toolStats.push({
63
+ toolName: originalName,
64
+ count: durations.length,
65
+ p50: percentile(durations, 0.5),
66
+ p95: percentile(durations, 0.95),
67
+ max: durations[durations.length - 1],
68
+ total,
69
+ });
70
+ }
71
+ // Sort by total duration descending.
72
+ toolStats.sort((a, b) => b.total - a.total);
73
+ const totalCalls = toolStats.reduce((sum, s) => sum + s.count, 0);
74
+ const totalDuration = toolStats.reduce((sum, s) => sum + s.total, 0);
75
+ return { toolStats, totalCalls, totalDuration, slowCallIds };
76
+ }
@@ -0,0 +1,89 @@
1
+ /** Collect all rows (including nested children) into a flat list. */
2
+ function flattenRows(rows) {
3
+ const result = [];
4
+ const walk = (list) => {
5
+ for (const row of list) {
6
+ result.push(row);
7
+ if (row.children.length > 0)
8
+ walk(row.children);
9
+ }
10
+ };
11
+ walk(rows);
12
+ return result;
13
+ }
14
+ /**
15
+ * Compute aggregated metrics for a session's waterfall rows.
16
+ *
17
+ * Accepts the top-level WaterfallRow array and the session's ContextHealth
18
+ * (may be null for sessions that haven't been scored yet). Returns a
19
+ * {@link SessionMetrics} object suitable for display and delta computation.
20
+ */
21
+ export function computeSessionMetrics(rows, health) {
22
+ if (rows.length === 0) {
23
+ return {
24
+ totalDuration: 0,
25
+ totalTokens: 0,
26
+ totalCalls: 0,
27
+ errorCount: 0,
28
+ errorRate: 0,
29
+ toolMix: {},
30
+ healthGrade: health?.grade ?? 'A',
31
+ healthScore: health?.score ?? 100,
32
+ contextFillTimeline: [],
33
+ };
34
+ }
35
+ const flat = flattenRows(rows);
36
+ let totalTokens = 0;
37
+ let errorCount = 0;
38
+ let minStart = Infinity;
39
+ let maxEnd = -Infinity;
40
+ const toolMix = {};
41
+ const contextFillTimeline = [];
42
+ for (const row of flat) {
43
+ totalTokens += row.inputTokens + row.outputTokens;
44
+ if (row.status === 'error')
45
+ errorCount++;
46
+ if (row.startTime < minStart)
47
+ minStart = row.startTime;
48
+ const end = row.endTime ?? row.startTime;
49
+ if (end > maxEnd)
50
+ maxEnd = end;
51
+ // Count tool mix (use original toolName casing)
52
+ const name = row.toolName;
53
+ toolMix[name] = (toolMix[name] ?? 0) + 1;
54
+ // Collect context fill trajectory — skip zero/unset values
55
+ if (row.contextFillPercent > 0) {
56
+ contextFillTimeline.push(row.contextFillPercent);
57
+ }
58
+ }
59
+ const totalCalls = flat.length;
60
+ const errorRate = totalCalls > 0 ? errorCount / totalCalls : 0;
61
+ const totalDuration = maxEnd > minStart ? maxEnd - minStart : 0;
62
+ return {
63
+ totalDuration,
64
+ totalTokens,
65
+ totalCalls,
66
+ errorCount,
67
+ errorRate,
68
+ toolMix,
69
+ healthGrade: health?.grade ?? 'A',
70
+ healthScore: health?.score ?? 100,
71
+ contextFillTimeline,
72
+ };
73
+ }
74
+ /**
75
+ * Compute the numeric deltas between two sessions (right minus left).
76
+ *
77
+ * A negative durationDelta means the right session completed faster.
78
+ * A negative errorRateDelta means the right session had fewer errors.
79
+ * These semantics let the UI consistently color negative as green (better)
80
+ * and positive as red (worse) for duration, tokens, calls, and error rate.
81
+ */
82
+ export function compareSessionMetrics(left, right) {
83
+ return {
84
+ durationDelta: right.totalDuration - left.totalDuration,
85
+ tokenDelta: right.totalTokens - left.totalTokens,
86
+ callDelta: right.totalCalls - left.totalCalls,
87
+ errorRateDelta: right.errorRate - left.errorRate,
88
+ };
89
+ }
@@ -35,15 +35,16 @@ function flattenRows(rows) {
35
35
  * Analyze rows and attach efficiency tips to wasteful ones. Mutates rows in place.
36
36
  *
37
37
  * Detection rules:
38
- * 1. Re-read — row.isReread is true
39
- * 2. Search fan-out — 5+ consecutive search/read tools with no write between them
40
- * 3. Correction loop — 3+ edits to the same file_path
41
- * 4. Repeated Bash — same command string run 2+ times
42
- * 5. Large token spike — row.tokenDelta > 10 000
43
- * 6. High context fill — first row where contextFillPercent >= 80
44
- * 7. No delegation — 50+ total tool rows with zero agent rows
38
+ * 1. Re-read — row.isReread is true
39
+ * 2. Search fan-out — 5+ consecutive search/read tools with no write between them
40
+ * 3. Correction loop — 3+ edits to the same file_path
41
+ * 4. Repeated Bash — same command string run 2+ times
42
+ * 5. Large token spike — row.tokenDelta > 10 000
43
+ * 6. High context fill — first row where contextFillPercent >= 80
44
+ * 7. No delegation — 50+ total tool rows with zero agent rows
45
45
  * 8. Post-compaction re-read — isReread after a compaction boundary
46
46
  * 9. Compaction thrash — 3+ compaction boundaries (thrash loop)
47
+ * 10. Identical tool loop — 3+ consecutive calls with the same toolName + input key
47
48
  *
48
49
  * A row may accumulate multiple tips. Duplicate tip ids on the same row are silently skipped.
49
50
  *
@@ -71,6 +72,12 @@ export function attachEfficiencyTips(rows, compactionBoundaries) {
71
72
  const bashCounts = new Map();
72
73
  /** Whether we have already attached the high-fill tip. */
73
74
  let highFillAttached = false;
75
+ /**
76
+ * State for Rule 10: Identical tool loop.
77
+ * Tracks the key of the last tool call and the consecutive run length.
78
+ */
79
+ let lastToolKey = null;
80
+ let identicalToolStreak = 0;
74
81
  /** Total tool rows (type === 'tool') across the session. */
75
82
  let toolRowCount = 0;
76
83
  /** Whether any agent row exists in the session. */
@@ -194,6 +201,28 @@ export function attachEfficiencyTips(rows, compactionBoundaries) {
194
201
  severity: 'critical',
195
202
  });
196
203
  }
204
+ // ------------------------------------------------------------------
205
+ // Rule 10: Identical tool loop
206
+ // Track 3+ consecutive tool calls with the same toolName + input key.
207
+ // Use first 200 chars of stringified input to avoid perf issues on
208
+ // large inputs while still catching the common stuck-loop pattern.
209
+ // ------------------------------------------------------------------
210
+ const inputKey = row.toolName + ':' + JSON.stringify(row.input).slice(0, 200);
211
+ if (inputKey === lastToolKey) {
212
+ identicalToolStreak++;
213
+ if (identicalToolStreak >= 3) {
214
+ addTip(row, {
215
+ id: 'identical-loop',
216
+ title: 'Identical tool loop',
217
+ message: 'Same tool called with identical input 3 times consecutively — the agent may be stuck in a loop.',
218
+ severity: 'warning',
219
+ });
220
+ }
221
+ }
222
+ else {
223
+ lastToolKey = inputKey;
224
+ identicalToolStreak = 1;
225
+ }
197
226
  }
198
227
  // -------------------------------------------------------------------------
199
228
  // Rule 7: No delegation (post-scan)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noctrace",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",