noctrace 1.1.0 → 1.3.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 +1 -1
- package/README.md +1 -0
- package/dist/client/assets/index-BGW0xA7n.js +30 -0
- package/dist/client/assets/index-DlKrxvV-.css +2 -0
- package/dist/client/index.html +2 -2
- package/dist/server/server/index.js +2 -0
- package/dist/server/server/rollup.js +330 -0
- package/dist/server/server/routes/api.js +30 -9
- package/dist/server/server/routes/patterns.js +42 -0
- package/dist/server/server/summary-cache.js +51 -0
- package/dist/server/server/ws.js +16 -19
- package/dist/server/shared/providers/claude-code.js +268 -0
- package/dist/server/shared/providers/index.js +37 -0
- package/dist/server/shared/providers/provider.js +5 -0
- package/dist/server/shared/session-summary.js +123 -0
- package/dist/server/shared/session.js +6 -0
- package/package.json +7 -1
- package/dist/client/assets/index-D3XepZ5e.js +0 -30
- package/dist/client/assets/index-DwPuae45.css +0 -2
package/dist/server/server/ws.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebSocket handler for real-time session event streaming.
|
|
3
3
|
* Mounts at /ws. One watcher per connection, cleaned up on disconnect.
|
|
4
|
+
*
|
|
5
|
+
* Phase B: the top-level directory watcher that broadcasts session-created
|
|
6
|
+
* events is now driven by provider.watch() from the Provider registry.
|
|
7
|
+
* Per-connection file watchers (watchSession / watchSubAgent) remain as-is
|
|
8
|
+
* since they do incremental row parsing not yet part of the Provider interface.
|
|
4
9
|
*/
|
|
5
10
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
11
|
import { spawn } from 'node:child_process';
|
|
@@ -9,6 +14,7 @@ import fs from 'node:fs';
|
|
|
9
14
|
import path from 'node:path';
|
|
10
15
|
import { watchSession, watchSubAgent } from './watcher.js';
|
|
11
16
|
import { extractAgentIds } from '../shared/parser.js';
|
|
17
|
+
import { createClaudeCodeProvider } from '../shared/providers/claude-code.js';
|
|
12
18
|
// ---------------------------------------------------------------------------
|
|
13
19
|
// Helpers
|
|
14
20
|
// ---------------------------------------------------------------------------
|
|
@@ -54,22 +60,16 @@ export function setupWebSocket(server, claudeHome) {
|
|
|
54
60
|
});
|
|
55
61
|
// Suppress unhandled WSS errors during port retry (EADDRINUSE propagates here)
|
|
56
62
|
wss.on('error', () => { });
|
|
57
|
-
// Watch the projects directory
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
persistent: true,
|
|
63
|
-
ignoreInitial: true,
|
|
64
|
-
depth: 1,
|
|
65
|
-
});
|
|
66
|
-
dirWatcher.on('add', (filePath) => {
|
|
67
|
-
if (!filePath.endsWith('.jsonl'))
|
|
63
|
+
// Watch the projects directory via the provider's watch() interface.
|
|
64
|
+
// session-added events map to session-created WebSocket broadcasts.
|
|
65
|
+
const dirProvider = createClaudeCodeProvider(claudeHome);
|
|
66
|
+
const unsubscribeDir = dirProvider.watch((event) => {
|
|
67
|
+
if (event.kind !== 'session-added')
|
|
68
68
|
return;
|
|
69
|
-
//
|
|
70
|
-
const
|
|
71
|
-
const slug =
|
|
72
|
-
if (!slug
|
|
69
|
+
// sessionId for claude-code is '<projectSlug>/<fileId>'
|
|
70
|
+
const slashIdx = event.sessionId.indexOf('/');
|
|
71
|
+
const slug = slashIdx !== -1 ? event.sessionId.slice(0, slashIdx) : event.sessionId;
|
|
72
|
+
if (!slug)
|
|
73
73
|
return;
|
|
74
74
|
const msg = { type: 'session-created', slug };
|
|
75
75
|
const payload = JSON.stringify(msg);
|
|
@@ -79,11 +79,8 @@ export function setupWebSocket(server, claudeHome) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
|
-
dirWatcher.on('error', (err) => {
|
|
83
|
-
console.warn('[noctrace] dir watcher error:', err instanceof Error ? err.message : String(err));
|
|
84
|
-
});
|
|
85
82
|
wss.on('close', () => {
|
|
86
|
-
|
|
83
|
+
unsubscribeDir();
|
|
87
84
|
});
|
|
88
85
|
wss.on('connection', (ws, _req) => {
|
|
89
86
|
let stopWatcher = null;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code provider implementation.
|
|
3
|
+
* Wraps the existing parseJsonlContent / parseSubAgentContent logic and the
|
|
4
|
+
* ~/.claude/projects directory structure into the Provider interface.
|
|
5
|
+
*
|
|
6
|
+
* Session id format: '<projectSlug>/<sessionId>'
|
|
7
|
+
* e.g. '-Users-lam-dev-noctrace/abc123def456'
|
|
8
|
+
*
|
|
9
|
+
* Phase A note: watch() returns a no-op unsubscribe. Real-time chokidar
|
|
10
|
+
* integration is deferred to Phase B when the server wires it up.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import chokidar from 'chokidar';
|
|
16
|
+
import { parseJsonlContent, extractSessionId } from '../parser.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the Claude home directory.
|
|
22
|
+
* Prefers the CLAUDE_HOME environment variable, falls back to ~/.claude.
|
|
23
|
+
*/
|
|
24
|
+
function resolveClaudeHome(override) {
|
|
25
|
+
if (override)
|
|
26
|
+
return override;
|
|
27
|
+
return process.env['CLAUDE_HOME'] ?? path.join(os.homedir(), '.claude');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Convert a project directory slug back to a human-readable path.
|
|
31
|
+
* '-Users-lam-dev-noctrace' → '/Users/lam/dev/noctrace'
|
|
32
|
+
* The result is then replaced with '~' when it starts with the home directory.
|
|
33
|
+
*/
|
|
34
|
+
function deSlugifyProject(slug) {
|
|
35
|
+
const rawPath = slug.replace(/-/g, '/');
|
|
36
|
+
const home = os.homedir();
|
|
37
|
+
if (rawPath.startsWith(home)) {
|
|
38
|
+
return '~' + rawPath.slice(home.length);
|
|
39
|
+
}
|
|
40
|
+
return rawPath;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read the mtime of a file; returns null on error.
|
|
44
|
+
*/
|
|
45
|
+
async function safeStatMtime(filePath) {
|
|
46
|
+
try {
|
|
47
|
+
const stat = await fs.stat(filePath);
|
|
48
|
+
return stat.mtime;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Provider factory
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const CLAUDE_CODE_CAPABILITIES = {
|
|
58
|
+
toolCallGranularity: 'full',
|
|
59
|
+
contextTracking: true,
|
|
60
|
+
subAgents: true,
|
|
61
|
+
realtime: true,
|
|
62
|
+
tokenAccounting: 'per-turn',
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Create a Claude Code provider instance.
|
|
66
|
+
*
|
|
67
|
+
* @param claudeHome - Override path to the Claude home directory.
|
|
68
|
+
* Defaults to CLAUDE_HOME env var or ~/.claude.
|
|
69
|
+
*/
|
|
70
|
+
export function createClaudeCodeProvider(claudeHome) {
|
|
71
|
+
const home = resolveClaudeHome(claudeHome);
|
|
72
|
+
const projectsDir = path.join(home, 'projects');
|
|
73
|
+
return {
|
|
74
|
+
id: 'claude-code',
|
|
75
|
+
displayName: 'Claude Code',
|
|
76
|
+
capabilities: CLAUDE_CODE_CAPABILITIES,
|
|
77
|
+
async listSessions(window) {
|
|
78
|
+
const results = [];
|
|
79
|
+
let slugs;
|
|
80
|
+
try {
|
|
81
|
+
slugs = await fs.readdir(projectsDir);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Projects directory doesn't exist — return empty list gracefully
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
for (const slug of slugs) {
|
|
88
|
+
const projectDir = path.join(projectsDir, slug);
|
|
89
|
+
let dirStat;
|
|
90
|
+
try {
|
|
91
|
+
dirStat = await fs.stat(projectDir);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!dirStat.isDirectory())
|
|
97
|
+
continue;
|
|
98
|
+
let files;
|
|
99
|
+
try {
|
|
100
|
+
files = await fs.readdir(projectDir);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
if (!file.endsWith('.jsonl'))
|
|
107
|
+
continue;
|
|
108
|
+
const sessionId = file.replace(/\.jsonl$/, '');
|
|
109
|
+
const filePath = path.join(projectDir, file);
|
|
110
|
+
const mtime = await safeStatMtime(filePath);
|
|
111
|
+
if (!mtime)
|
|
112
|
+
continue;
|
|
113
|
+
const mtimeMs = mtime.getTime();
|
|
114
|
+
// Filter by window: use mtime as endMs heuristic
|
|
115
|
+
if (mtimeMs < window.startMs || mtimeMs >= window.endMs)
|
|
116
|
+
continue;
|
|
117
|
+
// Extract start time from first record (best-effort, fall back to mtime)
|
|
118
|
+
let startMs = mtimeMs;
|
|
119
|
+
try {
|
|
120
|
+
const firstChunk = await readFirstChunk(filePath, 4096);
|
|
121
|
+
const firstTs = extractFirstTimestamp(firstChunk);
|
|
122
|
+
if (firstTs !== null)
|
|
123
|
+
startMs = firstTs;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// Leave startMs as mtime
|
|
127
|
+
}
|
|
128
|
+
results.push({
|
|
129
|
+
provider: 'claude-code',
|
|
130
|
+
sessionId,
|
|
131
|
+
projectContext: deSlugifyProject(slug),
|
|
132
|
+
rawSlug: `${slug}/${sessionId}`,
|
|
133
|
+
startMs,
|
|
134
|
+
endMs: mtimeMs,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return results;
|
|
139
|
+
},
|
|
140
|
+
async readSession(id) {
|
|
141
|
+
// id format: '<projectSlug>/<sessionId>'
|
|
142
|
+
const slashIdx = id.indexOf('/');
|
|
143
|
+
if (slashIdx === -1) {
|
|
144
|
+
throw new Error(`Invalid Claude Code session id: "${id}". Expected "<projectSlug>/<sessionId>".`);
|
|
145
|
+
}
|
|
146
|
+
const projectSlug = id.slice(0, slashIdx);
|
|
147
|
+
const sessionId = id.slice(slashIdx + 1);
|
|
148
|
+
const filePath = path.join(projectsDir, projectSlug, `${sessionId}.jsonl`);
|
|
149
|
+
let content;
|
|
150
|
+
try {
|
|
151
|
+
content = await fs.readFile(filePath, 'utf8');
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`Claude Code session not found: ${id}`);
|
|
155
|
+
}
|
|
156
|
+
const rows = parseJsonlContent(content);
|
|
157
|
+
const canonicalSessionId = extractSessionId(content) ?? sessionId;
|
|
158
|
+
// Extract mtime for endMs
|
|
159
|
+
const mtime = await safeStatMtime(filePath);
|
|
160
|
+
// Extract start time
|
|
161
|
+
const firstTs = extractFirstTimestamp(content.slice(0, 4096));
|
|
162
|
+
const startMs = firstTs ?? (mtime?.getTime() ?? Date.now());
|
|
163
|
+
const meta = {
|
|
164
|
+
provider: 'claude-code',
|
|
165
|
+
sessionId: canonicalSessionId,
|
|
166
|
+
projectContext: deSlugifyProject(projectSlug),
|
|
167
|
+
rawSlug: `${projectSlug}/${sessionId}`,
|
|
168
|
+
startMs,
|
|
169
|
+
endMs: mtime?.getTime() ?? null,
|
|
170
|
+
};
|
|
171
|
+
return { meta, native: rows };
|
|
172
|
+
},
|
|
173
|
+
watch(onEvent) {
|
|
174
|
+
// Phase B: real chokidar integration.
|
|
175
|
+
// Watches the projects directory for added and changed .jsonl files.
|
|
176
|
+
// Emits session-added for new files and session-updated for changed files.
|
|
177
|
+
// Uses persistent:true, ignoreInitial:true per architecture constraints.
|
|
178
|
+
let watcher = null;
|
|
179
|
+
try {
|
|
180
|
+
watcher = chokidar.watch(projectsDir, {
|
|
181
|
+
persistent: true,
|
|
182
|
+
ignoreInitial: true,
|
|
183
|
+
depth: 2,
|
|
184
|
+
});
|
|
185
|
+
watcher.on('add', (filePath) => {
|
|
186
|
+
if (!filePath.endsWith('.jsonl'))
|
|
187
|
+
return;
|
|
188
|
+
const relative = path.relative(projectsDir, filePath);
|
|
189
|
+
const parts = relative.split(path.sep);
|
|
190
|
+
if (parts.length < 2)
|
|
191
|
+
return;
|
|
192
|
+
const slug = parts[0];
|
|
193
|
+
const sessionId = parts[1].replace(/\.jsonl$/, '');
|
|
194
|
+
const id = `${slug}/${sessionId}`;
|
|
195
|
+
onEvent({ kind: 'session-added', provider: 'claude-code', sessionId: id });
|
|
196
|
+
});
|
|
197
|
+
watcher.on('change', (filePath) => {
|
|
198
|
+
if (!filePath.endsWith('.jsonl'))
|
|
199
|
+
return;
|
|
200
|
+
const relative = path.relative(projectsDir, filePath);
|
|
201
|
+
const parts = relative.split(path.sep);
|
|
202
|
+
if (parts.length < 2)
|
|
203
|
+
return;
|
|
204
|
+
const slug = parts[0];
|
|
205
|
+
const sessionId = parts[1].replace(/\.jsonl$/, '');
|
|
206
|
+
const id = `${slug}/${sessionId}`;
|
|
207
|
+
onEvent({ kind: 'session-updated', provider: 'claude-code', sessionId: id });
|
|
208
|
+
});
|
|
209
|
+
watcher.on('error', (err) => {
|
|
210
|
+
console.warn('[noctrace] claude-code provider watcher error:', err instanceof Error ? err.message : String(err));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
// If the projects directory doesn't exist, chokidar may throw — degrade gracefully
|
|
215
|
+
console.warn('[noctrace] claude-code provider: could not start watcher:', err instanceof Error ? err.message : String(err));
|
|
216
|
+
}
|
|
217
|
+
return () => {
|
|
218
|
+
watcher?.close().catch(() => { });
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Internal helpers
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
/**
|
|
227
|
+
* Read the first `maxBytes` of a file as a UTF-8 string.
|
|
228
|
+
* Used for fast timestamp extraction without loading the full file.
|
|
229
|
+
*/
|
|
230
|
+
async function readFirstChunk(filePath, maxBytes) {
|
|
231
|
+
const fh = await fs.open(filePath, 'r');
|
|
232
|
+
try {
|
|
233
|
+
const buf = Buffer.alloc(maxBytes);
|
|
234
|
+
const { bytesRead } = await fh.read(buf, 0, maxBytes, 0);
|
|
235
|
+
return buf.slice(0, bytesRead).toString('utf8');
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await fh.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Extract the Unix-ms timestamp from the first valid JSON record in a string.
|
|
243
|
+
* Returns null when no timestamp can be found.
|
|
244
|
+
*/
|
|
245
|
+
function extractFirstTimestamp(chunk) {
|
|
246
|
+
const lines = chunk.split('\n');
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const t = line.trim();
|
|
249
|
+
if (!t)
|
|
250
|
+
continue;
|
|
251
|
+
let parsed;
|
|
252
|
+
try {
|
|
253
|
+
parsed = JSON.parse(t);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
259
|
+
continue;
|
|
260
|
+
const ts = parsed['timestamp'];
|
|
261
|
+
if (typeof ts === 'string') {
|
|
262
|
+
const ms = new Date(ts).getTime();
|
|
263
|
+
if (!isNaN(ms))
|
|
264
|
+
return ms;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry for Noctrace multi-provider support.
|
|
3
|
+
* Phase A: registers the 'claude-code' provider by default.
|
|
4
|
+
* Additional providers (Codex, Copilot, etc.) will be registered in Phase B/C.
|
|
5
|
+
*/
|
|
6
|
+
import { createClaudeCodeProvider } from './claude-code.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Registry
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const _registry = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Register a provider in the global registry.
|
|
13
|
+
* Overwrites any existing provider with the same id.
|
|
14
|
+
* Use this in tests to inject mock or fixture-backed providers.
|
|
15
|
+
*/
|
|
16
|
+
export function registerProvider(provider) {
|
|
17
|
+
_registry.set(provider.id, provider);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Retrieve a provider by its stable id.
|
|
21
|
+
* Returns undefined when no provider with that id is registered.
|
|
22
|
+
*/
|
|
23
|
+
export function getProvider(id) {
|
|
24
|
+
return _registry.get(id);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return all currently registered providers as an array.
|
|
28
|
+
*/
|
|
29
|
+
export function listProviders() {
|
|
30
|
+
return Array.from(_registry.values());
|
|
31
|
+
}
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Default registration
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Register the Claude Code provider with default settings.
|
|
36
|
+
// The claudeHome path is resolved from CLAUDE_HOME env var or ~/.claude.
|
|
37
|
+
registerProvider(createClaudeCodeProvider());
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { computeContextHealth } from './health.js';
|
|
2
|
+
import { parseCompactionBoundaries } from './session-metadata.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
/** Recursively collect all rows, including nested children, into a flat list. */
|
|
7
|
+
function flattenRows(rows) {
|
|
8
|
+
const result = [];
|
|
9
|
+
const walk = (list) => {
|
|
10
|
+
for (const row of list) {
|
|
11
|
+
result.push(row);
|
|
12
|
+
if (row.children.length > 0)
|
|
13
|
+
walk(row.children);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
walk(rows);
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Public API
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* Build a {@link PatternSessionSummary} from a parsed WaterfallRow array.
|
|
24
|
+
*
|
|
25
|
+
* Tolerates empty sessions (returns zero counts, null grade, null model).
|
|
26
|
+
* Never throws.
|
|
27
|
+
*/
|
|
28
|
+
export function buildSessionSummary(rows, sessionId, projectSlug) {
|
|
29
|
+
const flat = flattenRows(rows);
|
|
30
|
+
// --- time bounds ---
|
|
31
|
+
let startMs = Infinity;
|
|
32
|
+
let endMs = -Infinity;
|
|
33
|
+
for (const row of flat) {
|
|
34
|
+
if (row.startTime < startMs)
|
|
35
|
+
startMs = row.startTime;
|
|
36
|
+
const end = row.endTime ?? row.startTime;
|
|
37
|
+
if (end > endMs)
|
|
38
|
+
endMs = end;
|
|
39
|
+
}
|
|
40
|
+
if (!isFinite(startMs))
|
|
41
|
+
startMs = 0;
|
|
42
|
+
if (!isFinite(endMs))
|
|
43
|
+
endMs = 0;
|
|
44
|
+
// --- primary model: model with most assistant turns ---
|
|
45
|
+
const modelTurnCounts = new Map();
|
|
46
|
+
for (const row of flat) {
|
|
47
|
+
if (row.modelName) {
|
|
48
|
+
modelTurnCounts.set(row.modelName, (modelTurnCounts.get(row.modelName) ?? 0) + 1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
let primaryModel = null;
|
|
52
|
+
let maxTurns = 0;
|
|
53
|
+
for (const [model, count] of modelTurnCounts) {
|
|
54
|
+
if (count > maxTurns) {
|
|
55
|
+
maxTurns = count;
|
|
56
|
+
primaryModel = model;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// --- health ---
|
|
60
|
+
let healthGrade = null;
|
|
61
|
+
let healthScore = null;
|
|
62
|
+
if (rows.length > 0) {
|
|
63
|
+
// We need compaction count. Derive from flat rows (compact_boundary rows produce
|
|
64
|
+
// tool rows with toolName 'compact_boundary' or we use a fallback of 0 since
|
|
65
|
+
// we don't have the raw JSONL string here). We count via health module directly.
|
|
66
|
+
const compactionCount = flat.filter((r) => r.type === 'tool' && r.toolName === 'compact_boundary').length;
|
|
67
|
+
const health = computeContextHealth(rows, compactionCount);
|
|
68
|
+
healthGrade = health.grade;
|
|
69
|
+
healthScore = health.score;
|
|
70
|
+
}
|
|
71
|
+
// --- tool stats (tool-type rows only, not agent/api-error/hook/turn) ---
|
|
72
|
+
const toolCounts = {};
|
|
73
|
+
const toolFailures = {};
|
|
74
|
+
const toolLatencies = {};
|
|
75
|
+
for (const row of flat) {
|
|
76
|
+
if (row.type !== 'tool')
|
|
77
|
+
continue;
|
|
78
|
+
const name = row.toolName;
|
|
79
|
+
toolCounts[name] = (toolCounts[name] ?? 0) + 1;
|
|
80
|
+
if (row.isFailure) {
|
|
81
|
+
toolFailures[name] = (toolFailures[name] ?? 0) + 1;
|
|
82
|
+
}
|
|
83
|
+
if (row.duration !== null) {
|
|
84
|
+
if (!toolLatencies[name])
|
|
85
|
+
toolLatencies[name] = [];
|
|
86
|
+
toolLatencies[name].push(row.duration);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// --- compaction count from compaction boundaries (agent rows tagged as compact) ---
|
|
90
|
+
// Since compact_boundary records produce system rows (not tool rows), and health.ts
|
|
91
|
+
// receives compactionCount from the caller (parseCompactionBoundaries), we re-derive it
|
|
92
|
+
// by counting rows whose toolName is the compact boundary sentinel used in the parser.
|
|
93
|
+
// As a fallback, inspect rows for any health score that already reflects compactions.
|
|
94
|
+
// The most reliable approach: count rows with type==='tool' && toolName==='compact_boundary'.
|
|
95
|
+
// The parser emits no such rows; compactions are system records counted separately.
|
|
96
|
+
// We set compactionCount=0 here and rely on the rollup caller to pass a better value
|
|
97
|
+
// when it has the raw content. However, for pure-row callers, we approximate by checking
|
|
98
|
+
// for compaction-indicative health signals.
|
|
99
|
+
const compactionCount = flat.filter((r) => r.type === 'tool' && r.toolName === 'compact_boundary').length;
|
|
100
|
+
return {
|
|
101
|
+
sessionId,
|
|
102
|
+
projectSlug,
|
|
103
|
+
startMs,
|
|
104
|
+
endMs,
|
|
105
|
+
primaryModel,
|
|
106
|
+
healthGrade,
|
|
107
|
+
healthScore,
|
|
108
|
+
toolCounts,
|
|
109
|
+
toolFailures,
|
|
110
|
+
toolLatencies,
|
|
111
|
+
compactionCount,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a PatternSessionSummary when the raw JSONL content string is available.
|
|
116
|
+
* This variant correctly counts compaction boundaries from the raw content.
|
|
117
|
+
*/
|
|
118
|
+
export function buildSessionSummaryFromContent(rows, sessionId, projectSlug, rawContent) {
|
|
119
|
+
const summary = buildSessionSummary(rows, sessionId, projectSlug);
|
|
120
|
+
// Override compactionCount with the accurate value from the raw JSONL
|
|
121
|
+
const boundaries = parseCompactionBoundaries(rawContent);
|
|
122
|
+
return { ...summary, compactionCount: boundaries.length };
|
|
123
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "noctrace",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Claude Code observability — DevTools-style waterfall visualizer for AI agent workflows, token tracking, and context health monitoring",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"test:smoke": "npm run build && vitest run tests/smoke/",
|
|
62
62
|
"lint": "eslint src/",
|
|
63
63
|
"typecheck": "tsc -p tsconfig.server.json --noEmit && tsc -p tsconfig.client.json --noEmit",
|
|
64
|
+
"version:check": "node scripts/check-version.mjs",
|
|
65
|
+
"version:bump": "node scripts/bump-version.mjs",
|
|
64
66
|
"postinstall": "node bin/postinstall.js",
|
|
65
67
|
"preuninstall": "node bin/preuninstall.js"
|
|
66
68
|
},
|
|
@@ -72,6 +74,9 @@
|
|
|
72
74
|
},
|
|
73
75
|
"devDependencies": {
|
|
74
76
|
"@tailwindcss/vite": "4.2.2",
|
|
77
|
+
"@testing-library/jest-dom": "6.6.3",
|
|
78
|
+
"@testing-library/react": "16.3.0",
|
|
79
|
+
"@testing-library/user-event": "14.6.1",
|
|
75
80
|
"@types/express": "5.0.6",
|
|
76
81
|
"@types/node": "22.19.15",
|
|
77
82
|
"@types/react": "19.2.14",
|
|
@@ -80,6 +85,7 @@
|
|
|
80
85
|
"@vitejs/plugin-react": "5.2.0",
|
|
81
86
|
"concurrently": "9.2.1",
|
|
82
87
|
"eslint": "9.39.4",
|
|
88
|
+
"happy-dom": "18.0.1",
|
|
83
89
|
"react": "19.2.4",
|
|
84
90
|
"react-dom": "19.2.4",
|
|
85
91
|
"tailwindcss": "4.2.2",
|