zozul-cli 0.1.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 (139) hide show
  1. package/.env.example +44 -0
  2. package/.github/workflows/publish.yml +26 -0
  3. package/DEVELOPMENT.md +288 -0
  4. package/LICENSE +201 -0
  5. package/README.md +178 -0
  6. package/dist/cli/commands.d.ts +3 -0
  7. package/dist/cli/commands.d.ts.map +1 -0
  8. package/dist/cli/commands.js +307 -0
  9. package/dist/cli/commands.js.map +1 -0
  10. package/dist/cli/format.d.ts +5 -0
  11. package/dist/cli/format.d.ts.map +1 -0
  12. package/dist/cli/format.js +115 -0
  13. package/dist/cli/format.js.map +1 -0
  14. package/dist/context/index.d.ts +8 -0
  15. package/dist/context/index.d.ts.map +1 -0
  16. package/dist/context/index.js +37 -0
  17. package/dist/context/index.js.map +1 -0
  18. package/dist/dashboard/html.d.ts +17 -0
  19. package/dist/dashboard/html.d.ts.map +1 -0
  20. package/dist/dashboard/html.js +79 -0
  21. package/dist/dashboard/html.js.map +1 -0
  22. package/dist/dashboard/index.html +1245 -0
  23. package/dist/hooks/config.d.ts +19 -0
  24. package/dist/hooks/config.d.ts.map +1 -0
  25. package/dist/hooks/config.js +106 -0
  26. package/dist/hooks/config.js.map +1 -0
  27. package/dist/hooks/git.d.ts +6 -0
  28. package/dist/hooks/git.d.ts.map +1 -0
  29. package/dist/hooks/git.js +73 -0
  30. package/dist/hooks/git.js.map +1 -0
  31. package/dist/hooks/index.d.ts +4 -0
  32. package/dist/hooks/index.d.ts.map +1 -0
  33. package/dist/hooks/index.js +3 -0
  34. package/dist/hooks/index.js.map +1 -0
  35. package/dist/hooks/server.d.ts +16 -0
  36. package/dist/hooks/server.d.ts.map +1 -0
  37. package/dist/hooks/server.js +349 -0
  38. package/dist/hooks/server.js.map +1 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +6 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/otel/config.d.ts +36 -0
  44. package/dist/otel/config.d.ts.map +1 -0
  45. package/dist/otel/config.js +109 -0
  46. package/dist/otel/config.js.map +1 -0
  47. package/dist/otel/index.d.ts +4 -0
  48. package/dist/otel/index.d.ts.map +1 -0
  49. package/dist/otel/index.js +3 -0
  50. package/dist/otel/index.js.map +1 -0
  51. package/dist/otel/receiver.d.ts +10 -0
  52. package/dist/otel/receiver.d.ts.map +1 -0
  53. package/dist/otel/receiver.js +155 -0
  54. package/dist/otel/receiver.js.map +1 -0
  55. package/dist/parser/index.d.ts +4 -0
  56. package/dist/parser/index.d.ts.map +1 -0
  57. package/dist/parser/index.js +3 -0
  58. package/dist/parser/index.js.map +1 -0
  59. package/dist/parser/ingest.d.ts +20 -0
  60. package/dist/parser/ingest.d.ts.map +1 -0
  61. package/dist/parser/ingest.js +98 -0
  62. package/dist/parser/ingest.js.map +1 -0
  63. package/dist/parser/jsonl.d.ts +14 -0
  64. package/dist/parser/jsonl.d.ts.map +1 -0
  65. package/dist/parser/jsonl.js +202 -0
  66. package/dist/parser/jsonl.js.map +1 -0
  67. package/dist/parser/types.d.ts +81 -0
  68. package/dist/parser/types.d.ts.map +1 -0
  69. package/dist/parser/types.js +9 -0
  70. package/dist/parser/types.js.map +1 -0
  71. package/dist/parser/watcher.d.ts +16 -0
  72. package/dist/parser/watcher.d.ts.map +1 -0
  73. package/dist/parser/watcher.js +103 -0
  74. package/dist/parser/watcher.js.map +1 -0
  75. package/dist/pricing/index.d.ts +2 -0
  76. package/dist/pricing/index.d.ts.map +1 -0
  77. package/dist/pricing/index.js +37 -0
  78. package/dist/pricing/index.js.map +1 -0
  79. package/dist/service/index.d.ts +31 -0
  80. package/dist/service/index.d.ts.map +1 -0
  81. package/dist/service/index.js +252 -0
  82. package/dist/service/index.js.map +1 -0
  83. package/dist/storage/db.d.ts +75 -0
  84. package/dist/storage/db.d.ts.map +1 -0
  85. package/dist/storage/db.js +117 -0
  86. package/dist/storage/db.js.map +1 -0
  87. package/dist/storage/index.d.ts +4 -0
  88. package/dist/storage/index.d.ts.map +1 -0
  89. package/dist/storage/index.js +3 -0
  90. package/dist/storage/index.js.map +1 -0
  91. package/dist/storage/repo.d.ts +162 -0
  92. package/dist/storage/repo.d.ts.map +1 -0
  93. package/dist/storage/repo.js +472 -0
  94. package/dist/storage/repo.js.map +1 -0
  95. package/dist/sync/client.d.ts +24 -0
  96. package/dist/sync/client.d.ts.map +1 -0
  97. package/dist/sync/client.js +41 -0
  98. package/dist/sync/client.js.map +1 -0
  99. package/dist/sync/index.d.ts +18 -0
  100. package/dist/sync/index.d.ts.map +1 -0
  101. package/dist/sync/index.js +135 -0
  102. package/dist/sync/index.js.map +1 -0
  103. package/dist/sync/sync.test.d.ts +2 -0
  104. package/dist/sync/sync.test.d.ts.map +1 -0
  105. package/dist/sync/sync.test.js +412 -0
  106. package/dist/sync/sync.test.js.map +1 -0
  107. package/dist/sync/transform.d.ts +80 -0
  108. package/dist/sync/transform.d.ts.map +1 -0
  109. package/dist/sync/transform.js +90 -0
  110. package/dist/sync/transform.js.map +1 -0
  111. package/package.json +50 -0
  112. package/src/cli/commands.ts +332 -0
  113. package/src/cli/format.ts +133 -0
  114. package/src/context/index.ts +42 -0
  115. package/src/dashboard/html.ts +97 -0
  116. package/src/dashboard/index.html +1245 -0
  117. package/src/hooks/config.ts +119 -0
  118. package/src/hooks/git.ts +77 -0
  119. package/src/hooks/index.ts +7 -0
  120. package/src/hooks/server.ts +397 -0
  121. package/src/index.ts +6 -0
  122. package/src/otel/config.ts +141 -0
  123. package/src/otel/index.ts +8 -0
  124. package/src/otel/receiver.ts +183 -0
  125. package/src/parser/index.ts +3 -0
  126. package/src/parser/ingest.ts +119 -0
  127. package/src/parser/jsonl.ts +241 -0
  128. package/src/parser/types.ts +89 -0
  129. package/src/parser/watcher.ts +116 -0
  130. package/src/pricing/index.ts +51 -0
  131. package/src/service/index.ts +272 -0
  132. package/src/storage/db.ts +198 -0
  133. package/src/storage/index.ts +3 -0
  134. package/src/storage/repo.ts +601 -0
  135. package/src/sync/client.ts +63 -0
  136. package/src/sync/index.ts +207 -0
  137. package/src/sync/sync.test.ts +447 -0
  138. package/src/sync/transform.ts +184 -0
  139. package/tsconfig.json +19 -0
@@ -0,0 +1,601 @@
1
+ import type Database from "better-sqlite3";
2
+ import type { SessionRow, TurnRow, ToolUseRow, HookEventRow, OtelMetricRow, OtelEventRow, TaskTagRow } from "./db.js";
3
+
4
+ export class SessionRepo {
5
+ constructor(private db: Database.Database) {}
6
+
7
+ upsertSession(session: Omit<SessionRow, "ended_at"> & { ended_at?: string | null }): void {
8
+ this.db.prepare(`
9
+ INSERT INTO sessions (id, project_path, started_at, ended_at, total_input_tokens,
10
+ total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
11
+ total_cost_usd, total_turns, total_duration_ms, model)
12
+ VALUES (@id, @project_path, @started_at, @ended_at, @total_input_tokens,
13
+ @total_output_tokens, @total_cache_read_tokens, @total_cache_creation_tokens,
14
+ @total_cost_usd, @total_turns, @total_duration_ms, @model)
15
+ ON CONFLICT(id) DO UPDATE SET
16
+ started_at = MIN(started_at, @started_at),
17
+ project_path = COALESCE(@project_path, project_path),
18
+ ended_at = COALESCE(@ended_at, ended_at),
19
+ total_turns = @total_turns,
20
+ model = COALESCE(@model, model),
21
+ -- Use MAX so that OTEL-accumulated values are never clobbered by a JSONL re-ingest
22
+ total_input_tokens = MAX(total_input_tokens, @total_input_tokens),
23
+ total_output_tokens = MAX(total_output_tokens, @total_output_tokens),
24
+ total_cache_read_tokens = MAX(total_cache_read_tokens, @total_cache_read_tokens),
25
+ total_cache_creation_tokens = MAX(total_cache_creation_tokens, @total_cache_creation_tokens),
26
+ total_cost_usd = MAX(total_cost_usd, @total_cost_usd),
27
+ total_duration_ms = MAX(total_duration_ms, @total_duration_ms)
28
+ `).run({
29
+ ...session,
30
+ ended_at: session.ended_at ?? null,
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Accumulate metric deltas from a single OTEL export batch into the sessions row.
36
+ * Creates a stub session row if none exists yet (OTEL may arrive before JSONL ingest).
37
+ * All numeric fields are additive deltas — never a full replacement.
38
+ */
39
+ updateSessionFromOtel(sessionId: string, deltas: {
40
+ costDelta: number;
41
+ inputDelta: number;
42
+ outputDelta: number;
43
+ cacheReadDelta: number;
44
+ cacheCreationDelta: number;
45
+ durationMsDelta: number;
46
+ latestTimestamp: string;
47
+ model: string | null;
48
+ }): void {
49
+ this.db.prepare(`
50
+ INSERT INTO sessions (id, started_at, ended_at, total_cost_usd, total_input_tokens,
51
+ total_output_tokens, total_cache_read_tokens, total_cache_creation_tokens,
52
+ total_duration_ms, model)
53
+ VALUES (@id, @ts, @ts, @cost, @input, @output, @cache_read, @cache_creation, @duration_ms, @model)
54
+ ON CONFLICT(id) DO UPDATE SET
55
+ total_cost_usd = total_cost_usd + @cost,
56
+ total_input_tokens = total_input_tokens + @input,
57
+ total_output_tokens = total_output_tokens + @output,
58
+ total_cache_read_tokens = total_cache_read_tokens + @cache_read,
59
+ total_cache_creation_tokens = total_cache_creation_tokens + @cache_creation,
60
+ total_duration_ms = total_duration_ms + @duration_ms,
61
+ ended_at = CASE WHEN @ts > COALESCE(ended_at, '') THEN @ts ELSE ended_at END,
62
+ model = COALESCE(@model, model)
63
+ `).run({
64
+ id: sessionId,
65
+ ts: deltas.latestTimestamp,
66
+ cost: deltas.costDelta,
67
+ input: deltas.inputDelta,
68
+ output: deltas.outputDelta,
69
+ cache_read: deltas.cacheReadDelta,
70
+ cache_creation: deltas.cacheCreationDelta,
71
+ duration_ms: deltas.durationMsDelta,
72
+ model: deltas.model,
73
+ });
74
+ }
75
+
76
+ insertTurn(turn: Omit<TurnRow, "id">): number {
77
+ // Use RETURNING so we get the correct id on both INSERT and ON CONFLICT UPDATE
78
+ const row = this.db.prepare(`
79
+ INSERT INTO turns (session_id, turn_index, role, timestamp, input_tokens, output_tokens,
80
+ cache_read_tokens, cache_creation_tokens, cost_usd, duration_ms, model, content_text, tool_calls, is_real_user)
81
+ VALUES (@session_id, @turn_index, @role, @timestamp, @input_tokens, @output_tokens,
82
+ @cache_read_tokens, @cache_creation_tokens, @cost_usd, @duration_ms, @model, @content_text, @tool_calls, @is_real_user)
83
+ ON CONFLICT(session_id, turn_index) DO UPDATE SET
84
+ content_text = @content_text,
85
+ tool_calls = @tool_calls,
86
+ input_tokens = @input_tokens,
87
+ output_tokens = @output_tokens,
88
+ cost_usd = MAX(cost_usd, @cost_usd),
89
+ duration_ms = MAX(duration_ms, @duration_ms),
90
+ is_real_user = @is_real_user
91
+ RETURNING id
92
+ `).get(turn) as { id: number } | undefined;
93
+ return row?.id ?? 0;
94
+ }
95
+
96
+ insertToolUse(toolUse: {
97
+ session_id: string;
98
+ turn_id: number | null;
99
+ tool_name: string;
100
+ tool_input: string | null;
101
+ tool_result: string | null;
102
+ success: number | null;
103
+ duration_ms: number;
104
+ timestamp: string;
105
+ }): void {
106
+ this.db.prepare(`
107
+ INSERT INTO tool_uses (session_id, turn_id, tool_name, tool_input, tool_result, success, duration_ms, timestamp)
108
+ VALUES (@session_id, @turn_id, @tool_name, @tool_input, @tool_result, @success, @duration_ms, @timestamp)
109
+ `).run(toolUse);
110
+ }
111
+
112
+ /**
113
+ * Replace all tool uses for a given turn in a single transaction.
114
+ * Deletes existing rows first to prevent duplication on re-ingest.
115
+ */
116
+ replaceToolUsesForTurn(turnId: number, toolUses: {
117
+ session_id: string;
118
+ turn_id: number | null;
119
+ tool_name: string;
120
+ tool_input: string | null;
121
+ tool_result: string | null;
122
+ success: number | null;
123
+ duration_ms: number;
124
+ timestamp: string;
125
+ }[]): void {
126
+ const del = this.db.prepare(`DELETE FROM tool_uses WHERE turn_id = ?`);
127
+ const ins = this.db.prepare(`
128
+ INSERT INTO tool_uses (session_id, turn_id, tool_name, tool_input, tool_result, success, duration_ms, timestamp)
129
+ VALUES (@session_id, @turn_id, @tool_name, @tool_input, @tool_result, @success, @duration_ms, @timestamp)
130
+ `);
131
+ this.db.transaction(() => {
132
+ del.run(turnId);
133
+ for (const row of toolUses) ins.run(row);
134
+ })();
135
+ }
136
+
137
+ insertHookEvent(event: {
138
+ session_id: string | null;
139
+ event_name: string;
140
+ timestamp: string;
141
+ payload: string;
142
+ }): void {
143
+ this.db.prepare(`
144
+ INSERT INTO hook_events (session_id, event_name, timestamp, payload)
145
+ VALUES (@session_id, @event_name, @timestamp, @payload)
146
+ `).run(event);
147
+ }
148
+
149
+ listSessions(limit = 50, offset = 0): SessionRow[] {
150
+ return this.db.prepare(`
151
+ SELECT * FROM sessions ORDER BY started_at DESC LIMIT ? OFFSET ?
152
+ `).all(limit, offset) as SessionRow[];
153
+ }
154
+
155
+ countSessions(): number {
156
+ const row = this.db.prepare(`SELECT COUNT(*) as n FROM sessions`).get() as { n: number };
157
+ return row.n;
158
+ }
159
+
160
+ getSession(id: string): SessionRow | undefined {
161
+ return this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id) as SessionRow | undefined;
162
+ }
163
+
164
+ getSessionTurns(sessionId: string): TurnRow[] {
165
+ return this.db.prepare(`
166
+ SELECT * FROM turns WHERE session_id = ? ORDER BY turn_index ASC
167
+ `).all(sessionId) as TurnRow[];
168
+ }
169
+
170
+ getSessionToolUses(sessionId: string) {
171
+ return this.db.prepare(`
172
+ SELECT * FROM tool_uses WHERE session_id = ? ORDER BY timestamp ASC
173
+ `).all(sessionId);
174
+ }
175
+
176
+ getSessionHookEvents(sessionId: string) {
177
+ return this.db.prepare(`
178
+ SELECT * FROM hook_events WHERE session_id = ? ORDER BY timestamp ASC
179
+ `).all(sessionId);
180
+ }
181
+
182
+ getAggregateStats() {
183
+ return this.db.prepare(`
184
+ SELECT
185
+ (SELECT COUNT(*) FROM sessions) as total_sessions,
186
+ (SELECT SUM(total_input_tokens) FROM sessions) as total_input_tokens,
187
+ (SELECT SUM(total_output_tokens) FROM sessions) as total_output_tokens,
188
+ (SELECT SUM(total_cache_read_tokens) FROM sessions) as total_cache_read_tokens,
189
+ (SELECT SUM(total_cost_usd) FROM sessions) as total_cost_usd,
190
+ (SELECT SUM(total_turns) FROM sessions) as total_turns,
191
+ (SELECT SUM(total_duration_ms) FROM sessions) as total_duration_ms,
192
+ (SELECT COUNT(*) FROM hook_events WHERE event_name = 'UserPromptSubmit') as total_user_prompts,
193
+ (SELECT COUNT(*) FROM hook_events WHERE event_name = 'Stop') as total_interruptions
194
+ `).get();
195
+ }
196
+
197
+ // ── OTEL data ──
198
+
199
+ insertOtelMetric(metric: {
200
+ name: string;
201
+ value: number;
202
+ attributes: string | null;
203
+ session_id: string | null;
204
+ model: string | null;
205
+ timestamp: string;
206
+ }): void {
207
+ this.db.prepare(`
208
+ INSERT INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
209
+ VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
210
+ `).run(metric);
211
+ }
212
+
213
+ insertOtelMetricBatch(metrics: {
214
+ name: string;
215
+ value: number;
216
+ attributes: string | null;
217
+ session_id: string | null;
218
+ model: string | null;
219
+ timestamp: string;
220
+ }[]): void {
221
+ const stmt = this.db.prepare(`
222
+ INSERT INTO otel_metrics (name, value, attributes, session_id, model, timestamp)
223
+ VALUES (@name, @value, @attributes, @session_id, @model, @timestamp)
224
+ `);
225
+ const tx = this.db.transaction((rows: typeof metrics) => {
226
+ for (const row of rows) stmt.run(row);
227
+ });
228
+ tx(metrics);
229
+ }
230
+
231
+ insertOtelEvent(event: {
232
+ event_name: string;
233
+ attributes: string | null;
234
+ session_id: string | null;
235
+ prompt_id: string | null;
236
+ timestamp: string;
237
+ }): void {
238
+ this.db.prepare(`
239
+ INSERT INTO otel_events (event_name, attributes, session_id, prompt_id, timestamp)
240
+ VALUES (@event_name, @attributes, @session_id, @prompt_id, @timestamp)
241
+ `).run(event);
242
+ }
243
+
244
+ insertOtelEventBatch(events: {
245
+ event_name: string;
246
+ attributes: string | null;
247
+ session_id: string | null;
248
+ prompt_id: string | null;
249
+ timestamp: string;
250
+ }[]): void {
251
+ const stmt = this.db.prepare(`
252
+ INSERT INTO otel_events (event_name, attributes, session_id, prompt_id, timestamp)
253
+ VALUES (@event_name, @attributes, @session_id, @prompt_id, @timestamp)
254
+ `);
255
+ const tx = this.db.transaction((rows: typeof events) => {
256
+ for (const row of rows) stmt.run(row);
257
+ });
258
+ tx(events);
259
+ }
260
+
261
+ // ── Dashboard queries ──
262
+
263
+ getTokenTimeSeries(from: string, to: string, stepSeconds: number): { timestamp: string; input: number; output: number; cache_read: number }[] {
264
+ return this.db.prepare(`
265
+ SELECT
266
+ datetime((CAST(strftime('%s', timestamp) AS INTEGER) / CAST(? AS INTEGER)) * CAST(? AS INTEGER), 'unixepoch') as timestamp,
267
+ SUM(CASE WHEN json_extract(attributes, '$.type') = 'input' THEN value ELSE 0 END) as input,
268
+ SUM(CASE WHEN json_extract(attributes, '$.type') = 'output' THEN value ELSE 0 END) as output,
269
+ SUM(CASE WHEN json_extract(attributes, '$.type') = 'cacheRead' THEN value ELSE 0 END) as cache_read
270
+ FROM otel_metrics
271
+ WHERE name = 'claude_code.token.usage'
272
+ AND timestamp >= ? AND timestamp <= ?
273
+ GROUP BY 1
274
+ ORDER BY 1 ASC
275
+ `).all(stepSeconds, stepSeconds, from, to) as { timestamp: string; input: number; output: number; cache_read: number }[];
276
+ }
277
+
278
+ getCostTimeSeries(from: string, to: string, stepSeconds: number): { timestamp: string; cost: number }[] {
279
+ return this.db.prepare(`
280
+ SELECT
281
+ datetime((CAST(strftime('%s', timestamp) AS INTEGER) / CAST(? AS INTEGER)) * CAST(? AS INTEGER), 'unixepoch') as timestamp,
282
+ SUM(value) as cost
283
+ FROM otel_metrics
284
+ WHERE name = 'claude_code.cost.usage'
285
+ AND timestamp >= ? AND timestamp <= ?
286
+ GROUP BY 1
287
+ ORDER BY 1 ASC
288
+ `).all(stepSeconds, stepSeconds, from, to) as { timestamp: string; cost: number }[];
289
+ }
290
+
291
+ getToolUsageBreakdown(): { tool_name: string; count: number }[] {
292
+ return this.db.prepare(`
293
+ SELECT tool_name, COUNT(*) as count
294
+ FROM tool_uses
295
+ GROUP BY tool_name
296
+ ORDER BY count DESC
297
+ LIMIT 20
298
+ `).all() as { tool_name: string; count: number }[];
299
+ }
300
+
301
+ getModelBreakdown(): { model: string; cost: number; tokens: number }[] {
302
+ return this.db.prepare(`
303
+ SELECT
304
+ model,
305
+ SUM(CASE WHEN name = 'claude_code.cost.usage' THEN value ELSE 0 END) as cost,
306
+ SUM(CASE WHEN name = 'claude_code.token.usage' THEN value ELSE 0 END) as tokens
307
+ FROM otel_metrics
308
+ WHERE model IS NOT NULL
309
+ GROUP BY model
310
+ ORDER BY cost DESC
311
+ `).all() as { model: string; cost: number; tokens: number }[];
312
+ }
313
+
314
+ getRecentOtelEvents(limit = 50): { event_name: string; attributes: string | null; session_id: string | null; prompt_id: string | null; timestamp: string }[] {
315
+ return this.db.prepare(`
316
+ SELECT event_name, attributes, session_id, prompt_id, timestamp
317
+ FROM otel_events
318
+ ORDER BY timestamp DESC
319
+ LIMIT ?
320
+ `).all(limit) as { event_name: string; attributes: string | null; session_id: string | null; prompt_id: string | null; timestamp: string }[];
321
+ }
322
+
323
+ // ── Task tags ──
324
+
325
+ tagTurn(turnId: number, task: string): void {
326
+ this.db.prepare(`
327
+ INSERT OR IGNORE INTO task_tags (turn_id, task, tagged_at)
328
+ VALUES (?, ?, ?)
329
+ `).run(turnId, task, new Date().toISOString());
330
+ }
331
+
332
+ tagTurnsBatch(turnIds: number[], task: string): void {
333
+ const stmt = this.db.prepare(`
334
+ INSERT OR IGNORE INTO task_tags (turn_id, task, tagged_at)
335
+ VALUES (?, ?, ?)
336
+ `);
337
+ const taggedAt = new Date().toISOString();
338
+ this.db.transaction(() => {
339
+ for (const turnId of turnIds) stmt.run(turnId, task, taggedAt);
340
+ })();
341
+ }
342
+
343
+ getTasksForTurn(turnId: number): string[] {
344
+ const rows = this.db.prepare(`
345
+ SELECT task FROM task_tags WHERE turn_id = ? ORDER BY tagged_at ASC
346
+ `).all(turnId) as { task: string }[];
347
+ return rows.map(r => r.task);
348
+ }
349
+
350
+ getTaskTagsForTurn(turnId: number): TaskTagRow[] {
351
+ return this.db.prepare(
352
+ `SELECT * FROM task_tags WHERE turn_id = ? ORDER BY tagged_at ASC`
353
+ ).all(turnId) as TaskTagRow[];
354
+ }
355
+
356
+ getTurnsByTask(task: string, limit = 50, offset = 0): TurnRow[] {
357
+ return this.db.prepare(`
358
+ SELECT t.* FROM turns t
359
+ JOIN task_tags tt ON tt.turn_id = t.id
360
+ WHERE tt.task = ?
361
+ ORDER BY t.timestamp DESC
362
+ LIMIT ? OFFSET ?
363
+ `).all(task, limit, offset) as TurnRow[];
364
+ }
365
+
366
+ getTaggedTurns(opts: {
367
+ tags?: string[];
368
+ mode?: "all" | "any";
369
+ from?: string;
370
+ to?: string;
371
+ limit?: number;
372
+ offset?: number;
373
+ } = {}): (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cost_usd: number })[] {
374
+ const { tags, mode = "any", from, to, limit = 50, offset = 0 } = opts;
375
+ const conditions: string[] = [];
376
+ const params: unknown[] = [];
377
+
378
+ if (tags && tags.length > 0) {
379
+ const placeholders = tags.map(() => "?").join(",");
380
+ if (mode === "all" && tags.length > 1) {
381
+ conditions.push(`t.id IN (
382
+ SELECT turn_id FROM task_tags WHERE task IN (${placeholders})
383
+ GROUP BY turn_id HAVING COUNT(DISTINCT task) = ?
384
+ )`);
385
+ params.push(...tags, tags.length);
386
+ } else {
387
+ conditions.push(`t.id IN (SELECT DISTINCT turn_id FROM task_tags WHERE task IN (${placeholders}))`);
388
+ params.push(...tags);
389
+ }
390
+ } else {
391
+ conditions.push("t.id IN (SELECT DISTINCT turn_id FROM task_tags)");
392
+ }
393
+
394
+ if (from && to) {
395
+ conditions.push("t.timestamp >= ? AND t.timestamp <= ?");
396
+ params.push(from, to);
397
+ }
398
+
399
+ // Only show real user turns (each row = one interaction block)
400
+ conditions.push("t.is_real_user = 1");
401
+
402
+ const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
403
+ params.push(limit, offset);
404
+
405
+ // Aggregate block stats: sum tokens/cost from this turn to the next real user turn
406
+ return this.db.prepare(`
407
+ SELECT t.*,
408
+ (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
427
+ FROM turns t
428
+ ${where}
429
+ ORDER BY t.timestamp DESC
430
+ LIMIT ? OFFSET ?
431
+ `).all(...params) as (TurnRow & { tags: string; block_input_tokens: number; block_output_tokens: number; block_cost_usd: number })[];
432
+ }
433
+
434
+ getTurnBlock(turnId: number): TurnRow[] {
435
+ const turn = this.db.prepare(`SELECT * FROM turns WHERE id = ?`).get(turnId) as TurnRow | undefined;
436
+ if (!turn) return [];
437
+
438
+ // Find the next real user turn in the same session
439
+ const nextRealUser = this.db.prepare(`
440
+ SELECT turn_index FROM turns
441
+ WHERE session_id = ? AND turn_index > ? AND is_real_user = 1
442
+ ORDER BY turn_index ASC LIMIT 1
443
+ `).get(turn.session_id, turn.turn_index) as { turn_index: number } | undefined;
444
+
445
+ const maxIndex = nextRealUser ? nextRealUser.turn_index : 999999;
446
+
447
+ return this.db.prepare(`
448
+ SELECT * FROM turns
449
+ WHERE session_id = ? AND turn_index >= ? AND turn_index < ?
450
+ ORDER BY turn_index ASC
451
+ `).all(turn.session_id, turn.turn_index, maxIndex) as TurnRow[];
452
+ }
453
+
454
+ getStatsByTask(task: string, from?: string, to?: string) {
455
+ const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
456
+ const params: unknown[] = [task];
457
+ if (from && to) params.push(from, to);
458
+ return this.db.prepare(`
459
+ SELECT
460
+ COUNT(*) as total_turns,
461
+ SUM(t.is_real_user) as user_turns,
462
+ SUM(t.input_tokens) as total_input_tokens,
463
+ SUM(t.output_tokens) as total_output_tokens,
464
+ SUM(t.cache_read_tokens) as total_cache_read_tokens,
465
+ SUM(t.cost_usd) as total_cost_usd,
466
+ SUM(t.duration_ms) as total_duration_ms
467
+ FROM turns t
468
+ JOIN task_tags tt ON tt.turn_id = t.id
469
+ WHERE tt.task = ?${timeFilter}
470
+ `).get(...params);
471
+ }
472
+
473
+ getStatsByTasks(tasks: string[], mode: "all" | "any" = "all", from?: string, to?: string) {
474
+ if (tasks.length === 0) return null;
475
+ if (tasks.length === 1) return this.getStatsByTask(tasks[0], from, to);
476
+ const placeholders = tasks.map(() => "?").join(",");
477
+ const timeFilter = from && to ? " AND t.timestamp >= ? AND t.timestamp <= ?" : "";
478
+ const timeParams: unknown[] = from && to ? [from, to] : [];
479
+ if (mode === "any") {
480
+ return this.db.prepare(`
481
+ SELECT
482
+ COUNT(DISTINCT t.id) as total_turns,
483
+ SUM(t.is_real_user) as user_turns,
484
+ SUM(t.input_tokens) as total_input_tokens,
485
+ SUM(t.output_tokens) as total_output_tokens,
486
+ SUM(t.cache_read_tokens) as total_cache_read_tokens,
487
+ SUM(t.cost_usd) as total_cost_usd,
488
+ SUM(t.duration_ms) as total_duration_ms
489
+ FROM turns t
490
+ WHERE t.id IN (
491
+ SELECT DISTINCT turn_id FROM task_tags WHERE task IN (${placeholders})
492
+ )${timeFilter}
493
+ `).get(...tasks, ...timeParams);
494
+ }
495
+ return this.db.prepare(`
496
+ SELECT
497
+ COUNT(*) as total_turns,
498
+ SUM(t.is_real_user) as user_turns,
499
+ SUM(t.input_tokens) as total_input_tokens,
500
+ SUM(t.output_tokens) as total_output_tokens,
501
+ SUM(t.cache_read_tokens) as total_cache_read_tokens,
502
+ SUM(t.cost_usd) as total_cost_usd,
503
+ SUM(t.duration_ms) as total_duration_ms
504
+ FROM turns t
505
+ WHERE t.id IN (
506
+ SELECT turn_id FROM task_tags
507
+ WHERE task IN (${placeholders})
508
+ GROUP BY turn_id
509
+ HAVING COUNT(DISTINCT task) = ?
510
+ )${timeFilter}
511
+ `).get(...tasks, tasks.length, ...timeParams);
512
+ }
513
+
514
+ listTasks(): { task: string; turn_count: number; first_tagged: string; last_tagged: string }[] {
515
+ return this.db.prepare(`
516
+ SELECT task, COUNT(*) as turn_count,
517
+ MIN(tagged_at) as first_tagged, MAX(tagged_at) as last_tagged
518
+ FROM task_tags
519
+ GROUP BY task
520
+ ORDER BY last_tagged DESC
521
+ `).all() as { task: string; turn_count: number; first_tagged: string; last_tagged: string }[];
522
+ }
523
+
524
+ // ── Sync watermarks ──
525
+
526
+ getSyncWatermark(tableName: string): number {
527
+ const row = this.db.prepare(
528
+ `SELECT last_synced_id FROM sync_watermarks WHERE table_name = ?`
529
+ ).get(tableName) as { last_synced_id: number } | undefined;
530
+ return row?.last_synced_id ?? 0;
531
+ }
532
+
533
+ setSyncWatermark(tableName: string, lastSyncedId: number): void {
534
+ this.db.prepare(`
535
+ INSERT INTO sync_watermarks (table_name, last_synced_id, last_synced_at)
536
+ VALUES (?, ?, datetime('now'))
537
+ ON CONFLICT(table_name) DO UPDATE SET
538
+ last_synced_id = excluded.last_synced_id,
539
+ last_synced_at = excluded.last_synced_at
540
+ `).run(tableName, lastSyncedId);
541
+ }
542
+
543
+ // ── Bulk reads for sync ──
544
+
545
+ getUnsyncedSessions(minRowid: number): (SessionRow & { _rowid: number })[] {
546
+ return this.db.prepare(`
547
+ SELECT *, rowid as _rowid FROM sessions WHERE rowid > ? ORDER BY rowid ASC
548
+ `).all(minRowid) as (SessionRow & { _rowid: number })[];
549
+ }
550
+
551
+ getSessionsByIds(ids: string[]): SessionRow[] {
552
+ if (ids.length === 0) return [];
553
+ const placeholders = ids.map(() => "?").join(",");
554
+ return this.db.prepare(
555
+ `SELECT * FROM sessions WHERE id IN (${placeholders})`
556
+ ).all(...ids) as SessionRow[];
557
+ }
558
+
559
+ getTurnsAfter(minId: number, limit: number): TurnRow[] {
560
+ return this.db.prepare(
561
+ `SELECT * FROM turns WHERE id > ? ORDER BY id ASC LIMIT ?`
562
+ ).all(minId, limit) as TurnRow[];
563
+ }
564
+
565
+ getToolUsesAfter(minId: number, limit: number): ToolUseRow[] {
566
+ return this.db.prepare(
567
+ `SELECT * FROM tool_uses WHERE id > ? ORDER BY id ASC LIMIT ?`
568
+ ).all(minId, limit) as ToolUseRow[];
569
+ }
570
+
571
+ getHookEventsAfter(minId: number, limit: number): HookEventRow[] {
572
+ return this.db.prepare(
573
+ `SELECT * FROM hook_events WHERE id > ? ORDER BY id ASC LIMIT ?`
574
+ ).all(minId, limit) as HookEventRow[];
575
+ }
576
+
577
+ getOtelMetricsAfter(minId: number, limit: number): OtelMetricRow[] {
578
+ return this.db.prepare(
579
+ `SELECT * FROM otel_metrics WHERE id > ? ORDER BY id ASC LIMIT ?`
580
+ ).all(minId, limit) as OtelMetricRow[];
581
+ }
582
+
583
+ getOtelEventsAfter(minId: number, limit: number): OtelEventRow[] {
584
+ return this.db.prepare(
585
+ `SELECT * FROM otel_events WHERE id > ? ORDER BY id ASC LIMIT ?`
586
+ ).all(minId, limit) as OtelEventRow[];
587
+ }
588
+
589
+ getTaskTagsAfter(minId: number, limit: number): TaskTagRow[] {
590
+ return this.db.prepare(
591
+ `SELECT * FROM task_tags WHERE id > ? ORDER BY id ASC LIMIT ?`
592
+ ).all(minId, limit) as TaskTagRow[];
593
+ }
594
+
595
+ getTurnLookup(): Map<number, { session_id: string; turn_index: number }> {
596
+ const rows = this.db.prepare(
597
+ `SELECT id, session_id, turn_index FROM turns`
598
+ ).all() as { id: number; session_id: string; turn_index: number }[];
599
+ return new Map(rows.map(r => [r.id, { session_id: r.session_id, turn_index: r.turn_index }]));
600
+ }
601
+ }
@@ -0,0 +1,63 @@
1
+ import type { SessionSyncPayload, ApiOtelMetric, ApiOtelEvent } from "./transform.js";
2
+
3
+ export interface SyncClientConfig {
4
+ apiUrl: string;
5
+ apiKey: string;
6
+ timeout?: number;
7
+ }
8
+
9
+ export interface SessionSyncResponse {
10
+ session_id: string;
11
+ turns_synced: number;
12
+ tool_uses_synced: number;
13
+ task_tags_synced: number;
14
+ hook_events_synced: number;
15
+ }
16
+
17
+ export class ZozulApiClient {
18
+ private baseUrl: string;
19
+ private apiKey: string;
20
+ private timeout: number;
21
+
22
+ constructor(config: SyncClientConfig) {
23
+ this.baseUrl = config.apiUrl.replace(/\/+$/, "");
24
+ this.apiKey = config.apiKey;
25
+ this.timeout = config.timeout ?? 30_000;
26
+ }
27
+
28
+ async syncSession(sessionId: string, payload: SessionSyncPayload): Promise<SessionSyncResponse> {
29
+ return this.post(`/api/v1/sessions/${sessionId}/sync`, payload);
30
+ }
31
+
32
+ async postOtelMetricsBulk(metrics: ApiOtelMetric[]): Promise<void> {
33
+ await this.post("/api/v1/otel/metrics/bulk", metrics);
34
+ }
35
+
36
+ async postOtelEventsBulk(events: ApiOtelEvent[]): Promise<void> {
37
+ await this.post("/api/v1/otel/events/bulk", events);
38
+ }
39
+
40
+ private async post<T = void>(path: string, body: unknown): Promise<T> {
41
+ const url = `${this.baseUrl}${path}`;
42
+ const res = await fetch(url, {
43
+ method: "POST",
44
+ headers: {
45
+ "Content-Type": "application/json",
46
+ "X-API-Key": this.apiKey,
47
+ },
48
+ body: JSON.stringify(body),
49
+ signal: AbortSignal.timeout(this.timeout),
50
+ });
51
+
52
+ if (!res.ok) {
53
+ const text = await res.text().catch(() => "");
54
+ throw new Error(`POST ${path} failed: ${res.status} ${res.statusText} — ${text}`);
55
+ }
56
+
57
+ const contentType = res.headers.get("content-type") ?? "";
58
+ if (contentType.includes("application/json")) {
59
+ return (await res.json()) as T;
60
+ }
61
+ return undefined as T;
62
+ }
63
+ }