fapony 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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +473 -0
  3. package/fapony.ts +78 -0
  4. package/package.json +42 -0
  5. package/skill/git-commit-conventional/SKILL.md +68 -0
  6. package/skill/git-ship/SKILL.md +144 -0
  7. package/skill/move-to-done/SKILL.md +126 -0
  8. package/skill/plan-with-pony/SKILL.md +263 -0
  9. package/skill/review-pony/SKILL.md +254 -0
  10. package/src/analyze.ts +517 -0
  11. package/src/context/index.ts +11 -0
  12. package/src/context/projectHealth.ts +359 -0
  13. package/src/conventions-seed.ts +420 -0
  14. package/src/db/defaults.ts +26 -0
  15. package/src/db/getters.ts +33 -0
  16. package/src/db/index.ts +7 -0
  17. package/src/db/load.ts +57 -0
  18. package/src/db/store.ts +286 -0
  19. package/src/db/types.ts +79 -0
  20. package/src/debt.ts +667 -0
  21. package/src/digest/cli.ts +75 -0
  22. package/src/digest/collect.ts +625 -0
  23. package/src/digest/html.ts +208 -0
  24. package/src/digest/text.ts +191 -0
  25. package/src/gate.ts +153 -0
  26. package/src/gates.ts +194 -0
  27. package/src/hook.ts +436 -0
  28. package/src/init-mem.ts +71 -0
  29. package/src/init.ts +237 -0
  30. package/src/install/claude.ts +361 -0
  31. package/src/install/codex.ts +61 -0
  32. package/src/install/cursor.ts +167 -0
  33. package/src/install/detect.ts +78 -0
  34. package/src/install/opencode.ts +234 -0
  35. package/src/install/skills.ts +106 -0
  36. package/src/install/types.ts +69 -0
  37. package/src/install/utils.ts +29 -0
  38. package/src/install/zcode.ts +120 -0
  39. package/src/install.ts +176 -0
  40. package/src/lint-baseline.ts +260 -0
  41. package/src/map.ts +320 -0
  42. package/src/math.ts +13 -0
  43. package/src/mcp/evidence.ts +332 -0
  44. package/src/mcp/primitives.ts +316 -0
  45. package/src/mcp/tools/check.ts +243 -0
  46. package/src/mcp/tools/collect.ts +157 -0
  47. package/src/mcp/tools/context.ts +66 -0
  48. package/src/mcp/tools/index.ts +309 -0
  49. package/src/mcp/tools/mem.ts +95 -0
  50. package/src/mcp/tools/plans.ts +255 -0
  51. package/src/mcp/tools/report.ts +285 -0
  52. package/src/mcp/tools/stats.ts +96 -0
  53. package/src/mcp/tools/usage.ts +211 -0
  54. package/src/mcp/tools/verdict.ts +148 -0
  55. package/src/mcp/transport.ts +241 -0
  56. package/src/mcp/types.ts +54 -0
  57. package/src/mcp/worktree.ts +27 -0
  58. package/src/memory.ts +264 -0
  59. package/src/parse.ts +71 -0
  60. package/src/plan-seed.ts +599 -0
  61. package/src/price/fetch.ts +146 -0
  62. package/src/price/index.ts +8 -0
  63. package/src/price/resolve.ts +213 -0
  64. package/src/report/cli.ts +92 -0
  65. package/src/report/format.ts +37 -0
  66. package/src/report/index.ts +4 -0
  67. package/src/report/render.ts +206 -0
  68. package/src/review-seed.ts +932 -0
  69. package/src/safety.ts +18 -0
  70. package/src/session/activeSession.ts +153 -0
  71. package/src/session/claude-code.ts +412 -0
  72. package/src/session/codex.ts +347 -0
  73. package/src/session/findModel.ts +376 -0
  74. package/src/session/helpers.ts +640 -0
  75. package/src/session/index.ts +31 -0
  76. package/src/session/opencode.ts +167 -0
  77. package/src/session/registry.ts +45 -0
  78. package/src/session/types.ts +128 -0
  79. package/src/session/zcode.ts +151 -0
  80. package/src/setup.ts +242 -0
  81. package/src/stats/cli.ts +44 -0
  82. package/src/stats/data.ts +1019 -0
  83. package/src/stats/format.ts +584 -0
  84. package/src/stats/index.ts +19 -0
  85. package/src/telemetry.ts +364 -0
  86. package/src/test.ts +2 -0
  87. package/src/update.ts +212 -0
  88. package/src/usage/cache.ts +125 -0
  89. package/src/usage/cli.ts +120 -0
  90. package/src/usage/format.ts +29 -0
  91. package/src/usage/index.ts +4 -0
  92. package/src/usage/render.ts +523 -0
  93. package/src/usage/scan.ts +161 -0
  94. package/src/util.ts +32 -0
  95. package/src/web/html.ts +33 -0
  96. package/templates/PLAN.md +90 -0
  97. package/templates/SPEC.md +30 -0
  98. package/templates/mem/commands/plan.ts +360 -0
  99. package/templates/mem/commands/read.ts +194 -0
  100. package/templates/mem/commands/rotate.ts +59 -0
  101. package/templates/mem/commands/selftest.ts +450 -0
  102. package/templates/mem/commands/write.ts +214 -0
  103. package/templates/mem/mem.ts +68 -0
  104. package/templates/mem/render.ts +63 -0
  105. package/templates/mem/selectors.ts +144 -0
  106. package/templates/mem/store.ts +285 -0
@@ -0,0 +1,640 @@
1
+ // src/session/helpers.ts — shared patterns for session usage providers
2
+ //
3
+ // Both OpenCode and ZCode (and future SQLite-backed providers) share:
4
+ // 1. WHERE-clause building with worktree/since/until filters
5
+ // 2. Detail aggregation: tool + step rows → UsageDetail shape
6
+ //
7
+ // The join column for worktree differs per provider (OpenCode: pr.worktree,
8
+ // ZCode: s.directory) — callers supply the column name.
9
+
10
+ import type { Database } from "bun:sqlite";
11
+ import type { SessionDetail, StepTimingSummary, UsageDetail } from "./types.js";
12
+ import { STEP_TOKENS_NOTE, TIMING_NOTE } from "./types.js";
13
+
14
+ // ─── WHERE clause builder ──────────────────────────────────────────────
15
+
16
+ export interface WhereClause {
17
+ /** SQL fragment: either `AND ...` or empty string. */
18
+ clause: string;
19
+ /** Bound parameters in order. */
20
+ params: (string | number)[];
21
+ }
22
+
23
+ /**
24
+ * Build a WHERE / AND filter clause for (worktree, since, until).
25
+ *
26
+ * @param worktreeCol - Column to match the worktree path (e.g. `pr.worktree` or `s.directory`).
27
+ * @param worktree - Optional worktree path filter.
28
+ * @param since - Optional lower bound for `s.time_created`.
29
+ * @param until - Optional upper bound for `s.time_created`.
30
+ * @param prefix - `"WHERE"` when this is the only filter block, `"AND"` when appended to existing WHERE.
31
+ */
32
+ export function buildWhereClause(
33
+ worktreeCol: string,
34
+ worktree?: string,
35
+ since?: number,
36
+ until?: number,
37
+ prefix: "WHERE" | "AND" = "AND",
38
+ ): WhereClause {
39
+ const conds: string[] = [];
40
+ const params: (string | number)[] = [];
41
+ if (worktree) {
42
+ conds.push(`${worktreeCol} = ?`);
43
+ params.push(worktree);
44
+ }
45
+ if (since !== undefined) {
46
+ conds.push("s.time_created >= ?");
47
+ params.push(since);
48
+ }
49
+ if (until !== undefined) {
50
+ conds.push("s.time_created <= ?");
51
+ params.push(until);
52
+ }
53
+ return {
54
+ clause: conds.length > 0 ? `${prefix} ${conds.join(" AND ")}` : "",
55
+ params,
56
+ };
57
+ }
58
+
59
+ // ─── Detail aggregation ────────────────────────────────────────────────
60
+
61
+ export interface DetailToolRow {
62
+ tool: string | null;
63
+ c: number;
64
+ }
65
+
66
+ export interface DetailStepRow {
67
+ sid: string;
68
+ model: string | null;
69
+ steps: number;
70
+ }
71
+
72
+ export interface DetailPerSessionToolRow {
73
+ sid: string;
74
+ tool: string | null;
75
+ c: number;
76
+ }
77
+
78
+ export interface DetailBytesRow {
79
+ tool: string | null;
80
+ /** Summed `state.output` length (context bytes consumed by tool results). */
81
+ b: number;
82
+ }
83
+
84
+ /**
85
+ * Aggregate tool + step query results into a UsageDetail.
86
+ *
87
+ * This is the shared logic between OpenCode's `readUsageDetail()` and
88
+ * ZCode's `readZcodeUsageDetail()`. Both query:
89
+ * - tool rows (tool name + count)
90
+ * - step rows (session id + model + step count)
91
+ * - per-session tool rows (session id + tool + count)
92
+ * then merge into the same UsageDetail shape via a Map.
93
+ */
94
+ export function aggregateDetail(
95
+ toolRows: DetailToolRow[],
96
+ stepsRow: { steps: number },
97
+ perSessionSteps: DetailStepRow[],
98
+ perSessionTools: DetailPerSessionToolRow[],
99
+ fallbackModelLookup?: Array<{ sid: string; model: string | null }>,
100
+ bytesRows?: DetailBytesRow[],
101
+ ): UsageDetail {
102
+ const tool_breakdown: Record<string, number> = {};
103
+ for (const r of toolRows) {
104
+ const name = typeof r.tool === "string" && r.tool ? r.tool : "(unknown)";
105
+ tool_breakdown[name] = r.c;
106
+ }
107
+
108
+ const bySessionMap = new Map<string, SessionDetail>();
109
+ for (const r of perSessionSteps) {
110
+ bySessionMap.set(r.sid, {
111
+ session_id: r.sid,
112
+ model: typeof r.model === "string" ? r.model : "(unknown)",
113
+ steps: r.steps,
114
+ tools: {},
115
+ });
116
+ }
117
+ for (const r of perSessionTools) {
118
+ let entry = bySessionMap.get(r.sid);
119
+ if (!entry) {
120
+ entry = { session_id: r.sid, model: "(unknown)", steps: 0, tools: {} };
121
+ bySessionMap.set(r.sid, entry);
122
+ }
123
+ const name = typeof r.tool === "string" && r.tool ? r.tool : "(unknown)";
124
+ entry.tools[name] = r.c;
125
+ }
126
+
127
+ // Backfill model for sessions that have tools but no step-finish rows.
128
+ if (fallbackModelLookup) {
129
+ for (const r of fallbackModelLookup) {
130
+ const entry = bySessionMap.get(r.sid);
131
+ if (entry && typeof r.model === "string" && r.model)
132
+ entry.model = r.model;
133
+ }
134
+ }
135
+
136
+ const bytesByTool: Record<string, number> = {};
137
+ for (const r of bytesRows ?? []) {
138
+ if (typeof r.b !== "number" || r.b <= 0) continue;
139
+ const name = typeof r.tool === "string" && r.tool ? r.tool : "(unknown)";
140
+ bytesByTool[name] = (bytesByTool[name] ?? 0) + r.b;
141
+ }
142
+
143
+ const detail: UsageDetail = {
144
+ tool_breakdown,
145
+ steps: stepsRow?.steps ?? 0,
146
+ by_session: [...bySessionMap.values()]
147
+ .filter((s) => s.steps > 0)
148
+ .sort((a, b) => b.steps - a.steps),
149
+ note: STEP_TOKENS_NOTE,
150
+ };
151
+ if (Object.keys(bytesByTool).length > 0) detail.bytes_by_tool = bytesByTool;
152
+ return detail;
153
+ }
154
+
155
+ /**
156
+ * Sum `bytes_by_tool` across any number of UsageDetail objects (or nulls).
157
+ * Claude Code, Codex and the SQLite providers (OpenCode/ZCode) all populate
158
+ * the field now, so callers still pass every client's detail (opencode +
159
+ * zcode + claude_code + codex) and never just the top-level one.
160
+ * Returns {} when no client reported bytes.
161
+ */
162
+ export function mergeBytesByTool(
163
+ ...details: Array<UsageDetail | null | undefined>
164
+ ): Record<string, number> {
165
+ const out: Record<string, number> = {};
166
+ for (const d of details) {
167
+ const bbt = d?.bytes_by_tool;
168
+ if (!bbt || typeof bbt !== "object") continue;
169
+ for (const [tool, bytes] of Object.entries(bbt)) {
170
+ if (typeof bytes !== "number" || bytes <= 0) continue;
171
+ out[tool] = (out[tool] ?? 0) + bytes;
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ─── SQLite detail reader ──────────────────────────────────────────────
178
+
179
+ /**
180
+ * SQL join fragment for the session→project link.
181
+ * OpenCode needs `JOIN project pr ON pr.id = s.project_id`.
182
+ * ZCode doesn't (worktree is on the session table directly).
183
+ */
184
+ function joinClause(needsProject: boolean): string {
185
+ return needsProject ? "JOIN project pr ON pr.id = s.project_id" : "";
186
+ }
187
+
188
+ /**
189
+ * Run detail queries against a SQLite DB and return UsageDetail.
190
+ *
191
+ * Providers supply the worktree column name. If it starts with `pr.`,
192
+ * the project join is included automatically. The model column for
193
+ * per-session queries is configurable (OpenCode: `s.model`, ZCode: `s.directory`).
194
+ *
195
+ * @param db - Open SQLite connection (readonly).
196
+ * @param worktreeCol - e.g. `pr.worktree` (OpenCode) or `s.directory` (ZCode).
197
+ * @param modelCol - Column for the model in per-session queries (e.g. `s.model` or `s.directory`).
198
+ * @param worktree - Optional worktree path filter.
199
+ * @param since - Optional lower bound.
200
+ * @param until - Optional upper bound.
201
+ */
202
+ export function readDetailFromDb(
203
+ db: Database,
204
+ worktreeCol: string,
205
+ modelCol: string,
206
+ worktree?: string,
207
+ since?: number,
208
+ until?: number,
209
+ ): UsageDetail {
210
+ const join = joinClause(worktreeCol.startsWith("pr."));
211
+ const filter = buildWhereClause(worktreeCol, worktree, since, until);
212
+ // Only OpenCode keeps a model on the session row to backfill from; it used to
213
+ // be inferred from the `pr.` worktree column, which no longer identifies it.
214
+ const canBackfillModel = modelCol === "s.model";
215
+
216
+ const toolRows = db
217
+ .prepare(
218
+ `
219
+ SELECT json_extract(p.data, '$.tool') AS tool, COUNT(*) AS c
220
+ FROM part p
221
+ JOIN session s ON s.id = p.session_id
222
+ ${join}
223
+ WHERE json_extract(p.data, '$.type') = 'tool'
224
+ ${filter.clause}
225
+ GROUP BY tool
226
+ ORDER BY c DESC
227
+ `,
228
+ )
229
+ .all(...filter.params) as DetailToolRow[];
230
+
231
+ const stepsRow = db
232
+ .prepare(
233
+ `
234
+ SELECT COUNT(*) AS steps
235
+ FROM part p
236
+ JOIN session s ON s.id = p.session_id
237
+ ${join}
238
+ WHERE json_extract(p.data, '$.type') = 'step-finish'
239
+ ${filter.clause}
240
+ `,
241
+ )
242
+ .get(...filter.params) as { steps: number };
243
+
244
+ const perSessionSteps = db
245
+ .prepare(
246
+ `
247
+ SELECT p.session_id AS sid, ${modelCol} AS model, COUNT(*) AS steps
248
+ FROM part p
249
+ JOIN session s ON s.id = p.session_id
250
+ ${join}
251
+ WHERE json_extract(p.data, '$.type') = 'step-finish'
252
+ ${filter.clause}
253
+ GROUP BY sid
254
+ `,
255
+ )
256
+ .all(...filter.params) as DetailStepRow[];
257
+
258
+ const perSessionTools = db
259
+ .prepare(
260
+ `
261
+ SELECT p.session_id AS sid, json_extract(p.data, '$.tool') AS tool, COUNT(*) AS c
262
+ FROM part p
263
+ JOIN session s ON s.id = p.session_id
264
+ ${join}
265
+ WHERE json_extract(p.data, '$.type') = 'tool'
266
+ ${filter.clause}
267
+ GROUP BY sid, tool
268
+ `,
269
+ )
270
+ .all(...filter.params) as DetailPerSessionToolRow[];
271
+
272
+ // Context bytes per tool — summed `state.output` length. Same signal the
273
+ // Claude Code/Codex readers derive from tool_result blocks, so `fapony_usage`
274
+ // stops reporting zero bytes for the SQLite clients.
275
+ const bytesRows = db
276
+ .prepare(
277
+ `
278
+ SELECT json_extract(p.data, '$.tool') AS tool,
279
+ SUM(length(COALESCE(json_extract(p.data, '$.state.output'), ''))) AS b
280
+ FROM part p
281
+ JOIN session s ON s.id = p.session_id
282
+ ${join}
283
+ WHERE json_extract(p.data, '$.type') = 'tool'
284
+ ${filter.clause}
285
+ GROUP BY tool
286
+ `,
287
+ )
288
+ .all(...filter.params) as DetailBytesRow[];
289
+
290
+ // Backfill model for sessions with tools but no step-finish rows.
291
+ let fallbackModelLookup:
292
+ | Array<{ sid: string; model: string | null }>
293
+ | undefined;
294
+ if (canBackfillModel) {
295
+ const toolSids = [...new Set(perSessionTools.map((r) => r.sid))];
296
+ const stepSids = new Set(perSessionSteps.map((r) => r.sid));
297
+ const missingModel = toolSids.filter((sid) => !stepSids.has(sid));
298
+ if (missingModel.length > 0) {
299
+ const placeholders = missingModel.map(() => "?").join(",");
300
+ fallbackModelLookup = db
301
+ .prepare(
302
+ `SELECT id AS sid, model AS model FROM session WHERE id IN (${placeholders})`,
303
+ )
304
+ .all(...missingModel) as Array<{ sid: string; model: string | null }>;
305
+ }
306
+ }
307
+
308
+ return aggregateDetail(
309
+ toolRows,
310
+ stepsRow,
311
+ perSessionSteps,
312
+ perSessionTools,
313
+ fallbackModelLookup,
314
+ bytesRows,
315
+ );
316
+ }
317
+
318
+ // ─── Timing extraction (PLAN-project-health-context step 3) ────────────
319
+ //
320
+ // Parses fields the clients already write — no client-side change:
321
+ // - OpenCode part.data.time.start/end (tool, reasoning parts) → duration
322
+ // - OpenCode part.data.state.time.start/end (tool parts) → tool latency
323
+ // - OpenCode part.data.tokens.{input,output} + data.cost (step-finish) → per-step tokens
324
+ // - Claude Code JSONL timestamp diffs + message.usage (via summarizeTiming)
325
+ // Row time_created/time_updated is the fallback when embedded time is absent
326
+ // (spec §1 risk row). Only aggregates leave this module — never raw I/O.
327
+
328
+ function numOrNull(v: unknown): number | null {
329
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
330
+ }
331
+
332
+ /** ISO string, epoch-ms, or epoch-s → ms epoch. Null when unparseable. */
333
+ export function parseTimeMs(v: unknown): number | null {
334
+ if (typeof v === "number" && Number.isFinite(v)) {
335
+ if (v > 1e13 || v < 0) return null;
336
+ return v >= 1e12 ? v : v * 1000;
337
+ }
338
+ if (typeof v === "string" && v) {
339
+ const t = Date.parse(v);
340
+ return Number.isNaN(t) ? null : t;
341
+ }
342
+ return null;
343
+ }
344
+
345
+ export interface ExtractedPartTiming {
346
+ partType: string | null;
347
+ durationMs: number | null;
348
+ toolName: string | null;
349
+ toolLatencyMs: number | null;
350
+ stepInput: number | null;
351
+ stepOutput: number | null;
352
+ stepCost: number | null;
353
+ }
354
+
355
+ /** Pull timing/token signals out of one part.data JSON blob. */
356
+ export function extractPartTiming(dataJson: string): ExtractedPartTiming {
357
+ const out: ExtractedPartTiming = {
358
+ partType: null,
359
+ durationMs: null,
360
+ toolName: null,
361
+ toolLatencyMs: null,
362
+ stepInput: null,
363
+ stepOutput: null,
364
+ stepCost: null,
365
+ };
366
+ let d: Record<string, unknown>;
367
+ try {
368
+ d = JSON.parse(dataJson) as Record<string, unknown>;
369
+ } catch {
370
+ return out;
371
+ }
372
+ if (!d || typeof d !== "object") return out;
373
+ if (typeof d.type === "string") out.partType = d.type;
374
+
375
+ // Duration: data.time.start/end (tool, reasoning parts).
376
+ const t = d.time as Record<string, unknown> | undefined;
377
+ if (t && typeof t === "object") {
378
+ const start = parseTimeMs(t.start);
379
+ const end = parseTimeMs(t.end);
380
+ if (start !== null && end !== null && end >= start)
381
+ out.durationMs = end - start;
382
+ }
383
+
384
+ // Tool latency: data.state.time.start/end, grouped by data.tool.
385
+ if (typeof d.tool === "string" && d.tool) out.toolName = d.tool;
386
+ const st = d.state as Record<string, unknown> | undefined;
387
+ const stt =
388
+ st && typeof st === "object"
389
+ ? (st.time as Record<string, unknown> | undefined)
390
+ : undefined;
391
+ if (stt && typeof stt === "object") {
392
+ const start = parseTimeMs(stt.start);
393
+ const end = parseTimeMs(stt.end);
394
+ if (start !== null && end !== null && end >= start)
395
+ out.toolLatencyMs = end - start;
396
+ }
397
+
398
+ // Per-step tokens: data.tokens + data.cost on step-finish rows.
399
+ const tok = d.tokens as Record<string, unknown> | undefined;
400
+ if (tok && typeof tok === "object") {
401
+ out.stepInput = numOrNull(tok.input ?? tok.input_tokens);
402
+ out.stepOutput = numOrNull(tok.output ?? tok.output_tokens);
403
+ }
404
+ out.stepCost = numOrNull(d.cost);
405
+ return out;
406
+ }
407
+
408
+ export interface TimingInput {
409
+ /** Per-part durations in ms (null = no time signal on that part). */
410
+ durationsMs: Array<number | null>;
411
+ /** One entry per step-finish row. */
412
+ stepTokens: Array<{
413
+ input: number | null;
414
+ output: number | null;
415
+ cost: number | null;
416
+ }>;
417
+ /** One entry per tool call with a measured latency. */
418
+ toolLatencies: Array<{ tool: string; ms: number }>;
419
+ /** step-finish row count (mirrors UsageDetail.steps). */
420
+ steps: number;
421
+ }
422
+
423
+ function avgOrNull(xs: number[]): number | null {
424
+ if (xs.length === 0) return null;
425
+ return xs.reduce((a, b) => a + b, 0) / xs.length;
426
+ }
427
+
428
+ /**
429
+ * Summarize provider-extracted timing arrays. Shared by the SQLite readers
430
+ * (OpenCode/ZCode) and the JSONL readers (Claude Code) so the shape never
431
+ * drifts per provider. Averages only — per-step tokens are never summed.
432
+ */
433
+ export function summarizeTiming(input: TimingInput): StepTimingSummary {
434
+ const measured = input.durationsMs.filter((x): x is number => x !== null);
435
+ const latByTool = new Map<string, number[]>();
436
+ for (const l of input.toolLatencies) {
437
+ let arr = latByTool.get(l.tool);
438
+ if (!arr) {
439
+ arr = [];
440
+ latByTool.set(l.tool, arr);
441
+ }
442
+ arr.push(l.ms);
443
+ }
444
+ const toolLatencyMsByType: StepTimingSummary["toolLatencyMsByType"] = {};
445
+ for (const [tool, arr] of latByTool) {
446
+ const avg = avgOrNull(arr);
447
+ if (avg !== null)
448
+ toolLatencyMsByType[tool] = { count: arr.length, avgMs: avg };
449
+ }
450
+ return {
451
+ steps: input.steps,
452
+ avgStepMs: avgOrNull(measured),
453
+ stepSamples: measured.length,
454
+ avgStepInput: avgOrNull(
455
+ input.stepTokens
456
+ .map((s) => s.input)
457
+ .filter((x): x is number => x !== null),
458
+ ),
459
+ avgStepOutput: avgOrNull(
460
+ input.stepTokens
461
+ .map((s) => s.output)
462
+ .filter((x): x is number => x !== null),
463
+ ),
464
+ avgStepCost: avgOrNull(
465
+ input.stepTokens
466
+ .map((s) => s.cost)
467
+ .filter((x): x is number => x !== null),
468
+ ),
469
+ toolLatencyMsByType,
470
+ note: TIMING_NOTE,
471
+ };
472
+ }
473
+
474
+ export interface TimingRow {
475
+ data: string;
476
+ time_created: number | null;
477
+ /** Null when the provider's part table has no time_updated column (ZCode). */
478
+ time_updated: number | null;
479
+ }
480
+
481
+ /**
482
+ * Row-timestamp fallback for one part. Unit comes from the magnitude of the
483
+ * stamps themselves (epoch-s vs epoch-ms), not the diff — a 500 diff is 500s
484
+ * in seconds-stamps but 500ms in ms-stamps. Null when the pair is missing
485
+ * or inverted — never throws, never negative.
486
+ */
487
+ export function rowFallbackMs(
488
+ created: number | null,
489
+ updated: number | null,
490
+ ): number | null {
491
+ if (created === null || updated === null) return null;
492
+ if (
493
+ !Number.isFinite(created) ||
494
+ !Number.isFinite(updated) ||
495
+ updated < created
496
+ )
497
+ return null;
498
+ const diff = updated - created;
499
+ return created >= 1e12 ? diff : diff * 1000;
500
+ }
501
+
502
+ /** Fold extracted part rows into a TimingInput (embedded + row fallback). */
503
+ export function collectTiming(rows: TimingRow[]): TimingInput {
504
+ const durationsMs: Array<number | null> = [];
505
+ const stepTokens: TimingInput["stepTokens"] = [];
506
+ const toolLatencies: TimingInput["toolLatencies"] = [];
507
+ let steps = 0;
508
+ for (const r of rows) {
509
+ const t = extractPartTiming(r.data);
510
+ // Step count mirrors UsageDetail.steps: every step-finish row counts,
511
+ // even when it carries no tokens (tokens stay null, never summed).
512
+ if (t.partType === "step-finish") {
513
+ steps++;
514
+ stepTokens.push({
515
+ input: t.stepInput,
516
+ output: t.stepOutput,
517
+ cost: t.stepCost,
518
+ });
519
+ }
520
+ if (t.toolName !== null && t.toolLatencyMs !== null)
521
+ toolLatencies.push({ tool: t.toolName, ms: t.toolLatencyMs });
522
+ durationsMs.push(
523
+ t.durationMs ?? rowFallbackMs(r.time_created, r.time_updated),
524
+ );
525
+ }
526
+ return { durationsMs, stepTokens, toolLatencies, steps };
527
+ }
528
+
529
+ /**
530
+ * Run the timing query against a SQLite DB and return a StepTimingSummary.
531
+ *
532
+ * Same join/filter as readDetailFromDb. `hasTimeUpdated` is false for
533
+ * providers whose part table has no time_updated column (ZCode) — the row
534
+ * fallback then degrades to embedded-only. Read-only like all session
535
+ * readers (caller holds PRAGMA query_only).
536
+ */
537
+ /** Default row cap for readTimingFromDb sampling — recent parts only, not a full scan. */
538
+ export const DEFAULT_TIMING_SAMPLE_LIMIT = 20_000;
539
+
540
+ export function readTimingFromDb(
541
+ db: Database,
542
+ worktreeCol: string,
543
+ opts?: {
544
+ worktree?: string;
545
+ since?: number;
546
+ until?: number;
547
+ hasTimeUpdated?: boolean;
548
+ /** Cap rows scanned, most recent first. `false`/omitted disables the cap (full scan). */
549
+ limit?: number | false;
550
+ },
551
+ ): StepTimingSummary {
552
+ const needsProject = worktreeCol.startsWith("pr.");
553
+ const join = joinClause(needsProject);
554
+ const filter = buildWhereClause(
555
+ worktreeCol,
556
+ opts?.worktree,
557
+ opts?.since,
558
+ opts?.until,
559
+ );
560
+ const updatedCol =
561
+ opts?.hasTimeUpdated === false
562
+ ? "NULL AS time_updated"
563
+ : "p.time_updated AS time_updated";
564
+ // ponytail: sampling caps JS-side JSON.parse cost (the real bottleneck on
565
+ // large part tables) — ORDER BY + LIMIT keeps it a fast index scan, not a
566
+ // full table scan. Pass limit:false for an exact full-scan run.
567
+ const limitClause =
568
+ opts?.limit === false ? "" : "ORDER BY p.time_created DESC LIMIT ?";
569
+ const limitParams =
570
+ opts?.limit === false ? [] : [opts?.limit ?? DEFAULT_TIMING_SAMPLE_LIMIT];
571
+ const rows = db
572
+ .prepare(
573
+ `
574
+ SELECT p.data AS data, p.time_created AS time_created, ${updatedCol}
575
+ FROM part p
576
+ JOIN session s ON s.id = p.session_id
577
+ ${join}
578
+ WHERE (json_extract(p.data, '$.type') = 'tool'
579
+ OR json_extract(p.data, '$.type') = 'step-finish'
580
+ OR json_extract(p.data, '$.type') = 'reasoning')
581
+ ${filter.clause}
582
+ ${limitClause}
583
+ `,
584
+ )
585
+ .all(...filter.params, ...limitParams) as Array<{
586
+ data: string;
587
+ time_created: number | null;
588
+ time_updated: number | null;
589
+ }>;
590
+ return summarizeTiming(
591
+ collectTiming(
592
+ rows.map((r) => ({
593
+ data: typeof r.data === "string" ? r.data : "",
594
+ time_created:
595
+ typeof r.time_created === "number" ? r.time_created : null,
596
+ time_updated:
597
+ typeof r.time_updated === "number" ? r.time_updated : null,
598
+ })),
599
+ ),
600
+ );
601
+ }
602
+
603
+ // ─── Read snapshot ─────────────────────────────────────────────────────
604
+
605
+ /**
606
+ * Pin ONE read snapshot for a multi-query read, but only under WAL.
607
+ *
608
+ * A client writes its session log while we read it, so totals, detail and
609
+ * timing can each land on a different state of the DB and disagree with each
610
+ * other. A deferred read transaction gives every query below the same
611
+ * snapshot.
612
+ *
613
+ * ponytail: WAL only. Without WAL the SHARED lock this takes can make the
614
+ * *client's own* write fail with SQLITE_BUSY — consistent numbers are never
615
+ * worth breaking the thing we are measuring. Non-WAL just reads unpinned,
616
+ * exactly as before. Returns whether a transaction was opened; pass that to
617
+ * endSnapshot() in a finally.
618
+ */
619
+ export function beginSnapshot(db: Database): boolean {
620
+ try {
621
+ const row = db.prepare("PRAGMA journal_mode").get() as {
622
+ journal_mode?: string;
623
+ } | null;
624
+ if (row?.journal_mode?.toLowerCase() !== "wal") return false;
625
+ db.run("BEGIN DEFERRED");
626
+ return true;
627
+ } catch {
628
+ return false;
629
+ }
630
+ }
631
+
632
+ /** End a beginSnapshot() transaction. No-op when none was opened. */
633
+ export function endSnapshot(db: Database, open: boolean): void {
634
+ if (!open) return;
635
+ try {
636
+ db.run("COMMIT");
637
+ } catch {
638
+ // Read-only transaction — nothing to roll back, and we are closing anyway.
639
+ }
640
+ }
@@ -0,0 +1,31 @@
1
+ // src/session/index.ts — re-exports for backward-compatible imports
2
+ //
3
+ // All callers that do `import { readPassiveUsage } from "./session.js"`
4
+ // will resolve here after src/session.ts is deleted.
5
+
6
+ export {
7
+ claudeProjectSlug,
8
+ findSessionAt,
9
+ loadSessionSpans,
10
+ type SessionSpan,
11
+ } from "./activeSession.js";
12
+ export { readClaudeCodeUsage } from "./claude-code.js";
13
+ export { readCodexUsage } from "./codex.js";
14
+ export { findSessionModel, type SessionClient } from "./findModel.js";
15
+ export {
16
+ collectTiming,
17
+ extractPartTiming,
18
+ mergeBytesByTool,
19
+ parseTimeMs,
20
+ rowFallbackMs,
21
+ summarizeTiming,
22
+ } from "./helpers.js";
23
+ export { readPassiveUsage } from "./opencode.js";
24
+ export { CLIENTS } from "./registry.js";
25
+ export {
26
+ EMPTY_RESULT,
27
+ type ModelBreakdown,
28
+ type PassiveUsageResult,
29
+ type UsageDetail,
30
+ } from "./types.js";
31
+ export { readZcodeUsage } from "./zcode.js";