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.
Files changed (47) hide show
  1. package/.claude/settings.local.json +7 -1
  2. package/DEVELOPMENT.md +16 -9
  3. package/dist/cli/commands.d.ts.map +1 -1
  4. package/dist/cli/commands.js +4 -0
  5. package/dist/cli/commands.js.map +1 -1
  6. package/dist/dashboard/index.html +164 -42
  7. package/dist/hooks/server.js +6 -0
  8. package/dist/hooks/server.js.map +1 -1
  9. package/dist/parser/ingest.d.ts +2 -0
  10. package/dist/parser/ingest.d.ts.map +1 -1
  11. package/dist/parser/ingest.js +8 -3
  12. package/dist/parser/ingest.js.map +1 -1
  13. package/dist/parser/jsonl.d.ts +9 -3
  14. package/dist/parser/jsonl.d.ts.map +1 -1
  15. package/dist/parser/jsonl.js +35 -11
  16. package/dist/parser/jsonl.js.map +1 -1
  17. package/dist/parser/types.d.ts +2 -0
  18. package/dist/parser/types.d.ts.map +1 -1
  19. package/dist/parser/watcher.d.ts.map +1 -1
  20. package/dist/parser/watcher.js +37 -10
  21. package/dist/parser/watcher.js.map +1 -1
  22. package/dist/storage/db.d.ts +2 -0
  23. package/dist/storage/db.d.ts.map +1 -1
  24. package/dist/storage/db.js +24 -0
  25. package/dist/storage/db.js.map +1 -1
  26. package/dist/storage/repo.d.ts +8 -2
  27. package/dist/storage/repo.d.ts.map +1 -1
  28. package/dist/storage/repo.js +64 -42
  29. package/dist/storage/repo.js.map +1 -1
  30. package/dist/sync/sync.test.js +12 -0
  31. package/dist/sync/sync.test.js.map +1 -1
  32. package/dist/sync/transform.d.ts +2 -0
  33. package/dist/sync/transform.d.ts.map +1 -1
  34. package/dist/sync/transform.js +2 -0
  35. package/dist/sync/transform.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli/commands.ts +5 -0
  38. package/src/dashboard/index.html +164 -42
  39. package/src/hooks/server.ts +7 -0
  40. package/src/parser/ingest.ts +10 -5
  41. package/src/parser/jsonl.ts +43 -7
  42. package/src/parser/types.ts +2 -0
  43. package/src/parser/watcher.ts +34 -9
  44. package/src/storage/db.ts +24 -0
  45. package/src/storage/repo.ts +72 -48
  46. package/src/sync/sync.test.ts +12 -0
  47. package/src/sync/transform.ts +4 -0
@@ -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, total_input_tokens,
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, @started_at, @ended_at, @total_input_tokens,
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
- -- Use MAX so OTEL values aren't clobbered by JSONL re-ingest,
38
- -- but JSONL values fill in as a floor for sessions without OTEL data.
39
- total_input_tokens = MAX(total_input_tokens, @total_input_tokens),
40
- total_output_tokens = MAX(total_output_tokens, @total_output_tokens),
41
- total_cache_read_tokens = MAX(total_cache_read_tokens, @total_cache_read_tokens),
42
- total_cache_creation_tokens = MAX(total_cache_creation_tokens, @total_cache_creation_tokens),
43
- total_cost_usd = MAX(total_cost_usd, @total_cost_usd),
44
- total_duration_ms = MAX(total_duration_ms, @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 sessions WHERE started_at >= ? AND started_at <= ? ORDER BY started_at DESC LIMIT ? OFFSET ?
183
- `).all(from, to, limit, offset) as SessionRow[];
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 sessions ORDER BY started_at DESC LIMIT ? OFFSET ?
187
- `).all(limit, offset) as SessionRow[];
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(`SELECT * FROM sessions WHERE id = ?`).get(id) as SessionRow | undefined;
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 ? "\n WHERE t.timestamp >= ? AND t.timestamp <= ?" : "";
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${timeFilter}
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
- ), total_input_tokens),
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
- ), total_output_tokens),
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
- ), total_cache_read_tokens),
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
- ), total_cache_creation_tokens),
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
- // Zero out cost/duration for sessions with no OTEL backing (unverifiable)
674
+ // Step 2: Recompute duration from turns for all sessions
661
675
  this.db.prepare(`
662
- UPDATE sessions SET total_cost_usd = 0, total_duration_ms = 0
663
- WHERE (total_cost_usd > 0 OR total_duration_ms > 0)
664
- AND id NOT IN (SELECT DISTINCT session_id FROM otel_metrics WHERE session_id IS NOT NULL)
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;
@@ -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,
@@ -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,