zozul-cli 0.3.8 → 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.
@@ -36,14 +36,14 @@ export class SessionRepo {
36
36
  ended_at = COALESCE(@ended_at, ended_at),
37
37
  total_turns = @total_turns,
38
38
  model = COALESCE(@model, model),
39
- -- Use MAX so OTEL values aren't clobbered by JSONL re-ingest,
40
- -- but JSONL values fill in as a floor for sessions without OTEL data.
41
- total_input_tokens = MAX(total_input_tokens, @total_input_tokens),
42
- total_output_tokens = MAX(total_output_tokens, @total_output_tokens),
43
- total_cache_read_tokens = MAX(total_cache_read_tokens, @total_cache_read_tokens),
44
- total_cache_creation_tokens = MAX(total_cache_creation_tokens, @total_cache_creation_tokens),
45
- total_cost_usd = MAX(total_cost_usd, @total_cost_usd),
46
- 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
47
47
  `).run({
48
48
  ...session,
49
49
  ended_at: session.ended_at ?? null,
@@ -180,15 +180,17 @@ export class SessionRepo {
180
180
  `).run(event);
181
181
  }
182
182
 
183
- listSessions(limit = 50, offset = 0, from?: string, to?: string): SessionRow[] {
183
+ listSessions(limit = 50, offset = 0, from?: string, to?: string): (SessionRow & { user_turns: number })[] {
184
184
  if (from && to) {
185
185
  return this.db.prepare(`
186
- SELECT * FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? AND started_at <= ? ORDER BY started_at DESC LIMIT ? OFFSET ?
187
- `).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 })[];
188
189
  }
189
190
  return this.db.prepare(`
190
- SELECT * FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ? OFFSET ?
191
- `).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 })[];
192
194
  }
193
195
 
194
196
  countSessions(from?: string, to?: string): number {
@@ -200,8 +202,11 @@ export class SessionRepo {
200
202
  return row.n;
201
203
  }
202
204
 
203
- getSession(id: string): SessionRow | undefined {
204
- 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;
205
210
  }
206
211
 
207
212
  getSubSessions(parentSessionId: string): SessionRow[] {
@@ -270,7 +275,7 @@ export class SessionRepo {
270
275
  timestamp: string;
271
276
  }): void {
272
277
  this.db.prepare(`
273
- 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)
274
279
  VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
275
280
  `).run(metric);
276
281
  }
@@ -284,7 +289,7 @@ export class SessionRepo {
284
289
  timestamp: string;
285
290
  }[]): void {
286
291
  const stmt = this.db.prepare(`
287
- 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)
288
293
  VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
289
294
  `);
290
295
  const tx = this.db.transaction((rows: typeof metrics) => {
@@ -612,7 +617,7 @@ export class SessionRepo {
612
617
  * Fixes drift caused by late-start catch-up batches or duplicate processing.
613
618
  * Safe to call repeatedly — always produces correct values from append-only source data.
614
619
  */
615
- getTaskGroups(from?: string, to?: string): { tags: string; turn_count: number; human_interventions: number; total_duration_ms: number; total_cost_usd: number; last_seen: string }[] {
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 }[] {
616
621
  const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
617
622
  const params: unknown[] = from && to ? [from, to] : [];
618
623
 
@@ -628,6 +633,7 @@ export class SessionRepo {
628
633
  SUM(CASE WHEN t.is_real_user = 1 THEN 1 ELSE 0 END) as human_interventions,
629
634
  COALESCE(SUM(t.duration_ms), 0) as total_duration_ms,
630
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,
631
637
  MAX(t.timestamp) as last_seen
632
638
  FROM turns t
633
639
  LEFT JOIN turn_tag_sets tts ON tts.turn_id = t.id
@@ -635,11 +641,11 @@ export class SessionRepo {
635
641
  WHERE s.parent_session_id IS NULL${timeFilter}
636
642
  GROUP BY COALESCE(tts.tag_set, 'Untagged')
637
643
  ORDER BY last_seen DESC
638
- `).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 }[];
639
645
  }
640
646
 
641
647
  recomputeSessionCostsFromOtel(): number {
642
- // Recompute from raw OTEL for sessions that have metrics
648
+ // Step 1: Recompute from raw OTEL for sessions that have metrics (authoritative)
643
649
  const updated = this.db.prepare(`
644
650
  UPDATE sessions SET
645
651
  total_cost_usd = COALESCE((
@@ -649,31 +655,37 @@ export class SessionRepo {
649
655
  total_input_tokens = COALESCE((
650
656
  SELECT SUM(value) FROM otel_metrics
651
657
  WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'input' AND session_id = sessions.id
652
- ), total_input_tokens),
658
+ ), 0),
653
659
  total_output_tokens = COALESCE((
654
660
  SELECT SUM(value) FROM otel_metrics
655
661
  WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'output' AND session_id = sessions.id
656
- ), total_output_tokens),
662
+ ), 0),
657
663
  total_cache_read_tokens = COALESCE((
658
664
  SELECT SUM(value) FROM otel_metrics
659
665
  WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'cacheRead' AND session_id = sessions.id
660
- ), total_cache_read_tokens),
666
+ ), 0),
661
667
  total_cache_creation_tokens = COALESCE((
662
668
  SELECT SUM(value) FROM otel_metrics
663
669
  WHERE name = 'claude_code.token.usage' AND json_extract(attributes, '$.type') = 'cacheCreation' AND session_id = sessions.id
664
- ), total_cache_creation_tokens),
665
- total_duration_ms = COALESCE((
666
- SELECT SUM(value) * 1000 FROM otel_metrics
667
- WHERE name = 'claude_code.active_time.total' AND session_id = sessions.id
668
- ), total_duration_ms)
670
+ ), 0)
669
671
  WHERE id IN (SELECT DISTINCT session_id FROM otel_metrics WHERE session_id IS NOT NULL)
670
672
  `).run();
671
673
 
672
- // Zero out cost/duration for sessions with no OTEL backing (unverifiable)
674
+ // Step 2: Recompute duration from turns for all sessions
673
675
  this.db.prepare(`
674
- UPDATE sessions SET total_cost_usd = 0, total_duration_ms = 0
675
- WHERE (total_cost_usd > 0 OR total_duration_ms > 0)
676
- 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)
677
689
  `).run();
678
690
 
679
691
  return updated.changes;