noctrace 0.7.4 → 0.8.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/.claude-plugin/plugin.json +7 -7
- package/README.md +67 -38
- package/dist/client/assets/index-DwPuae45.css +2 -0
- package/dist/client/assets/index-x60cSMi2.js +30 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/routes/api.js +79 -4
- package/dist/server/server/watcher.js +2 -1
- package/dist/server/server/ws.js +1 -1
- package/dist/server/shared/otlp-export.js +116 -0
- package/dist/server/shared/parser.js +132 -16
- package/dist/server/shared/reliability.js +186 -0
- package/dist/server/shared/session-metadata.js +161 -0
- package/hooks/hooks.json +33 -0
- package/package.json +15 -6
- package/dist/client/assets/index-BPKebIZj.js +0 -30
- package/dist/client/assets/index-DyjeNSzP.css +0 -2
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
* Extract the first plausible file path from a label string.
|
|
16
|
+
* Matches Unix-style absolute paths or relative paths containing a `/`.
|
|
17
|
+
* Returns null when no path-like token is found.
|
|
18
|
+
*/
|
|
19
|
+
function extractFilePath(label) {
|
|
20
|
+
// Match tokens that look like file paths:
|
|
21
|
+
// - absolute: /foo/bar.ts
|
|
22
|
+
// - relative with directory: src/foo.ts
|
|
23
|
+
// We split on spaces/colons/parens and look for tokens containing '/'
|
|
24
|
+
const tokens = label.split(/[\s:()[\],"']+/);
|
|
25
|
+
for (const token of tokens) {
|
|
26
|
+
if (token.length === 0)
|
|
27
|
+
continue;
|
|
28
|
+
// Must contain a slash and at least one dot (heuristic for real paths)
|
|
29
|
+
if (token.includes('/') && token.includes('.')) {
|
|
30
|
+
return token;
|
|
31
|
+
}
|
|
32
|
+
// Also accept absolute paths without a dot (e.g. /usr/local/bin/node)
|
|
33
|
+
if (token.startsWith('/') && token.includes('/')) {
|
|
34
|
+
return token;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Compute per-tool and session-level reliability statistics.
|
|
41
|
+
*
|
|
42
|
+
* Rows with status 'running' are excluded from all counts — they have not
|
|
43
|
+
* yet completed and would skew success/error ratios.
|
|
44
|
+
*
|
|
45
|
+
* @param rows - Top-level WaterfallRow array from the session parser.
|
|
46
|
+
* @returns {@link SessionReliability} with per-tool and aggregate metrics.
|
|
47
|
+
*/
|
|
48
|
+
export function computeReliability(rows) {
|
|
49
|
+
const flat = flattenRows(rows);
|
|
50
|
+
// Only consider completed rows (skip running)
|
|
51
|
+
const completed = flat.filter((r) => r.status !== 'running');
|
|
52
|
+
if (completed.length === 0) {
|
|
53
|
+
return {
|
|
54
|
+
totalCalls: 0,
|
|
55
|
+
successCount: 0,
|
|
56
|
+
errorCount: 0,
|
|
57
|
+
failureCount: 0,
|
|
58
|
+
overallReliability: 100,
|
|
59
|
+
errorDensity: 0,
|
|
60
|
+
recoveryAttempts: 0,
|
|
61
|
+
recoverySuccesses: 0,
|
|
62
|
+
recoveryRate: 0,
|
|
63
|
+
avgErrorsBeforeFix: 0,
|
|
64
|
+
toolReliability: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
// Overall counts
|
|
69
|
+
// -------------------------------------------------------------------------
|
|
70
|
+
let successCount = 0;
|
|
71
|
+
let errorCount = 0;
|
|
72
|
+
let failureCount = 0;
|
|
73
|
+
for (const row of completed) {
|
|
74
|
+
if (row.isFailure) {
|
|
75
|
+
failureCount++;
|
|
76
|
+
}
|
|
77
|
+
else if (row.status === 'error') {
|
|
78
|
+
errorCount++;
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
successCount++;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const totalCalls = completed.length;
|
|
85
|
+
const overallReliability = (successCount / totalCalls) * 100;
|
|
86
|
+
// -------------------------------------------------------------------------
|
|
87
|
+
// Error density: errors per 10 calls
|
|
88
|
+
// -------------------------------------------------------------------------
|
|
89
|
+
const errorDensity = totalCalls > 0 ? (errorCount / totalCalls) * 10 : 0;
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// Recovery rate: error→same-tool-success sequences
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
// Build a map from toolName (lowercased) to the ordered list of completed rows
|
|
94
|
+
const toolSequences = new Map();
|
|
95
|
+
for (const row of completed) {
|
|
96
|
+
const key = row.toolName.toLowerCase();
|
|
97
|
+
if (!toolSequences.has(key))
|
|
98
|
+
toolSequences.set(key, []);
|
|
99
|
+
toolSequences.get(key).push(row);
|
|
100
|
+
}
|
|
101
|
+
let recoveryAttempts = 0;
|
|
102
|
+
let recoverySuccesses = 0;
|
|
103
|
+
for (const [, sequence] of toolSequences) {
|
|
104
|
+
for (let i = 0; i < sequence.length - 1; i++) {
|
|
105
|
+
if (sequence[i].status === 'error') {
|
|
106
|
+
// Look for the immediately next row in the same-tool sequence
|
|
107
|
+
recoveryAttempts++;
|
|
108
|
+
if (sequence[i + 1].status === 'success') {
|
|
109
|
+
recoverySuccesses++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const recoveryRate = recoveryAttempts > 0 ? (recoverySuccesses / recoveryAttempts) * 100 : 0;
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
// Error-to-fix ratio: avg errors before first success, per file
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
// Group rows by extracted file path
|
|
119
|
+
const fileRows = new Map();
|
|
120
|
+
for (const row of completed) {
|
|
121
|
+
const filePath = extractFilePath(row.label);
|
|
122
|
+
if (filePath === null)
|
|
123
|
+
continue;
|
|
124
|
+
if (!fileRows.has(filePath))
|
|
125
|
+
fileRows.set(filePath, []);
|
|
126
|
+
fileRows.get(filePath).push(row);
|
|
127
|
+
}
|
|
128
|
+
const errorsBeforeFixCounts = [];
|
|
129
|
+
for (const [, fileSeq] of fileRows) {
|
|
130
|
+
// Find the index of the first success
|
|
131
|
+
const firstSuccessIdx = fileSeq.findIndex((r) => r.status === 'success');
|
|
132
|
+
if (firstSuccessIdx <= 0)
|
|
133
|
+
continue; // no success, or success was first — skip
|
|
134
|
+
// Count errors before the first success
|
|
135
|
+
const errorsBeforeSuccess = fileSeq
|
|
136
|
+
.slice(0, firstSuccessIdx)
|
|
137
|
+
.filter((r) => r.status === 'error').length;
|
|
138
|
+
if (errorsBeforeSuccess === 0)
|
|
139
|
+
continue; // no errors before success — skip
|
|
140
|
+
errorsBeforeFixCounts.push(errorsBeforeSuccess);
|
|
141
|
+
}
|
|
142
|
+
const avgErrorsBeforeFix = errorsBeforeFixCounts.length > 0
|
|
143
|
+
? errorsBeforeFixCounts.reduce((a, b) => a + b, 0) / errorsBeforeFixCounts.length
|
|
144
|
+
: 0;
|
|
145
|
+
// -------------------------------------------------------------------------
|
|
146
|
+
// Per-tool reliability
|
|
147
|
+
// -------------------------------------------------------------------------
|
|
148
|
+
const toolGroups = new Map();
|
|
149
|
+
for (const row of completed) {
|
|
150
|
+
const key = row.toolName.toLowerCase();
|
|
151
|
+
if (!toolGroups.has(key)) {
|
|
152
|
+
toolGroups.set(key, { originalName: row.toolName, success: 0, errors: 0, failures: 0 });
|
|
153
|
+
}
|
|
154
|
+
const entry = toolGroups.get(key);
|
|
155
|
+
if (row.isFailure) {
|
|
156
|
+
entry.failures++;
|
|
157
|
+
}
|
|
158
|
+
else if (row.status === 'error') {
|
|
159
|
+
entry.errors++;
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
entry.success++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const toolReliability = [];
|
|
166
|
+
for (const [, { originalName, success, errors, failures }] of toolGroups) {
|
|
167
|
+
const total = success + errors + failures;
|
|
168
|
+
const reliability = total > 0 ? (success / total) * 100 : 100;
|
|
169
|
+
toolReliability.push({ toolName: originalName, total, success, errors, failures, reliability });
|
|
170
|
+
}
|
|
171
|
+
// Sort ascending by reliability — worst tools first
|
|
172
|
+
toolReliability.sort((a, b) => a.reliability - b.reliability);
|
|
173
|
+
return {
|
|
174
|
+
totalCalls,
|
|
175
|
+
successCount,
|
|
176
|
+
errorCount,
|
|
177
|
+
failureCount,
|
|
178
|
+
overallReliability,
|
|
179
|
+
errorDensity,
|
|
180
|
+
recoveryAttempts,
|
|
181
|
+
recoverySuccesses,
|
|
182
|
+
recoveryRate,
|
|
183
|
+
avgErrorsBeforeFix,
|
|
184
|
+
toolReliability,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
function isObj(val) {
|
|
2
|
+
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Extract compaction boundaries with optional metadata from JSONL content.
|
|
6
|
+
* Returns CompactionBoundary[] with trigger type and pre-compaction token count.
|
|
7
|
+
*/
|
|
8
|
+
export function parseCompactionBoundaries(content) {
|
|
9
|
+
const lines = content.split('\n');
|
|
10
|
+
const out = [];
|
|
11
|
+
for (const line of lines) {
|
|
12
|
+
const t = line.trim();
|
|
13
|
+
if (!t)
|
|
14
|
+
continue;
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(t);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (!isObj(parsed))
|
|
23
|
+
continue;
|
|
24
|
+
if (parsed['type'] !== 'system')
|
|
25
|
+
continue;
|
|
26
|
+
if (parsed['subtype'] !== 'compact_boundary')
|
|
27
|
+
continue;
|
|
28
|
+
const timestamp = typeof parsed['timestamp'] === 'string'
|
|
29
|
+
? new Date(parsed['timestamp']).getTime() : Date.now();
|
|
30
|
+
// Extract compact_metadata if present
|
|
31
|
+
const meta = isObj(parsed['compact_metadata']) ? parsed['compact_metadata'] : null;
|
|
32
|
+
const trigger = meta && (meta['trigger'] === 'manual' || meta['trigger'] === 'auto')
|
|
33
|
+
? meta['trigger'] : null;
|
|
34
|
+
const preTokens = meta && typeof meta['pre_tokens'] === 'number' ? meta['pre_tokens'] : null;
|
|
35
|
+
out.push({ timestamp, trigger, preTokens });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Parse the terminal result record from JSONL content.
|
|
41
|
+
* Extracts duration_api_ms, modelUsage, stop_reason, permission_denials.
|
|
42
|
+
*/
|
|
43
|
+
export function parseSessionResultMetrics(content) {
|
|
44
|
+
const defaults = {
|
|
45
|
+
durationApiMs: null,
|
|
46
|
+
modelUsage: [],
|
|
47
|
+
stopReason: null,
|
|
48
|
+
permissionDenialCount: 0,
|
|
49
|
+
};
|
|
50
|
+
const lines = content.split('\n');
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const t = line.trim();
|
|
53
|
+
if (!t)
|
|
54
|
+
continue;
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(t);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!isObj(parsed))
|
|
63
|
+
continue;
|
|
64
|
+
if (parsed['type'] !== 'result')
|
|
65
|
+
continue;
|
|
66
|
+
// duration_api_ms
|
|
67
|
+
if (typeof parsed['duration_api_ms'] === 'number') {
|
|
68
|
+
defaults.durationApiMs = parsed['duration_api_ms'];
|
|
69
|
+
}
|
|
70
|
+
// stop_reason
|
|
71
|
+
const sr = parsed['stop_reason'];
|
|
72
|
+
if (sr === 'end_turn' || sr === 'max_tokens' || sr === 'refusal') {
|
|
73
|
+
defaults.stopReason = sr;
|
|
74
|
+
}
|
|
75
|
+
// permission_denials
|
|
76
|
+
if (Array.isArray(parsed['permission_denials'])) {
|
|
77
|
+
defaults.permissionDenialCount = parsed['permission_denials'].length;
|
|
78
|
+
}
|
|
79
|
+
// modelUsage — { [modelName]: { input_tokens, output_tokens, cache_read_input_tokens?, cache_creation_input_tokens? } }
|
|
80
|
+
const mu = parsed['modelUsage'];
|
|
81
|
+
if (isObj(mu)) {
|
|
82
|
+
const entries = [];
|
|
83
|
+
for (const [model, usage] of Object.entries(mu)) {
|
|
84
|
+
if (!isObj(usage))
|
|
85
|
+
continue;
|
|
86
|
+
entries.push({
|
|
87
|
+
model,
|
|
88
|
+
inputTokens: typeof usage['input_tokens'] === 'number' ? usage['input_tokens'] : 0,
|
|
89
|
+
outputTokens: typeof usage['output_tokens'] === 'number' ? usage['output_tokens'] : 0,
|
|
90
|
+
cacheReadTokens: typeof usage['cache_read_input_tokens'] === 'number' ? usage['cache_read_input_tokens'] : 0,
|
|
91
|
+
cacheCreateTokens: typeof usage['cache_creation_input_tokens'] === 'number' ? usage['cache_creation_input_tokens'] : 0,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
defaults.modelUsage = entries;
|
|
95
|
+
}
|
|
96
|
+
// Only one result record per session — stop scanning
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
return defaults;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse session init context (agents, skills, plugins, effort) from JSONL content.
|
|
103
|
+
* Reads the first system init record.
|
|
104
|
+
*/
|
|
105
|
+
export function parseInitContext(content) {
|
|
106
|
+
const defaults = { agents: [], skills: [], plugins: [], effort: null };
|
|
107
|
+
const lines = content.split('\n');
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
const t = line.trim();
|
|
110
|
+
if (!t)
|
|
111
|
+
continue;
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(t);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!isObj(parsed))
|
|
120
|
+
continue;
|
|
121
|
+
if (parsed['type'] !== 'system')
|
|
122
|
+
continue;
|
|
123
|
+
// Match init records — subtype may be 'init' or absent on the first system record
|
|
124
|
+
const subtype = parsed['subtype'];
|
|
125
|
+
if (subtype !== undefined && subtype !== 'init')
|
|
126
|
+
continue;
|
|
127
|
+
// agents
|
|
128
|
+
if (Array.isArray(parsed['agents'])) {
|
|
129
|
+
defaults.agents = parsed['agents'].filter((a) => typeof a === 'string');
|
|
130
|
+
}
|
|
131
|
+
// skills
|
|
132
|
+
if (Array.isArray(parsed['skills'])) {
|
|
133
|
+
defaults.skills = parsed['skills'].filter((s) => typeof s === 'string');
|
|
134
|
+
}
|
|
135
|
+
// plugins
|
|
136
|
+
if (Array.isArray(parsed['plugins'])) {
|
|
137
|
+
for (const p of parsed['plugins']) {
|
|
138
|
+
if (isObj(p) && typeof p['name'] === 'string') {
|
|
139
|
+
defaults.plugins.push({
|
|
140
|
+
name: p['name'],
|
|
141
|
+
path: typeof p['path'] === 'string' ? p['path'] : '',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// effort level
|
|
147
|
+
if (typeof parsed['effort'] === 'string') {
|
|
148
|
+
defaults.effort = parsed['effort'];
|
|
149
|
+
}
|
|
150
|
+
// Also check nested config field where effort might appear
|
|
151
|
+
if (defaults.effort === null && isObj(parsed['config'])) {
|
|
152
|
+
const config = parsed['config'];
|
|
153
|
+
if (typeof config['effort'] === 'string') {
|
|
154
|
+
defaults.effort = config['effort'];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Only read the first init record
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
return defaults;
|
|
161
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -64,5 +64,38 @@
|
|
|
64
64
|
}
|
|
65
65
|
]
|
|
66
66
|
}
|
|
67
|
+
],
|
|
68
|
+
"SessionStart": [
|
|
69
|
+
{
|
|
70
|
+
"hooks": [
|
|
71
|
+
{
|
|
72
|
+
"type": "command",
|
|
73
|
+
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
74
|
+
"async": true
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
],
|
|
79
|
+
"SessionEnd": [
|
|
80
|
+
{
|
|
81
|
+
"hooks": [
|
|
82
|
+
{
|
|
83
|
+
"type": "command",
|
|
84
|
+
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
85
|
+
"async": true
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"PostToolUseFailure": [
|
|
91
|
+
{
|
|
92
|
+
"hooks": [
|
|
93
|
+
{
|
|
94
|
+
"type": "command",
|
|
95
|
+
"command": "curl -s -X POST http://localhost:4117/api/hooks -H 'Content-Type: application/json' --data-raw \"$(cat)\"",
|
|
96
|
+
"async": true
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
67
100
|
]
|
|
68
101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noctrace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Chrome DevTools Network-tab-style waterfall visualizer for Claude Code agent workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,17 +17,26 @@
|
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
20
|
-
"
|
|
20
|
+
"noctrace",
|
|
21
21
|
"claude-code",
|
|
22
|
+
"claude",
|
|
22
23
|
"anthropic",
|
|
24
|
+
"observability",
|
|
23
25
|
"devtools",
|
|
24
26
|
"waterfall",
|
|
25
|
-
"
|
|
26
|
-
"agent",
|
|
27
|
-
"ai",
|
|
27
|
+
"ai-agent",
|
|
28
28
|
"llm",
|
|
29
29
|
"timeline",
|
|
30
|
-
"visualizer"
|
|
30
|
+
"visualizer",
|
|
31
|
+
"claude-code-hooks",
|
|
32
|
+
"token-usage",
|
|
33
|
+
"context-window",
|
|
34
|
+
"agent-monitoring",
|
|
35
|
+
"ai-observability",
|
|
36
|
+
"claude-code-devtools",
|
|
37
|
+
"session-viewer",
|
|
38
|
+
"tool-calls",
|
|
39
|
+
"sub-agents"
|
|
31
40
|
],
|
|
32
41
|
"bin": {
|
|
33
42
|
"noctrace": "bin/noctrace.js"
|