zozul-cli 0.1.1 → 0.2.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.
@@ -4,11 +4,14 @@ import { ingestSessionFile } from "../parser/ingest.js";
4
4
  import { handleOtlpMetrics, handleOtlpLogs } from "../otel/receiver.js";
5
5
  import { dashboardHtml, dashboardHtmlWithToggle } from "../dashboard/html.js";
6
6
  import { getActiveContext, clearActiveContext } from "../context/index.js";
7
+ import { ZozulApiClient } from "../sync/client.js";
8
+ import { syncSingleSession, runSync } from "../sync/index.js";
7
9
 
8
10
  export interface HookServerOptions {
9
11
  port: number;
10
12
  repo: SessionRepo;
11
13
  verbose?: boolean;
14
+ syncClient?: ZozulApiClient;
12
15
  }
13
16
 
14
17
  /**
@@ -19,7 +22,7 @@ export interface HookServerOptions {
19
22
  * - Web dashboard (GET /dashboard)
20
23
  */
21
24
  export function createHookServer(opts: HookServerOptions): http.Server {
22
- const { repo, verbose } = opts;
25
+ const { repo, verbose, syncClient } = opts;
23
26
  // Track last SessionEnd time per session to suppress rapid duplicates (Claude Code
24
27
  // sometimes fires two SessionEnd events within seconds for the same session).
25
28
  const lastSessionEnd = new Map<string, number>();
@@ -48,7 +51,13 @@ export function createHookServer(opts: HookServerOptions): http.Server {
48
51
 
49
52
  // ── Hook events ──
50
53
  if (method === "POST" && url.startsWith("/hook")) {
51
- await handleHookEvent(url, req, repo, res, verbose, lastSessionEnd);
54
+ await handleHookEvent(url, req, repo, res, verbose, lastSessionEnd, syncClient);
55
+ return;
56
+ }
57
+
58
+ // ── Health check ──
59
+ if (method === "GET" && url === "/health") {
60
+ sendJson(res, 200, { status: "ok" });
52
61
  return;
53
62
  }
54
63
 
@@ -59,7 +68,7 @@ export function createHookServer(opts: HookServerOptions): http.Server {
59
68
  const html = apiUrl && apiKey
60
69
  ? dashboardHtmlWithToggle({ apiUrl, apiKey }, "local")
61
70
  : dashboardHtml();
62
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
71
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
63
72
  res.end(html);
64
73
  return;
65
74
  }
@@ -91,6 +100,7 @@ async function handleHookEvent(
91
100
  res: http.ServerResponse,
92
101
  verbose?: boolean,
93
102
  lastSessionEnd?: Map<string, number>,
103
+ syncClient?: ZozulApiClient,
94
104
  ): Promise<void> {
95
105
  const body = await readBody(req);
96
106
 
@@ -127,6 +137,17 @@ async function handleHookEvent(
127
137
  } catch (err) {
128
138
  if (verbose) log(` -> transcript ingest failed: ${err}`);
129
139
  }
140
+
141
+ // Sync session immediately, then do a delayed sweep to catch trailing OTEL data
142
+ if (syncClient && payload.session_id) {
143
+ syncSingleSession(repo, syncClient, payload.session_id, { verbose }).catch(() => {});
144
+ setTimeout(() => runSync(repo, syncClient, { verbose }).catch(() => {}), 90_000);
145
+ }
146
+ }
147
+
148
+ // On Stop: sync the current session immediately
149
+ if (eventName === "Stop" && payload.session_id && syncClient) {
150
+ syncSingleSession(repo, syncClient, payload.session_id, { verbose }).catch(() => {});
130
151
  }
131
152
 
132
153
  // Auto-clear context when Claude runs git commit or git push
@@ -1,6 +1,22 @@
1
1
  import type Database from "better-sqlite3";
2
2
  import type { SessionRow, TurnRow, ToolUseRow, HookEventRow, OtelMetricRow, OtelEventRow, TaskTagRow } from "./db.js";
3
3
 
4
+ /**
5
+ * SQL expression that estimates per-turn cost by distributing the session's
6
+ * OTEL-accumulated total_cost_usd proportionally based on ALL token types
7
+ * (input, output, cache_read, cache_creation).
8
+ * Requires tables aliased as `t` (turns) and `s` (sessions).
9
+ */
10
+ const PROPORTIONAL_COST_SQL = `
11
+ CASE WHEN COALESCE(s.total_input_tokens, 0) + COALESCE(s.total_output_tokens, 0)
12
+ + COALESCE(s.total_cache_read_tokens, 0) + COALESCE(s.total_cache_creation_tokens, 0) > 0
13
+ THEN s.total_cost_usd
14
+ * CAST(COALESCE(t.input_tokens, 0) + COALESCE(t.output_tokens, 0)
15
+ + COALESCE(t.cache_read_tokens, 0) + COALESCE(t.cache_creation_tokens, 0) AS REAL)
16
+ / (COALESCE(s.total_input_tokens, 0) + COALESCE(s.total_output_tokens, 0)
17
+ + COALESCE(s.total_cache_read_tokens, 0) + COALESCE(s.total_cache_creation_tokens, 0))
18
+ ELSE 0 END`;
19
+
4
20
  export class SessionRepo {
5
21
  constructor(private db: Database.Database) {}
6
22
 
@@ -353,14 +369,23 @@ export class SessionRepo {
353
369
  ).all(turnId) as TaskTagRow[];
354
370
  }
355
371
 
356
- getTurnsByTask(task: string, limit = 50, offset = 0): TurnRow[] {
372
+ getTurnsByTask(task: string, limit = 50, offset = 0): (TurnRow & { block_input_tokens: number; block_output_tokens: number; block_cache_read_tokens: number; block_cache_creation_tokens: number })[] {
373
+ const blockWhere = `b.session_id = t.session_id AND b.turn_index >= t.turn_index
374
+ AND b.turn_index < COALESCE(
375
+ (SELECT MIN(n.turn_index) FROM turns n WHERE n.session_id = t.session_id AND n.turn_index > t.turn_index AND n.is_real_user = 1),
376
+ 999999)`;
357
377
  return this.db.prepare(`
358
- SELECT t.* FROM turns t
378
+ SELECT t.*,
379
+ (SELECT COALESCE(SUM(b.input_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_input_tokens,
380
+ (SELECT COALESCE(SUM(b.output_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_output_tokens,
381
+ (SELECT COALESCE(SUM(b.cache_read_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_cache_read_tokens,
382
+ (SELECT COALESCE(SUM(b.cache_creation_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_cache_creation_tokens
383
+ FROM turns t
359
384
  JOIN task_tags tt ON tt.turn_id = t.id
360
- WHERE tt.task = ?
385
+ WHERE tt.task = ? AND t.is_real_user = 1
361
386
  ORDER BY t.timestamp DESC
362
387
  LIMIT ? OFFSET ?
363
- `).all(task, limit, offset) as TurnRow[];
388
+ `).all(task, limit, offset) as (TurnRow & { block_input_tokens: number; block_output_tokens: number; block_cache_read_tokens: number; block_cache_creation_tokens: number })[];
364
389
  }
365
390
 
366
391
  getTaggedTurns(opts: {
@@ -370,7 +395,7 @@ export class SessionRepo {
370
395
  to?: string;
371
396
  limit?: number;
372
397
  offset?: number;
373
- } = {}): (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cost_usd: number })[] {
398
+ } = {}): (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cache_read_tokens: number; block_cache_creation_tokens: number })[] {
374
399
  const { tags, mode = "any", from, to, limit = 50, offset = 0 } = opts;
375
400
  const conditions: string[] = [];
376
401
  const params: unknown[] = [];
@@ -402,33 +427,23 @@ export class SessionRepo {
402
427
  const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
403
428
  params.push(limit, offset);
404
429
 
405
- // Aggregate block stats: sum tokens/cost from this turn to the next real user turn
430
+ // Aggregate block stats: sum tokens from this turn to the next real user turn
431
+ const blockWhere = `b.session_id = t.session_id AND b.turn_index >= t.turn_index
432
+ AND b.turn_index < COALESCE(
433
+ (SELECT MIN(n.turn_index) FROM turns n WHERE n.session_id = t.session_id AND n.turn_index > t.turn_index AND n.is_real_user = 1),
434
+ 999999)`;
406
435
  return this.db.prepare(`
407
436
  SELECT t.*,
408
437
  (SELECT GROUP_CONCAT(tt2.task, ', ') FROM task_tags tt2 WHERE tt2.turn_id = t.id) as tags,
409
- (SELECT COALESCE(SUM(b.input_tokens), 0) FROM turns b
410
- WHERE b.session_id = t.session_id AND b.turn_index >= t.turn_index
411
- AND b.turn_index < COALESCE(
412
- (SELECT MIN(n.turn_index) FROM turns n WHERE n.session_id = t.session_id AND n.turn_index > t.turn_index AND n.is_real_user = 1),
413
- 999999)
414
- ) as block_input_tokens,
415
- (SELECT COALESCE(SUM(b.output_tokens), 0) FROM turns b
416
- WHERE b.session_id = t.session_id AND b.turn_index >= t.turn_index
417
- AND b.turn_index < COALESCE(
418
- (SELECT MIN(n.turn_index) FROM turns n WHERE n.session_id = t.session_id AND n.turn_index > t.turn_index AND n.is_real_user = 1),
419
- 999999)
420
- ) as block_output_tokens,
421
- (SELECT COALESCE(SUM(b.cost_usd), 0) FROM turns b
422
- WHERE b.session_id = t.session_id AND b.turn_index >= t.turn_index
423
- AND b.turn_index < COALESCE(
424
- (SELECT MIN(n.turn_index) FROM turns n WHERE n.session_id = t.session_id AND n.turn_index > t.turn_index AND n.is_real_user = 1),
425
- 999999)
426
- ) as block_cost_usd
438
+ (SELECT COALESCE(SUM(b.input_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_input_tokens,
439
+ (SELECT COALESCE(SUM(b.output_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_output_tokens,
440
+ (SELECT COALESCE(SUM(b.cache_read_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_cache_read_tokens,
441
+ (SELECT COALESCE(SUM(b.cache_creation_tokens), 0) FROM turns b WHERE ${blockWhere}) as block_cache_creation_tokens
427
442
  FROM turns t
428
443
  ${where}
429
444
  ORDER BY t.timestamp DESC
430
445
  LIMIT ? OFFSET ?
431
- `).all(...params) as (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cost_usd: number })[];
446
+ `).all(...params) as (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cache_read_tokens: number; block_cache_creation_tokens: number })[];
432
447
  }
433
448
 
434
449
  getTurnBlock(turnId: number): TurnRow[] {
@@ -445,9 +460,11 @@ export class SessionRepo {
445
460
  const maxIndex = nextRealUser ? nextRealUser.turn_index : 999999;
446
461
 
447
462
  return this.db.prepare(`
448
- SELECT * FROM turns
449
- WHERE session_id = ? AND turn_index >= ? AND turn_index < ?
450
- ORDER BY turn_index ASC
463
+ SELECT t.*, ${PROPORTIONAL_COST_SQL} as estimated_cost_usd
464
+ FROM turns t
465
+ JOIN sessions s ON s.id = t.session_id
466
+ WHERE t.session_id = ? AND t.turn_index >= ? AND t.turn_index < ?
467
+ ORDER BY t.turn_index ASC
451
468
  `).all(turn.session_id, turn.turn_index, maxIndex) as TurnRow[];
452
469
  }
453
470
 
@@ -462,10 +479,11 @@ export class SessionRepo {
462
479
  SUM(t.input_tokens) as total_input_tokens,
463
480
  SUM(t.output_tokens) as total_output_tokens,
464
481
  SUM(t.cache_read_tokens) as total_cache_read_tokens,
465
- SUM(t.cost_usd) as total_cost_usd,
482
+ SUM(${PROPORTIONAL_COST_SQL}) as total_cost_usd,
466
483
  SUM(t.duration_ms) as total_duration_ms
467
484
  FROM turns t
468
485
  JOIN task_tags tt ON tt.turn_id = t.id
486
+ JOIN sessions s ON s.id = t.session_id
469
487
  WHERE tt.task = ?${timeFilter}
470
488
  `).get(...params);
471
489
  }
@@ -484,9 +502,10 @@ export class SessionRepo {
484
502
  SUM(t.input_tokens) as total_input_tokens,
485
503
  SUM(t.output_tokens) as total_output_tokens,
486
504
  SUM(t.cache_read_tokens) as total_cache_read_tokens,
487
- SUM(t.cost_usd) as total_cost_usd,
505
+ SUM(${PROPORTIONAL_COST_SQL}) as total_cost_usd,
488
506
  SUM(t.duration_ms) as total_duration_ms
489
507
  FROM turns t
508
+ JOIN sessions s ON s.id = t.session_id
490
509
  WHERE t.id IN (
491
510
  SELECT DISTINCT turn_id FROM task_tags WHERE task IN (${placeholders})
492
511
  )${timeFilter}
@@ -499,9 +518,10 @@ export class SessionRepo {
499
518
  SUM(t.input_tokens) as total_input_tokens,
500
519
  SUM(t.output_tokens) as total_output_tokens,
501
520
  SUM(t.cache_read_tokens) as total_cache_read_tokens,
502
- SUM(t.cost_usd) as total_cost_usd,
521
+ SUM(${PROPORTIONAL_COST_SQL}) as total_cost_usd,
503
522
  SUM(t.duration_ms) as total_duration_ms
504
523
  FROM turns t
524
+ JOIN sessions s ON s.id = t.session_id
505
525
  WHERE t.id IN (
506
526
  SELECT turn_id FROM task_tags
507
527
  WHERE task IN (${placeholders})
package/src/sync/index.ts CHANGED
@@ -61,6 +61,30 @@ function buildSessionPayload(
61
61
  };
62
62
  }
63
63
 
64
+ /**
65
+ * Sync a single session by ID — used for immediate post-Stop/SessionEnd sync.
66
+ * Silently no-ops if the session doesn't exist.
67
+ */
68
+ export async function syncSingleSession(
69
+ repo: SessionRepo,
70
+ client: ZozulApiClient,
71
+ sessionId: string,
72
+ opts: SyncOptions = {},
73
+ ): Promise<void> {
74
+ const sessions = repo.getSessionsByIds([sessionId]);
75
+ if (sessions.length === 0) return;
76
+
77
+ const payload = buildSessionPayload(repo, sessions[0]);
78
+ if (opts.dryRun) return;
79
+
80
+ try {
81
+ await client.syncSession(sessionId, payload);
82
+ if (opts.verbose) console.log(` auto-sync ${sessionId}: ok`);
83
+ } catch (err) {
84
+ if (opts.verbose) console.error(` auto-sync ${sessionId}: failed — ${err instanceof Error ? err.message : err}`);
85
+ }
86
+ }
87
+
64
88
  export async function runSync(
65
89
  repo: SessionRepo,
66
90
  client: ZozulApiClient,