zozul-cli 0.1.0 → 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.
- package/.github/workflows/publish.yml +1 -1
- package/DEVELOPMENT.md +123 -12
- package/README.md +18 -14
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +10 -1
- package/dist/cli/commands.js.map +1 -1
- package/dist/dashboard/html.d.ts +5 -6
- package/dist/dashboard/html.d.ts.map +1 -1
- package/dist/dashboard/html.js +7 -50
- package/dist/dashboard/html.js.map +1 -1
- package/dist/dashboard/index.html +1015 -739
- package/dist/hooks/server.d.ts +2 -0
- package/dist/hooks/server.d.ts.map +1 -1
- package/dist/hooks/server.js +19 -4
- package/dist/hooks/server.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 +46 -27
- package/dist/storage/repo.js.map +1 -1
- package/dist/sync/index.d.ts +5 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +21 -0
- package/dist/sync/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands.ts +12 -1
- package/src/dashboard/html.ts +7 -61
- package/src/dashboard/index.html +1015 -739
- package/src/hooks/server.ts +24 -3
- package/src/storage/repo.ts +51 -31
- package/src/sync/index.ts +24 -0
package/src/hooks/server.ts
CHANGED
|
@@ -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
|
package/src/storage/repo.ts
CHANGED
|
@@ -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
|
|
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;
|
|
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
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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;
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|