zozul-cli 0.3.7 → 0.4.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/settings.local.json +7 -1
- package/DEVELOPMENT.md +16 -9
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +4 -0
- package/dist/cli/commands.js.map +1 -1
- package/dist/dashboard/index.html +164 -42
- package/dist/hooks/server.js +6 -0
- package/dist/hooks/server.js.map +1 -1
- package/dist/parser/ingest.d.ts +2 -0
- package/dist/parser/ingest.d.ts.map +1 -1
- package/dist/parser/ingest.js +8 -3
- package/dist/parser/ingest.js.map +1 -1
- package/dist/parser/jsonl.d.ts +9 -3
- package/dist/parser/jsonl.d.ts.map +1 -1
- package/dist/parser/jsonl.js +35 -11
- package/dist/parser/jsonl.js.map +1 -1
- package/dist/parser/types.d.ts +2 -0
- package/dist/parser/types.d.ts.map +1 -1
- package/dist/parser/watcher.d.ts.map +1 -1
- package/dist/parser/watcher.js +37 -10
- package/dist/parser/watcher.js.map +1 -1
- package/dist/storage/db.d.ts +2 -0
- package/dist/storage/db.d.ts.map +1 -1
- package/dist/storage/db.js +24 -0
- package/dist/storage/db.js.map +1 -1
- package/dist/storage/repo.d.ts +8 -2
- package/dist/storage/repo.d.ts.map +1 -1
- package/dist/storage/repo.js +64 -42
- package/dist/storage/repo.js.map +1 -1
- package/dist/sync/sync.test.js +12 -0
- package/dist/sync/sync.test.js.map +1 -1
- package/dist/sync/transform.d.ts +2 -0
- package/dist/sync/transform.d.ts.map +1 -1
- package/dist/sync/transform.js +2 -0
- package/dist/sync/transform.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands.ts +5 -0
- package/src/dashboard/index.html +164 -42
- package/src/hooks/server.ts +7 -0
- package/src/parser/ingest.ts +10 -5
- package/src/parser/jsonl.ts +43 -7
- package/src/parser/types.ts +2 -0
- package/src/parser/watcher.ts +34 -9
- package/src/storage/db.ts +24 -0
- package/src/storage/repo.ts +72 -48
- package/src/sync/sync.test.ts +12 -0
- package/src/sync/transform.ts +4 -0
package/src/storage/repo.ts
CHANGED
|
@@ -22,29 +22,33 @@ export class SessionRepo {
|
|
|
22
22
|
|
|
23
23
|
upsertSession(session: Omit<SessionRow, "ended_at"> & { ended_at?: string | null }): void {
|
|
24
24
|
this.db.prepare(`
|
|
25
|
-
INSERT INTO sessions (id, project_path, started_at, ended_at,
|
|
26
|
-
total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
|
|
25
|
+
INSERT INTO sessions (id, project_path, parent_session_id, agent_type, started_at, ended_at,
|
|
26
|
+
total_input_tokens, total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
|
|
27
27
|
total_cost_usd, total_turns, total_duration_ms, model)
|
|
28
|
-
VALUES (@id, @project_path, @
|
|
29
|
-
@total_output_tokens, @total_cache_read_tokens, @total_cache_creation_tokens,
|
|
28
|
+
VALUES (@id, @project_path, @parent_session_id, @agent_type, @started_at, @ended_at,
|
|
29
|
+
@total_input_tokens, @total_output_tokens, @total_cache_read_tokens, @total_cache_creation_tokens,
|
|
30
30
|
@total_cost_usd, @total_turns, @total_duration_ms, @model)
|
|
31
31
|
ON CONFLICT(id) DO UPDATE SET
|
|
32
32
|
started_at = MIN(started_at, @started_at),
|
|
33
33
|
project_path = COALESCE(@project_path, project_path),
|
|
34
|
+
parent_session_id = COALESCE(@parent_session_id, parent_session_id),
|
|
35
|
+
agent_type = COALESCE(@agent_type, agent_type),
|
|
34
36
|
ended_at = COALESCE(@ended_at, ended_at),
|
|
35
37
|
total_turns = @total_turns,
|
|
36
38
|
model = COALESCE(@model, model),
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
total_input_tokens =
|
|
40
|
-
total_output_tokens =
|
|
41
|
-
total_cache_read_tokens =
|
|
42
|
-
total_cache_creation_tokens =
|
|
43
|
-
total_cost_usd =
|
|
44
|
-
total_duration_ms =
|
|
39
|
+
-- JSONL values act as initial estimates; recomputeSessionCostsFromOtel
|
|
40
|
+
-- overwrites with authoritative OTEL data when available.
|
|
41
|
+
total_input_tokens = @total_input_tokens,
|
|
42
|
+
total_output_tokens = @total_output_tokens,
|
|
43
|
+
total_cache_read_tokens = @total_cache_read_tokens,
|
|
44
|
+
total_cache_creation_tokens = @total_cache_creation_tokens,
|
|
45
|
+
total_cost_usd = @total_cost_usd,
|
|
46
|
+
total_duration_ms = @total_duration_ms
|
|
45
47
|
`).run({
|
|
46
48
|
...session,
|
|
47
49
|
ended_at: session.ended_at ?? null,
|
|
50
|
+
parent_session_id: session.parent_session_id ?? null,
|
|
51
|
+
agent_type: session.agent_type ?? null,
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -176,28 +180,39 @@ export class SessionRepo {
|
|
|
176
180
|
`).run(event);
|
|
177
181
|
}
|
|
178
182
|
|
|
179
|
-
listSessions(limit = 50, offset = 0, from?: string, to?: string): SessionRow[] {
|
|
183
|
+
listSessions(limit = 50, offset = 0, from?: string, to?: string): (SessionRow & { user_turns: number })[] {
|
|
180
184
|
if (from && to) {
|
|
181
185
|
return this.db.prepare(`
|
|
182
|
-
SELECT * FROM
|
|
183
|
-
|
|
186
|
+
SELECT s.*, (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id AND t.is_real_user = 1) as user_turns
|
|
187
|
+
FROM sessions s WHERE s.parent_session_id IS NULL AND s.started_at >= ? AND s.started_at <= ? ORDER BY s.started_at DESC LIMIT ? OFFSET ?
|
|
188
|
+
`).all(from, to, limit, offset) as (SessionRow & { user_turns: number })[];
|
|
184
189
|
}
|
|
185
190
|
return this.db.prepare(`
|
|
186
|
-
SELECT * FROM
|
|
187
|
-
|
|
191
|
+
SELECT s.*, (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id AND t.is_real_user = 1) as user_turns
|
|
192
|
+
FROM sessions s WHERE s.parent_session_id IS NULL ORDER BY s.started_at DESC LIMIT ? OFFSET ?
|
|
193
|
+
`).all(limit, offset) as (SessionRow & { user_turns: number })[];
|
|
188
194
|
}
|
|
189
195
|
|
|
190
196
|
countSessions(from?: string, to?: string): number {
|
|
191
197
|
if (from && to) {
|
|
192
|
-
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE started_at >= ? AND started_at <= ?`).get(from, to) as { n: number };
|
|
198
|
+
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ?`).get(from, to) as { n: number };
|
|
193
199
|
return row.n;
|
|
194
200
|
}
|
|
195
|
-
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions`).get() as { n: number };
|
|
201
|
+
const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions WHERE parent_session_id IS NULL`).get() as { n: number };
|
|
196
202
|
return row.n;
|
|
197
203
|
}
|
|
198
204
|
|
|
199
|
-
getSession(id: string): SessionRow | undefined {
|
|
200
|
-
return this.db.prepare(`
|
|
205
|
+
getSession(id: string): (SessionRow & { user_turns: number }) | undefined {
|
|
206
|
+
return this.db.prepare(`
|
|
207
|
+
SELECT s.*, (SELECT COUNT(*) FROM turns t WHERE t.session_id = s.id AND t.is_real_user = 1) as user_turns
|
|
208
|
+
FROM sessions s WHERE s.id = ?
|
|
209
|
+
`).get(id) as (SessionRow & { user_turns: number }) | undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
getSubSessions(parentSessionId: string): SessionRow[] {
|
|
213
|
+
return this.db.prepare(`
|
|
214
|
+
SELECT * FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC
|
|
215
|
+
`).all(parentSessionId) as SessionRow[];
|
|
201
216
|
}
|
|
202
217
|
|
|
203
218
|
getSessionTurns(sessionId: string): TurnRow[] {
|
|
@@ -221,7 +236,7 @@ export class SessionRepo {
|
|
|
221
236
|
getAggregateStats(from?: string, to?: string) {
|
|
222
237
|
if (from && to) {
|
|
223
238
|
return this.db.prepare(`
|
|
224
|
-
WITH fs AS (SELECT * FROM sessions WHERE started_at >= ? AND started_at <= ?),
|
|
239
|
+
WITH fs AS (SELECT * FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ?),
|
|
225
240
|
fh AS (SELECT * FROM hook_events WHERE timestamp >= ? AND timestamp <= ?)
|
|
226
241
|
SELECT
|
|
227
242
|
(SELECT COUNT(*) FROM fs) as total_sessions,
|
|
@@ -237,13 +252,13 @@ export class SessionRepo {
|
|
|
237
252
|
}
|
|
238
253
|
return this.db.prepare(`
|
|
239
254
|
SELECT
|
|
240
|
-
(SELECT COUNT(*) FROM sessions) as total_sessions,
|
|
241
|
-
(SELECT SUM(total_input_tokens) FROM sessions) as total_input_tokens,
|
|
242
|
-
(SELECT SUM(total_output_tokens) FROM sessions) as total_output_tokens,
|
|
243
|
-
(SELECT SUM(total_cache_read_tokens) FROM sessions) as total_cache_read_tokens,
|
|
244
|
-
(SELECT SUM(total_cost_usd) FROM sessions) as total_cost_usd,
|
|
245
|
-
(SELECT SUM(total_turns) FROM sessions) as total_turns,
|
|
246
|
-
(SELECT SUM(total_duration_ms) FROM sessions) as total_duration_ms,
|
|
255
|
+
(SELECT COUNT(*) FROM sessions WHERE parent_session_id IS NULL) as total_sessions,
|
|
256
|
+
(SELECT SUM(total_input_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_input_tokens,
|
|
257
|
+
(SELECT SUM(total_output_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_output_tokens,
|
|
258
|
+
(SELECT SUM(total_cache_read_tokens) FROM sessions WHERE parent_session_id IS NULL) as total_cache_read_tokens,
|
|
259
|
+
(SELECT SUM(total_cost_usd) FROM sessions WHERE parent_session_id IS NULL) as total_cost_usd,
|
|
260
|
+
(SELECT SUM(total_turns) FROM sessions WHERE parent_session_id IS NULL) as total_turns,
|
|
261
|
+
(SELECT SUM(total_duration_ms) FROM sessions WHERE parent_session_id IS NULL) as total_duration_ms,
|
|
247
262
|
(SELECT COUNT(*) FROM hook_events WHERE event_name = 'UserPromptSubmit') as total_user_prompts,
|
|
248
263
|
(SELECT COUNT(*) FROM hook_events WHERE event_name = 'Stop') as total_interruptions
|
|
249
264
|
`).get();
|
|
@@ -260,7 +275,7 @@ export class SessionRepo {
|
|
|
260
275
|
timestamp: string;
|
|
261
276
|
}): void {
|
|
262
277
|
this.db.prepare(`
|
|
263
|
-
INSERT INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
|
|
278
|
+
INSERT OR IGNORE INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
|
|
264
279
|
VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
|
|
265
280
|
`).run(metric);
|
|
266
281
|
}
|
|
@@ -274,7 +289,7 @@ export class SessionRepo {
|
|
|
274
289
|
timestamp: string;
|
|
275
290
|
}[]): void {
|
|
276
291
|
const stmt = this.db.prepare(`
|
|
277
|
-
INSERT INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
|
|
292
|
+
INSERT OR IGNORE INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
|
|
278
293
|
VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
|
|
279
294
|
`);
|
|
280
295
|
const tx = this.db.transaction((rows: typeof metrics) => {
|
|
@@ -478,6 +493,7 @@ export class SessionRepo {
|
|
|
478
493
|
|
|
479
494
|
// Only show real user turns (each row = one interaction block)
|
|
480
495
|
conditions.push("t.is_real_user = 1");
|
|
496
|
+
conditions.push("t.session_id IN (SELECT id FROM sessions WHERE parent_session_id IS NULL)");
|
|
481
497
|
|
|
482
498
|
const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
|
|
483
499
|
params.push(limit, offset);
|
|
@@ -601,8 +617,8 @@ export class SessionRepo {
|
|
|
601
617
|
* Fixes drift caused by late-start catch-up batches or duplicate processing.
|
|
602
618
|
* Safe to call repeatedly — always produces correct values from append-only source data.
|
|
603
619
|
*/
|
|
604
|
-
getTaskGroups(from?: string, to?: string): { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; last_seen: string }[] {
|
|
605
|
-
const timeFilter = from && to ? "
|
|
620
|
+
getTaskGroups(from?: string, to?: string): { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; total_tokens: number; last_seen: string }[] {
|
|
621
|
+
const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
|
|
606
622
|
const params: unknown[] = from && to ? [from, to] : [];
|
|
607
623
|
|
|
608
624
|
return this.db.prepare(`
|
|
@@ -617,17 +633,19 @@ export class SessionRepo {
|
|
|
617
633
|
SUM(CASE WHEN t.is_real_user = 1 THEN 1 ELSE 0 END) as human_interventions,
|
|
618
634
|
COALESCE(SUM(t.duration_ms), 0) as total_duration_ms,
|
|
619
635
|
COALESCE(SUM(${PROPORTIONAL_COST_SQL}), 0) as total_cost_usd,
|
|
636
|
+
COALESCE(SUM(t.input_tokens), 0) + COALESCE(SUM(t.output_tokens), 0) + COALESCE(SUM(t.cache_read_tokens), 0) as total_tokens,
|
|
620
637
|
MAX(t.timestamp) as last_seen
|
|
621
638
|
FROM turns t
|
|
622
639
|
LEFT JOIN turn_tag_sets tts ON tts.turn_id = t.id
|
|
623
|
-
JOIN sessions s ON s.id = t.session_id
|
|
640
|
+
JOIN sessions s ON s.id = t.session_id
|
|
641
|
+
WHERE s.parent_session_id IS NULL${timeFilter}
|
|
624
642
|
GROUP BY COALESCE(tts.tag_set, 'Untagged')
|
|
625
643
|
ORDER BY last_seen DESC
|
|
626
|
-
`).all(...params) as { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; last_seen: string }[];
|
|
644
|
+
`).all(...params) as { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; total_tokens: number; last_seen: string }[];
|
|
627
645
|
}
|
|
628
646
|
|
|
629
647
|
recomputeSessionCostsFromOtel(): number {
|
|
630
|
-
// Recompute from raw OTEL for sessions that have metrics
|
|
648
|
+
// Step 1: Recompute from raw OTEL for sessions that have metrics (authoritative)
|
|
631
649
|
const updated = this.db.prepare(`
|
|
632
650
|
UPDATE sessions SET
|
|
633
651
|
total_cost_usd = COALESCE((
|
|
@@ -637,31 +655,37 @@ export class SessionRepo {
|
|
|
637
655
|
total_input_tokens = COALESCE((
|
|
638
656
|
SELECT SUM(value) FROM otel_metrics
|
|
639
657
|
WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'input' AND session_id = sessions.id
|
|
640
|
-
),
|
|
658
|
+
), 0),
|
|
641
659
|
total_output_tokens = COALESCE((
|
|
642
660
|
SELECT SUM(value) FROM otel_metrics
|
|
643
661
|
WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'output' AND session_id = sessions.id
|
|
644
|
-
),
|
|
662
|
+
), 0),
|
|
645
663
|
total_cache_read_tokens = COALESCE((
|
|
646
664
|
SELECT SUM(value) FROM otel_metrics
|
|
647
665
|
WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'cacheRead' AND session_id = sessions.id
|
|
648
|
-
),
|
|
666
|
+
), 0),
|
|
649
667
|
total_cache_creation_tokens = COALESCE((
|
|
650
668
|
SELECT SUM(value) FROM otel_metrics
|
|
651
669
|
WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'cacheCreation' AND session_id = sessions.id
|
|
652
|
-
),
|
|
653
|
-
total_duration_ms = COALESCE((
|
|
654
|
-
SELECT SUM(value) * 1000 FROM otel_metrics
|
|
655
|
-
WHERE name = 'claude_code.active_time.total' AND session_id = sessions.id
|
|
656
|
-
), total_duration_ms)
|
|
670
|
+
), 0)
|
|
657
671
|
WHERE id IN (SELECT DISTINCT session_id FROM otel_metrics WHERE session_id IS NOT NULL)
|
|
658
672
|
`).run();
|
|
659
673
|
|
|
660
|
-
//
|
|
674
|
+
// Step 2: Recompute duration from turns for all sessions
|
|
661
675
|
this.db.prepare(`
|
|
662
|
-
UPDATE sessions SET
|
|
663
|
-
|
|
664
|
-
|
|
676
|
+
UPDATE sessions SET
|
|
677
|
+
total_duration_ms = COALESCE((
|
|
678
|
+
SELECT SUM(duration_ms) FROM turns WHERE session_id = sessions.id
|
|
679
|
+
), 0)
|
|
680
|
+
`).run();
|
|
681
|
+
|
|
682
|
+
// Step 3: Fallback for sessions without OTEL — use SUM(turns.cost_usd)
|
|
683
|
+
this.db.prepare(`
|
|
684
|
+
UPDATE sessions SET
|
|
685
|
+
total_cost_usd = COALESCE((
|
|
686
|
+
SELECT SUM(cost_usd) FROM turns WHERE session_id = sessions.id
|
|
687
|
+
), 0)
|
|
688
|
+
WHERE id NOT IN (SELECT DISTINCT session_id FROM otel_metrics WHERE session_id IS NOT NULL)
|
|
665
689
|
`).run();
|
|
666
690
|
|
|
667
691
|
return updated.changes;
|
package/src/sync/sync.test.ts
CHANGED
|
@@ -104,6 +104,8 @@ describe("zozul sync e2e", () => {
|
|
|
104
104
|
repo.upsertSession({
|
|
105
105
|
id: "sess-001",
|
|
106
106
|
project_path: "/projects/test",
|
|
107
|
+
parent_session_id: null,
|
|
108
|
+
agent_type: null,
|
|
107
109
|
started_at: "2026-03-28T10:00:00Z",
|
|
108
110
|
total_input_tokens: 100,
|
|
109
111
|
total_output_tokens: 50,
|
|
@@ -249,6 +251,8 @@ describe("zozul sync e2e", () => {
|
|
|
249
251
|
repo.upsertSession({
|
|
250
252
|
id: "sess-002",
|
|
251
253
|
project_path: null,
|
|
254
|
+
parent_session_id: null,
|
|
255
|
+
agent_type: null,
|
|
252
256
|
started_at: "2026-03-28T11:00:00Z",
|
|
253
257
|
total_input_tokens: 10,
|
|
254
258
|
total_output_tokens: 10,
|
|
@@ -296,6 +300,8 @@ describe("zozul sync e2e", () => {
|
|
|
296
300
|
repo.upsertSession({
|
|
297
301
|
id: "sess-A",
|
|
298
302
|
project_path: null,
|
|
303
|
+
parent_session_id: null,
|
|
304
|
+
agent_type: null,
|
|
299
305
|
started_at: "2026-03-28T12:00:00Z",
|
|
300
306
|
total_input_tokens: 10,
|
|
301
307
|
total_output_tokens: 10,
|
|
@@ -330,6 +336,8 @@ describe("zozul sync e2e", () => {
|
|
|
330
336
|
repo.upsertSession({
|
|
331
337
|
id: "sess-B",
|
|
332
338
|
project_path: "/new",
|
|
339
|
+
parent_session_id: null,
|
|
340
|
+
agent_type: null,
|
|
333
341
|
started_at: "2026-03-28T13:00:00Z",
|
|
334
342
|
total_input_tokens: 20,
|
|
335
343
|
total_output_tokens: 20,
|
|
@@ -369,6 +377,8 @@ describe("zozul sync e2e", () => {
|
|
|
369
377
|
repo.upsertSession({
|
|
370
378
|
id: "sess-dry",
|
|
371
379
|
project_path: null,
|
|
380
|
+
parent_session_id: null,
|
|
381
|
+
agent_type: null,
|
|
372
382
|
started_at: "2026-03-28T14:00:00Z",
|
|
373
383
|
total_input_tokens: 5,
|
|
374
384
|
total_output_tokens: 5,
|
|
@@ -411,6 +421,8 @@ describe("zozul sync e2e", () => {
|
|
|
411
421
|
repo.upsertSession({
|
|
412
422
|
id: "sess-err",
|
|
413
423
|
project_path: null,
|
|
424
|
+
parent_session_id: null,
|
|
425
|
+
agent_type: null,
|
|
414
426
|
started_at: "2026-03-28T15:00:00Z",
|
|
415
427
|
total_input_tokens: 10,
|
|
416
428
|
total_output_tokens: 10,
|
package/src/sync/transform.ts
CHANGED
|
@@ -7,6 +7,8 @@ import type {
|
|
|
7
7
|
export type ApiSession = {
|
|
8
8
|
id: string;
|
|
9
9
|
project_path: string | null;
|
|
10
|
+
parent_session_id: string | null;
|
|
11
|
+
agent_type: string | null;
|
|
10
12
|
started_at: string;
|
|
11
13
|
ended_at: string | null;
|
|
12
14
|
total_input_tokens: number;
|
|
@@ -103,6 +105,8 @@ export function transformSession(row: SessionRow): ApiSession {
|
|
|
103
105
|
return {
|
|
104
106
|
id: row.id,
|
|
105
107
|
project_path: row.project_path,
|
|
108
|
+
parent_session_id: row.parent_session_id,
|
|
109
|
+
agent_type: row.agent_type,
|
|
106
110
|
started_at: row.started_at,
|
|
107
111
|
ended_at: row.ended_at,
|
|
108
112
|
total_input_tokens: row.total_input_tokens,
|