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.
- package/README.md +9 -3
- package/dist/client/assets/index-C4vi082v.css +2 -0
- package/dist/client/assets/index-DqY0cF0g.js +30 -0
- package/dist/client/index.html +2 -2
- package/dist/server/shared/filter.js +172 -0
- package/dist/server/shared/latency-stats.js +76 -0
- package/dist/server/shared/session-compare.js +89 -0
- package/dist/server/shared/tips.js +36 -7
- package/package.json +1 -1
- package/dist/client/assets/index-CfD3iTQS.js +0 -30
- package/dist/client/assets/index-nE1IMGzA.css +0 -2
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
39
|
-
* 2. Search fan-out
|
|
40
|
-
* 3. Correction loop
|
|
41
|
-
* 4. Repeated Bash
|
|
42
|
-
* 5. Large token spike
|
|
43
|
-
* 6. High context fill
|
|
44
|
-
* 7. No delegation
|
|
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)
|