@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Tests for MetricsStore (SQLite-backed session metrics storage).
3
+ *
4
+ * Uses real bun:sqlite with temp files. No mocks.
5
+ * Philosophy: "never mock what you can use for real" (mx-252b16).
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtemp } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { cleanupTempDir } from "../test-helpers.ts";
13
+ import type { SessionMetrics } from "../types.ts";
14
+ import { createMetricsStore, type MetricsStore } from "./store.ts";
15
+
16
+ let tempDir: string;
17
+ let dbPath: string;
18
+ let store: MetricsStore;
19
+
20
+ beforeEach(async () => {
21
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-metrics-test-"));
22
+ dbPath = join(tempDir, "metrics.db");
23
+ store = createMetricsStore(dbPath);
24
+ });
25
+
26
+ afterEach(async () => {
27
+ store.close();
28
+ await cleanupTempDir(tempDir);
29
+ });
30
+
31
+ /** Helper to create a SessionMetrics object with optional overrides. */
32
+ function makeSession(overrides: Partial<SessionMetrics> = {}): SessionMetrics {
33
+ return {
34
+ agentName: "test-agent",
35
+ beadId: "test-task-123",
36
+ capability: "builder",
37
+ startedAt: new Date("2026-01-01T00:00:00Z").toISOString(),
38
+ completedAt: new Date("2026-01-01T00:05:00Z").toISOString(),
39
+ durationMs: 300_000,
40
+ exitCode: 0,
41
+ mergeResult: "auto-resolve",
42
+ parentAgent: "coordinator",
43
+ inputTokens: 0,
44
+ outputTokens: 0,
45
+ cacheReadTokens: 0,
46
+ cacheCreationTokens: 0,
47
+ estimatedCostUsd: null,
48
+ modelUsed: null,
49
+ runId: null,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // === recordSession ===
55
+
56
+ describe("recordSession", () => {
57
+ test("inserts a session and retrieves it via getRecentSessions", () => {
58
+ const session = makeSession();
59
+ store.recordSession(session);
60
+
61
+ const retrieved = store.getRecentSessions(10);
62
+ expect(retrieved).toHaveLength(1);
63
+ expect(retrieved[0]).toEqual(session);
64
+ });
65
+
66
+ test("INSERT OR REPLACE: same (agent_name, task_id) key overwrites previous row", () => {
67
+ const session1 = makeSession({ durationMs: 100_000 });
68
+ const session2 = makeSession({ durationMs: 200_000 });
69
+
70
+ store.recordSession(session1);
71
+ store.recordSession(session2);
72
+
73
+ const retrieved = store.getRecentSessions(10);
74
+ expect(retrieved).toHaveLength(1);
75
+ expect(retrieved[0]?.durationMs).toBe(200_000);
76
+ });
77
+
78
+ test("all fields roundtrip correctly (camelCase TS → snake_case SQLite → camelCase TS)", () => {
79
+ const session = makeSession({
80
+ agentName: "special-agent",
81
+ beadId: "task-xyz",
82
+ capability: "reviewer",
83
+ startedAt: "2026-02-01T12:00:00Z",
84
+ completedAt: "2026-02-01T12:30:00Z",
85
+ durationMs: 1_800_000,
86
+ exitCode: 42,
87
+ mergeResult: "ai-resolve",
88
+ parentAgent: "lead-agent",
89
+ });
90
+
91
+ store.recordSession(session);
92
+ const retrieved = store.getRecentSessions(10);
93
+
94
+ expect(retrieved).toHaveLength(1);
95
+ expect(retrieved[0]).toEqual(session);
96
+ });
97
+
98
+ test("null fields (completedAt, exitCode, mergeResult, parentAgent) stored and retrieved as null", () => {
99
+ const session = makeSession({
100
+ completedAt: null,
101
+ exitCode: null,
102
+ mergeResult: null,
103
+ parentAgent: null,
104
+ });
105
+
106
+ store.recordSession(session);
107
+ const retrieved = store.getRecentSessions(10);
108
+
109
+ expect(retrieved).toHaveLength(1);
110
+ expect(retrieved[0]?.completedAt).toBeNull();
111
+ expect(retrieved[0]?.exitCode).toBeNull();
112
+ expect(retrieved[0]?.mergeResult).toBeNull();
113
+ expect(retrieved[0]?.parentAgent).toBeNull();
114
+ });
115
+ });
116
+
117
+ // === getRecentSessions ===
118
+
119
+ describe("getRecentSessions", () => {
120
+ test("returns sessions ordered by started_at DESC (most recent first)", () => {
121
+ const session1 = makeSession({
122
+ beadId: "task-1",
123
+ startedAt: "2026-01-01T10:00:00Z",
124
+ });
125
+ const session2 = makeSession({
126
+ beadId: "task-2",
127
+ startedAt: "2026-01-01T12:00:00Z",
128
+ });
129
+ const session3 = makeSession({
130
+ beadId: "task-3",
131
+ startedAt: "2026-01-01T11:00:00Z",
132
+ });
133
+
134
+ store.recordSession(session1);
135
+ store.recordSession(session2);
136
+ store.recordSession(session3);
137
+
138
+ const retrieved = store.getRecentSessions(10);
139
+ expect(retrieved).toHaveLength(3);
140
+ expect(retrieved[0]?.beadId).toBe("task-2"); // most recent
141
+ expect(retrieved[1]?.beadId).toBe("task-3");
142
+ expect(retrieved[2]?.beadId).toBe("task-1"); // oldest
143
+ });
144
+
145
+ test("default limit is 20", () => {
146
+ // Insert 25 sessions
147
+ for (let i = 0; i < 25; i++) {
148
+ store.recordSession(
149
+ makeSession({
150
+ beadId: `task-${i}`,
151
+ startedAt: new Date(Date.now() + i * 1000).toISOString(),
152
+ }),
153
+ );
154
+ }
155
+
156
+ const retrieved = store.getRecentSessions();
157
+ expect(retrieved).toHaveLength(20);
158
+ });
159
+
160
+ test("custom limit works (e.g., limit=2 returns only 2)", () => {
161
+ store.recordSession(makeSession({ beadId: "task-1" }));
162
+ store.recordSession(makeSession({ beadId: "task-2" }));
163
+ store.recordSession(makeSession({ beadId: "task-3" }));
164
+
165
+ const retrieved = store.getRecentSessions(2);
166
+ expect(retrieved).toHaveLength(2);
167
+ });
168
+
169
+ test("empty DB returns empty array", () => {
170
+ const retrieved = store.getRecentSessions(10);
171
+ expect(retrieved).toEqual([]);
172
+ });
173
+ });
174
+
175
+ // === getSessionsByAgent ===
176
+
177
+ describe("getSessionsByAgent", () => {
178
+ test("filters by agent name correctly", () => {
179
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
180
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
181
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-3" }));
182
+
183
+ const retrieved = store.getSessionsByAgent("agent-a");
184
+ expect(retrieved).toHaveLength(2);
185
+ expect(retrieved[0]?.agentName).toBe("agent-a");
186
+ expect(retrieved[1]?.agentName).toBe("agent-a");
187
+ });
188
+
189
+ test("returns empty array for unknown agent", () => {
190
+ store.recordSession(makeSession({ agentName: "known-agent" }));
191
+
192
+ const retrieved = store.getSessionsByAgent("unknown-agent");
193
+ expect(retrieved).toEqual([]);
194
+ });
195
+
196
+ test("multiple sessions for same agent all returned, ordered by started_at DESC", () => {
197
+ store.recordSession(
198
+ makeSession({
199
+ agentName: "agent-x",
200
+ beadId: "task-1",
201
+ startedAt: "2026-01-01T10:00:00Z",
202
+ }),
203
+ );
204
+ store.recordSession(
205
+ makeSession({
206
+ agentName: "agent-x",
207
+ beadId: "task-2",
208
+ startedAt: "2026-01-01T12:00:00Z",
209
+ }),
210
+ );
211
+ store.recordSession(
212
+ makeSession({
213
+ agentName: "agent-x",
214
+ beadId: "task-3",
215
+ startedAt: "2026-01-01T11:00:00Z",
216
+ }),
217
+ );
218
+
219
+ const retrieved = store.getSessionsByAgent("agent-x");
220
+ expect(retrieved).toHaveLength(3);
221
+ expect(retrieved[0]?.beadId).toBe("task-2"); // most recent
222
+ expect(retrieved[1]?.beadId).toBe("task-3");
223
+ expect(retrieved[2]?.beadId).toBe("task-1"); // oldest
224
+ });
225
+ });
226
+
227
+ // === getAverageDuration ===
228
+
229
+ describe("getAverageDuration", () => {
230
+ test("average across all completed sessions (completedAt IS NOT NULL)", () => {
231
+ store.recordSession(makeSession({ beadId: "task-1", durationMs: 100_000 }));
232
+ store.recordSession(makeSession({ beadId: "task-2", durationMs: 200_000 }));
233
+ store.recordSession(makeSession({ beadId: "task-3", durationMs: 300_000 }));
234
+
235
+ const avg = store.getAverageDuration();
236
+ expect(avg).toBe(200_000);
237
+ });
238
+
239
+ test("average filtered by capability", () => {
240
+ store.recordSession(
241
+ makeSession({ beadId: "task-1", capability: "builder", durationMs: 100_000 }),
242
+ );
243
+ store.recordSession(makeSession({ beadId: "task-2", capability: "scout", durationMs: 50_000 }));
244
+ store.recordSession(
245
+ makeSession({ beadId: "task-3", capability: "builder", durationMs: 200_000 }),
246
+ );
247
+
248
+ const avgBuilder = store.getAverageDuration("builder");
249
+ const avgScout = store.getAverageDuration("scout");
250
+
251
+ expect(avgBuilder).toBe(150_000);
252
+ expect(avgScout).toBe(50_000);
253
+ });
254
+
255
+ test("returns 0 when no completed sessions exist", () => {
256
+ const avg = store.getAverageDuration();
257
+ expect(avg).toBe(0);
258
+ });
259
+
260
+ test("sessions with completedAt=null are excluded from average", () => {
261
+ store.recordSession(makeSession({ beadId: "task-1", durationMs: 100_000, completedAt: null }));
262
+ store.recordSession(makeSession({ beadId: "task-2", durationMs: 200_000 }));
263
+ store.recordSession(makeSession({ beadId: "task-3", durationMs: 300_000 }));
264
+
265
+ const avg = store.getAverageDuration();
266
+ expect(avg).toBe(250_000); // (200_000 + 300_000) / 2
267
+ });
268
+
269
+ test("single session returns that session's duration", () => {
270
+ store.recordSession(makeSession({ durationMs: 123_456 }));
271
+
272
+ const avg = store.getAverageDuration();
273
+ expect(avg).toBe(123_456);
274
+ });
275
+ });
276
+
277
+ // === token fields ===
278
+
279
+ describe("token fields", () => {
280
+ test("token data roundtrips correctly", () => {
281
+ const session = makeSession({
282
+ inputTokens: 15_000,
283
+ outputTokens: 3_000,
284
+ cacheReadTokens: 100_000,
285
+ cacheCreationTokens: 10_000,
286
+ estimatedCostUsd: 1.23,
287
+ modelUsed: "claude-opus-4-6",
288
+ });
289
+
290
+ store.recordSession(session);
291
+ const retrieved = store.getRecentSessions(10);
292
+
293
+ expect(retrieved).toHaveLength(1);
294
+ expect(retrieved[0]?.inputTokens).toBe(15_000);
295
+ expect(retrieved[0]?.outputTokens).toBe(3_000);
296
+ expect(retrieved[0]?.cacheReadTokens).toBe(100_000);
297
+ expect(retrieved[0]?.cacheCreationTokens).toBe(10_000);
298
+ expect(retrieved[0]?.estimatedCostUsd).toBeCloseTo(1.23, 2);
299
+ expect(retrieved[0]?.modelUsed).toBe("claude-opus-4-6");
300
+ });
301
+
302
+ test("token fields default to 0 and cost/model default to null", () => {
303
+ const session = makeSession();
304
+
305
+ store.recordSession(session);
306
+ const retrieved = store.getRecentSessions(10);
307
+
308
+ expect(retrieved).toHaveLength(1);
309
+ expect(retrieved[0]?.inputTokens).toBe(0);
310
+ expect(retrieved[0]?.outputTokens).toBe(0);
311
+ expect(retrieved[0]?.cacheReadTokens).toBe(0);
312
+ expect(retrieved[0]?.cacheCreationTokens).toBe(0);
313
+ expect(retrieved[0]?.estimatedCostUsd).toBeNull();
314
+ expect(retrieved[0]?.modelUsed).toBeNull();
315
+ });
316
+
317
+ test("migration adds token columns to existing table without them", () => {
318
+ // Close the current store which has the new schema
319
+ store.close();
320
+
321
+ // Create a DB with the old schema (no token columns)
322
+ const { Database } = require("bun:sqlite");
323
+ const oldDb = new Database(dbPath);
324
+ oldDb.exec("DROP TABLE IF EXISTS sessions");
325
+ oldDb.exec(`
326
+ CREATE TABLE sessions (
327
+ agent_name TEXT NOT NULL,
328
+ bead_id TEXT NOT NULL,
329
+ capability TEXT NOT NULL,
330
+ started_at TEXT NOT NULL,
331
+ completed_at TEXT,
332
+ duration_ms INTEGER NOT NULL DEFAULT 0,
333
+ exit_code INTEGER,
334
+ merge_result TEXT,
335
+ parent_agent TEXT,
336
+ PRIMARY KEY (agent_name, bead_id)
337
+ )
338
+ `);
339
+ // Insert a row with old schema
340
+ oldDb.exec(`
341
+ INSERT INTO sessions (agent_name, bead_id, capability, started_at, duration_ms)
342
+ VALUES ('old-agent', 'old-task', 'builder', '2026-01-01T00:00:00Z', 100000)
343
+ `);
344
+ oldDb.close();
345
+
346
+ // Re-open with createMetricsStore which should migrate
347
+ store = createMetricsStore(dbPath);
348
+
349
+ // The old row should still be readable with token defaults
350
+ const sessions = store.getRecentSessions(10);
351
+ expect(sessions).toHaveLength(1);
352
+ expect(sessions[0]?.agentName).toBe("old-agent");
353
+ expect(sessions[0]?.inputTokens).toBe(0);
354
+ expect(sessions[0]?.outputTokens).toBe(0);
355
+ expect(sessions[0]?.estimatedCostUsd).toBeNull();
356
+ expect(sessions[0]?.modelUsed).toBeNull();
357
+
358
+ // New rows with token data should work
359
+ store.recordSession(
360
+ makeSession({
361
+ agentName: "new-agent",
362
+ beadId: "new-task",
363
+ inputTokens: 5000,
364
+ outputTokens: 1000,
365
+ estimatedCostUsd: 0.42,
366
+ modelUsed: "claude-sonnet-4-20250514",
367
+ }),
368
+ );
369
+
370
+ const newSessions = store.getSessionsByAgent("new-agent");
371
+ expect(newSessions).toHaveLength(1);
372
+ expect(newSessions[0]?.inputTokens).toBe(5000);
373
+ expect(newSessions[0]?.estimatedCostUsd).toBeCloseTo(0.42, 2);
374
+ });
375
+ });
376
+
377
+ // === getSessionsByRun ===
378
+
379
+ describe("getSessionsByRun", () => {
380
+ test("returns sessions matching run_id", () => {
381
+ store.recordSession(makeSession({ agentName: "a1", beadId: "t1", runId: "run-001" }));
382
+ store.recordSession(makeSession({ agentName: "a2", beadId: "t2", runId: "run-001" }));
383
+ store.recordSession(makeSession({ agentName: "a3", beadId: "t3", runId: "run-002" }));
384
+
385
+ const sessions = store.getSessionsByRun("run-001");
386
+ expect(sessions).toHaveLength(2);
387
+ expect(sessions.every((s) => s.runId === "run-001")).toBe(true);
388
+ });
389
+
390
+ test("returns empty array for unknown run_id", () => {
391
+ store.recordSession(makeSession({ agentName: "a1", beadId: "t1", runId: "run-001" }));
392
+ expect(store.getSessionsByRun("run-nonexistent")).toEqual([]);
393
+ });
394
+
395
+ test("sessions with null run_id are not returned", () => {
396
+ store.recordSession(makeSession({ agentName: "a1", beadId: "t1", runId: null }));
397
+ store.recordSession(makeSession({ agentName: "a2", beadId: "t2", runId: "run-001" }));
398
+ expect(store.getSessionsByRun("run-001")).toHaveLength(1);
399
+ });
400
+ });
401
+
402
+ // === purge ===
403
+
404
+ describe("purge", () => {
405
+ test("purge all deletes everything and returns count", () => {
406
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
407
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
408
+ store.recordSession(makeSession({ agentName: "agent-c", beadId: "task-3" }));
409
+
410
+ const count = store.purge({ all: true });
411
+ expect(count).toBe(3);
412
+ expect(store.getRecentSessions(10)).toEqual([]);
413
+ });
414
+
415
+ test("purge by agent deletes only that agent's records", () => {
416
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-1" }));
417
+ store.recordSession(makeSession({ agentName: "agent-b", beadId: "task-2" }));
418
+ store.recordSession(makeSession({ agentName: "agent-a", beadId: "task-3" }));
419
+
420
+ const count = store.purge({ agent: "agent-a" });
421
+ expect(count).toBe(2);
422
+
423
+ const remaining = store.getRecentSessions(10);
424
+ expect(remaining).toHaveLength(1);
425
+ expect(remaining[0]?.agentName).toBe("agent-b");
426
+ });
427
+
428
+ test("purge on empty DB returns 0", () => {
429
+ const count = store.purge({ all: true });
430
+ expect(count).toBe(0);
431
+ });
432
+
433
+ test("purge with no options returns 0 without deleting", () => {
434
+ store.recordSession(makeSession({ beadId: "task-1" }));
435
+
436
+ const count = store.purge({});
437
+ expect(count).toBe(0);
438
+ expect(store.getRecentSessions(10)).toHaveLength(1);
439
+ });
440
+ });
441
+
442
+ // === token snapshots ===
443
+
444
+ describe("token snapshots", () => {
445
+ test("recordSnapshot inserts and can be retrieved via getLatestSnapshots", () => {
446
+ const snapshot = {
447
+ agentName: "test-agent",
448
+ inputTokens: 1000,
449
+ outputTokens: 500,
450
+ cacheReadTokens: 200,
451
+ cacheCreationTokens: 100,
452
+ estimatedCostUsd: 0.15,
453
+ modelUsed: "claude-sonnet-4-5",
454
+ createdAt: new Date().toISOString(),
455
+ };
456
+
457
+ store.recordSnapshot(snapshot);
458
+
459
+ const snapshots = store.getLatestSnapshots();
460
+ expect(snapshots).toHaveLength(1);
461
+ expect(snapshots[0]?.agentName).toBe("test-agent");
462
+ expect(snapshots[0]?.inputTokens).toBe(1000);
463
+ expect(snapshots[0]?.outputTokens).toBe(500);
464
+ expect(snapshots[0]?.estimatedCostUsd).toBeCloseTo(0.15, 2);
465
+ });
466
+
467
+ test("getLatestSnapshots returns one row per agent (the most recent)", () => {
468
+ const now = Date.now();
469
+ store.recordSnapshot({
470
+ agentName: "agent-a",
471
+ inputTokens: 100,
472
+ outputTokens: 50,
473
+ cacheReadTokens: 0,
474
+ cacheCreationTokens: 0,
475
+ estimatedCostUsd: 0.01,
476
+ modelUsed: "claude-sonnet-4-5",
477
+ createdAt: new Date(now - 60_000).toISOString(), // 1 min ago
478
+ });
479
+
480
+ store.recordSnapshot({
481
+ agentName: "agent-a",
482
+ inputTokens: 200,
483
+ outputTokens: 100,
484
+ cacheReadTokens: 0,
485
+ cacheCreationTokens: 0,
486
+ estimatedCostUsd: 0.02,
487
+ modelUsed: "claude-sonnet-4-5",
488
+ createdAt: new Date(now).toISOString(), // now (most recent)
489
+ });
490
+
491
+ store.recordSnapshot({
492
+ agentName: "agent-b",
493
+ inputTokens: 300,
494
+ outputTokens: 150,
495
+ cacheReadTokens: 0,
496
+ cacheCreationTokens: 0,
497
+ estimatedCostUsd: 0.03,
498
+ modelUsed: "claude-sonnet-4-5",
499
+ createdAt: new Date(now - 30_000).toISOString(), // 30s ago
500
+ });
501
+
502
+ const snapshots = store.getLatestSnapshots();
503
+ expect(snapshots).toHaveLength(2); // one per agent
504
+
505
+ const agentASnapshot = snapshots.find((s) => s.agentName === "agent-a");
506
+ const agentBSnapshot = snapshots.find((s) => s.agentName === "agent-b");
507
+
508
+ expect(agentASnapshot?.inputTokens).toBe(200); // most recent for agent-a
509
+ expect(agentBSnapshot?.inputTokens).toBe(300);
510
+ });
511
+
512
+ test("getLatestSnapshotTime returns the most recent timestamp for an agent", () => {
513
+ const now = Date.now();
514
+ const time1 = new Date(now - 60_000).toISOString();
515
+ const time2 = new Date(now).toISOString();
516
+
517
+ store.recordSnapshot({
518
+ agentName: "test-agent",
519
+ inputTokens: 100,
520
+ outputTokens: 50,
521
+ cacheReadTokens: 0,
522
+ cacheCreationTokens: 0,
523
+ estimatedCostUsd: null,
524
+ modelUsed: null,
525
+ createdAt: time1,
526
+ });
527
+
528
+ store.recordSnapshot({
529
+ agentName: "test-agent",
530
+ inputTokens: 200,
531
+ outputTokens: 100,
532
+ cacheReadTokens: 0,
533
+ cacheCreationTokens: 0,
534
+ estimatedCostUsd: null,
535
+ modelUsed: null,
536
+ createdAt: time2,
537
+ });
538
+
539
+ const latestTime = store.getLatestSnapshotTime("test-agent");
540
+ expect(latestTime).toBe(time2);
541
+ });
542
+
543
+ test("getLatestSnapshotTime returns null for unknown agent", () => {
544
+ const latestTime = store.getLatestSnapshotTime("unknown-agent");
545
+ expect(latestTime).toBeNull();
546
+ });
547
+
548
+ test("purgeSnapshots with all=true deletes everything", () => {
549
+ store.recordSnapshot({
550
+ agentName: "agent-a",
551
+ inputTokens: 100,
552
+ outputTokens: 50,
553
+ cacheReadTokens: 0,
554
+ cacheCreationTokens: 0,
555
+ estimatedCostUsd: null,
556
+ modelUsed: null,
557
+ createdAt: new Date().toISOString(),
558
+ });
559
+
560
+ store.recordSnapshot({
561
+ agentName: "agent-b",
562
+ inputTokens: 200,
563
+ outputTokens: 100,
564
+ cacheReadTokens: 0,
565
+ cacheCreationTokens: 0,
566
+ estimatedCostUsd: null,
567
+ modelUsed: null,
568
+ createdAt: new Date().toISOString(),
569
+ });
570
+
571
+ const count = store.purgeSnapshots({ all: true });
572
+ expect(count).toBe(2);
573
+ expect(store.getLatestSnapshots()).toEqual([]);
574
+ });
575
+
576
+ test("purgeSnapshots with agent filter deletes only that agent", () => {
577
+ store.recordSnapshot({
578
+ agentName: "agent-a",
579
+ inputTokens: 100,
580
+ outputTokens: 50,
581
+ cacheReadTokens: 0,
582
+ cacheCreationTokens: 0,
583
+ estimatedCostUsd: null,
584
+ modelUsed: null,
585
+ createdAt: new Date().toISOString(),
586
+ });
587
+
588
+ store.recordSnapshot({
589
+ agentName: "agent-b",
590
+ inputTokens: 200,
591
+ outputTokens: 100,
592
+ cacheReadTokens: 0,
593
+ cacheCreationTokens: 0,
594
+ estimatedCostUsd: null,
595
+ modelUsed: null,
596
+ createdAt: new Date().toISOString(),
597
+ });
598
+
599
+ const count = store.purgeSnapshots({ agent: "agent-a" });
600
+ expect(count).toBe(1);
601
+
602
+ const remaining = store.getLatestSnapshots();
603
+ expect(remaining).toHaveLength(1);
604
+ expect(remaining[0]?.agentName).toBe("agent-b");
605
+ });
606
+
607
+ test("purgeSnapshots with olderThanMs deletes old snapshots", () => {
608
+ const now = Date.now();
609
+ store.recordSnapshot({
610
+ agentName: "agent-a",
611
+ inputTokens: 100,
612
+ outputTokens: 50,
613
+ cacheReadTokens: 0,
614
+ cacheCreationTokens: 0,
615
+ estimatedCostUsd: null,
616
+ modelUsed: null,
617
+ createdAt: new Date(now - 120_000).toISOString(), // 2 min ago
618
+ });
619
+
620
+ store.recordSnapshot({
621
+ agentName: "agent-b",
622
+ inputTokens: 200,
623
+ outputTokens: 100,
624
+ cacheReadTokens: 0,
625
+ cacheCreationTokens: 0,
626
+ estimatedCostUsd: null,
627
+ modelUsed: null,
628
+ createdAt: new Date(now - 10_000).toISOString(), // 10s ago (recent)
629
+ });
630
+
631
+ const count = store.purgeSnapshots({ olderThanMs: 60_000 }); // delete older than 1 min
632
+ expect(count).toBe(1); // only the 2-min-old one
633
+
634
+ const remaining = store.getLatestSnapshots();
635
+ expect(remaining).toHaveLength(1);
636
+ expect(remaining[0]?.agentName).toBe("agent-b");
637
+ });
638
+
639
+ test("table creation is idempotent (re-opening store does not fail)", () => {
640
+ store.recordSnapshot({
641
+ agentName: "test-agent",
642
+ inputTokens: 100,
643
+ outputTokens: 50,
644
+ cacheReadTokens: 0,
645
+ cacheCreationTokens: 0,
646
+ estimatedCostUsd: null,
647
+ modelUsed: null,
648
+ createdAt: new Date().toISOString(),
649
+ });
650
+
651
+ store.close();
652
+
653
+ // Re-open and verify data persists
654
+ store = createMetricsStore(dbPath);
655
+ const snapshots = store.getLatestSnapshots();
656
+ expect(snapshots).toHaveLength(1);
657
+ expect(snapshots[0]?.agentName).toBe("test-agent");
658
+ });
659
+ });
660
+
661
+ // === close ===
662
+
663
+ describe("close", () => {
664
+ test("calling close does not throw", () => {
665
+ expect(() => store.close()).not.toThrow();
666
+ });
667
+ });