@sixb/pg 0.1.20 → 0.1.22

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/README.md +1 -0
  2. package/dist/agents/agent-storage.d.ts +8 -2
  3. package/dist/agents/agent-storage.d.ts.map +1 -1
  4. package/dist/agents/approvals.d.ts +48 -0
  5. package/dist/agents/approvals.d.ts.map +1 -0
  6. package/dist/agents/checkpoints.d.ts +1 -0
  7. package/dist/agents/checkpoints.d.ts.map +1 -1
  8. package/dist/agents/index.d.ts +1 -0
  9. package/dist/agents/index.d.ts.map +1 -1
  10. package/dist/agents/memory.d.ts +19 -0
  11. package/dist/agents/memory.d.ts.map +1 -0
  12. package/dist/agents/messages.d.ts.map +1 -1
  13. package/dist/agents/rows.d.ts +15 -1
  14. package/dist/agents/rows.d.ts.map +1 -1
  15. package/dist/agents/runs.d.ts +3 -1
  16. package/dist/agents/runs.d.ts.map +1 -1
  17. package/dist/agents/settings.d.ts +29 -0
  18. package/dist/agents/settings.d.ts.map +1 -0
  19. package/dist/agents/threads.d.ts +10 -1
  20. package/dist/agents/threads.d.ts.map +1 -1
  21. package/dist/ai-cost-groups.d.ts.map +1 -1
  22. package/dist/auth-storage/auth-storage.d.ts +3 -2
  23. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  24. package/dist/auth-storage/sessions.d.ts +2 -1
  25. package/dist/auth-storage/sessions.d.ts.map +1 -1
  26. package/dist/auth-storage/users.d.ts +8 -4
  27. package/dist/auth-storage/users.d.ts.map +1 -1
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +6001 -2923
  31. package/dist/index.js.map +45 -37
  32. package/dist/migrations.d.ts.map +1 -1
  33. package/dist/objects/conditional-reader.d.ts +17 -0
  34. package/dist/objects/conditional-reader.d.ts.map +1 -0
  35. package/dist/objects/query-compiler.d.ts +27 -1
  36. package/dist/objects/query-compiler.d.ts.map +1 -1
  37. package/dist/objects/query-indexes.d.ts.map +1 -1
  38. package/dist/objects/read-scope.d.ts +10 -1
  39. package/dist/objects/read-scope.d.ts.map +1 -1
  40. package/dist/objects/reader.d.ts +2 -0
  41. package/dist/objects/reader.d.ts.map +1 -1
  42. package/dist/objects/scoped-reader.d.ts +12 -0
  43. package/dist/objects/scoped-reader.d.ts.map +1 -0
  44. package/dist/objects/selected-reader.d.ts +1 -0
  45. package/dist/objects/selected-reader.d.ts.map +1 -1
  46. package/dist/objects/storage.d.ts +7 -1
  47. package/dist/objects/storage.d.ts.map +1 -1
  48. package/dist/ontology-storage/index.d.ts +2 -0
  49. package/dist/ontology-storage/index.d.ts.map +1 -1
  50. package/dist/ontology-storage/materialization-session.d.ts +6 -0
  51. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  52. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  53. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  54. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  55. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -1
  56. package/dist/ontology-storage/sources.d.ts +2 -1
  57. package/dist/ontology-storage/sources.d.ts.map +1 -1
  58. package/dist/ontology-storage/unique-values.d.ts +60 -0
  59. package/dist/ontology-storage/unique-values.d.ts.map +1 -0
  60. package/dist/pg-action-run-storage.d.ts +4 -0
  61. package/dist/pg-action-run-storage.d.ts.map +1 -1
  62. package/dist/pg-activity-storage.d.ts +22 -0
  63. package/dist/pg-activity-storage.d.ts.map +1 -0
  64. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  65. package/dist/pg-ai-usage-storage.d.ts +8 -1
  66. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  67. package/dist/pg-artifact-storage.d.ts +26 -0
  68. package/dist/pg-artifact-storage.d.ts.map +1 -0
  69. package/dist/pg-execution-storage.d.ts +3 -1
  70. package/dist/pg-execution-storage.d.ts.map +1 -1
  71. package/dist/pg-workflow-run-storage.d.ts +4 -0
  72. package/dist/pg-workflow-run-storage.d.ts.map +1 -1
  73. package/dist/share-grant-storage.d.ts +2 -1
  74. package/dist/share-grant-storage.d.ts.map +1 -1
  75. package/dist/storage-errors.d.ts +2 -0
  76. package/dist/storage-errors.d.ts.map +1 -1
  77. package/package.json +3 -3
  78. package/src/agents/agent-storage.ts +11 -2
  79. package/src/agents/approvals.ts +400 -0
  80. package/src/agents/checkpoints.ts +8 -4
  81. package/src/agents/index.ts +1 -0
  82. package/src/agents/memory.ts +175 -0
  83. package/src/agents/messages.ts +14 -7
  84. package/src/agents/rows.ts +46 -1
  85. package/src/agents/runs.ts +154 -22
  86. package/src/agents/settings.ts +105 -0
  87. package/src/agents/threads.ts +379 -3
  88. package/src/ai-cost-groups.ts +11 -1
  89. package/src/auth-storage/auth-storage.ts +45 -13
  90. package/src/auth-storage/sessions.ts +20 -0
  91. package/src/auth-storage/users.ts +105 -43
  92. package/src/index.ts +10 -0
  93. package/src/migrations/063-artifacts.sql +67 -0
  94. package/src/migrations/064-drop-output-diagnostics.sql +14 -0
  95. package/src/migrations/065-plan-work-payload-statistics.sql +5 -0
  96. package/src/migrations/066-activity.sql +61 -0
  97. package/src/migrations/067-activity-timeline.sql +15 -0
  98. package/src/migrations/068-activity-interactions.sql +20 -0
  99. package/src/migrations/069-artifact-kinds.sql +4 -0
  100. package/src/migrations/070-artifact-state.sql +16 -0
  101. package/src/migrations/071-agent-thread-participants.sql +16 -0
  102. package/src/migrations/072-forked-context-checkpoints.sql +23 -0
  103. package/src/migrations/073-unique-values.sql +25 -0
  104. package/src/migrations/074-generated-media-usage.sql +21 -0
  105. package/src/migrations/075-model-call-files.sql +11 -0
  106. package/src/migrations/076-agent-memory.sql +33 -0
  107. package/src/migrations/077-artifact-threads.sql +21 -0
  108. package/src/migrations/078-agent-approvals.sql +58 -0
  109. package/src/migrations/079-agent-approval-resume.sql +20 -0
  110. package/src/migrations/080-subagent-threads.sql +145 -0
  111. package/src/migrations/081-subagent-questions.sql +6 -0
  112. package/src/migrations.ts +52 -0
  113. package/src/objects/conditional-reader.ts +44 -0
  114. package/src/objects/prepare-queries.ts +1 -1
  115. package/src/objects/query-compiler.ts +96 -33
  116. package/src/objects/query-indexes.ts +29 -10
  117. package/src/objects/read-scope.ts +140 -25
  118. package/src/objects/reader.ts +50 -6
  119. package/src/objects/scoped-reader.ts +101 -0
  120. package/src/objects/selected-reader.ts +16 -76
  121. package/src/objects/storage.ts +24 -5
  122. package/src/objects/text-count-index.ts +1 -0
  123. package/src/ontology-storage/index.ts +3 -0
  124. package/src/ontology-storage/materialization-session.ts +8 -0
  125. package/src/ontology-storage/materialization-writer.ts +76 -1
  126. package/src/ontology-storage/materializations.ts +20 -1
  127. package/src/ontology-storage/replacement-plans.ts +25 -7
  128. package/src/ontology-storage/sources.ts +37 -0
  129. package/src/ontology-storage/unique-values.ts +274 -0
  130. package/src/pg-action-run-storage.ts +23 -0
  131. package/src/pg-activity-storage.ts +486 -0
  132. package/src/pg-ai-cost-storage.ts +62 -12
  133. package/src/pg-ai-usage-storage.ts +90 -1
  134. package/src/pg-artifact-storage.ts +521 -0
  135. package/src/pg-execution-storage.ts +83 -0
  136. package/src/pg-workflow-run-storage.ts +23 -0
  137. package/src/share-grant-storage.ts +21 -0
  138. package/src/storage-errors.ts +7 -0
  139. package/src/transactions.ts +1 -1
@@ -0,0 +1,486 @@
1
+ import {
2
+ ACTIVITY_MAX_VISIT_MS,
3
+ type ActivitySourceState,
4
+ activityActor,
5
+ activityCalendarDays,
6
+ parseActivityAttribution,
7
+ planActivityAppend,
8
+ type StoredActivityEvent,
9
+ serializeActivityAttribution,
10
+ validateActivityAppend,
11
+ } from "@sixb/core/internal/storage"
12
+ import type {
13
+ ActivityCalendarInput,
14
+ ActivityCursor,
15
+ ActivityDay,
16
+ ActivityInteraction,
17
+ ActivityPageView,
18
+ ActivityPeriodInput,
19
+ ActivitySessionVisits,
20
+ ActivityStorage,
21
+ ActivitySummary,
22
+ ActivityUsersPage,
23
+ ActivityVisit,
24
+ ActivityVisitsPage,
25
+ AppendActivityInput,
26
+ AppendActivityResult,
27
+ ListActivitySessionVisitsInput,
28
+ ListActivityUsersInput,
29
+ ListActivityVisitsInput,
30
+ ReadActivityVisitInput,
31
+ StoredActivityVisitPage,
32
+ } from "@sixb/core/storage"
33
+ import type { SQLClient } from "./pg-client"
34
+ import { lockAdvisoryKeys, type PgStoreClient, runPgTransaction } from "./transactions"
35
+
36
+ // Sorts after every stored timestamp, so a first page starts from the newest row.
37
+ const NO_CURSOR = { at: new Date("9999-12-31T23:59:59.999Z"), id: "" }
38
+
39
+ /** PostgreSQL activity. An advisory lock per session serializes its batches across replicas. */
40
+ export class PgActivityStorage implements ActivityStorage {
41
+ constructor(private readonly sql: PgStoreClient) {}
42
+
43
+ async append(input: AppendActivityInput): Promise<AppendActivityResult> {
44
+ validateActivityAppend(input)
45
+
46
+ return runPgTransaction(this.sql, async (sql) => {
47
+ await lockAdvisoryKeys(sql, [
48
+ JSON.stringify(["activity", input.projectId, input.actor.type, input.sessionId]),
49
+ ])
50
+
51
+ const plan = planActivityAppend(
52
+ input,
53
+ await readSource(sql, input),
54
+ await readStoredEvents(sql, input)
55
+ )
56
+ if (plan.status !== "append") {
57
+ return { status: plan.status === "duplicate" ? "accepted" : "throttled", accepted: 0 }
58
+ }
59
+
60
+ const { state } = plan
61
+ const lastSeenAt = new Date(state.lastSeenAt)
62
+
63
+ if (plan.newVisit) {
64
+ const attribution =
65
+ plan.newVisit.attribution === null
66
+ ? null
67
+ : serializeActivityAttribution(plan.newVisit.attribution)
68
+
69
+ await sql`
70
+ INSERT INTO activity_visits (
71
+ project_id, id, actor_type, user_id, grant_id, session_id,
72
+ started_at, last_seen_at, page_views, interactions, attribution
73
+ ) VALUES (
74
+ ${input.projectId}, ${state.visitId}, ${input.actor.type},
75
+ ${input.actor.type === "user" ? input.actor.userId : null},
76
+ ${input.actor.type === "share" ? input.actor.grantId : null},
77
+ ${input.sessionId}, ${lastSeenAt}, ${lastSeenAt}, ${plan.pageViews.length},
78
+ ${plan.interactions.length}, ${attribution}::text::jsonb
79
+ )
80
+ `
81
+ } else {
82
+ await sql`
83
+ UPDATE activity_visits
84
+ SET
85
+ last_seen_at = ${lastSeenAt},
86
+ page_views = page_views + ${plan.pageViews.length},
87
+ interactions = interactions + ${plan.interactions.length}
88
+ WHERE project_id = ${input.projectId} AND id = ${state.visitId}
89
+ `
90
+ }
91
+
92
+ for (const pageView of plan.pageViews) {
93
+ await sql`
94
+ INSERT INTO activity_page_views (
95
+ project_id, visit_id, sequence, id, route, received_at, attribution
96
+ ) VALUES (
97
+ ${input.projectId}, ${pageView.visitId}, ${pageView.sequence}, ${pageView.id},
98
+ ${pageView.route}, ${pageView.receivedAt},
99
+ ${serializeActivityAttribution(pageView.attribution)}::text::jsonb
100
+ )
101
+ `
102
+ }
103
+
104
+ for (const interaction of plan.interactions) {
105
+ await sql`
106
+ INSERT INTO activity_interactions (
107
+ project_id, visit_id, sequence, id, name, route, received_at
108
+ ) VALUES (
109
+ ${input.projectId}, ${interaction.visitId}, ${interaction.sequence},
110
+ ${interaction.id}, ${interaction.name}, ${interaction.route}, ${interaction.receivedAt}
111
+ )
112
+ `
113
+ }
114
+
115
+ await sql`
116
+ INSERT INTO activity_sources (
117
+ project_id, actor_type, session_id, visit_id, last_seen_at, sequence,
118
+ window_start, window_count
119
+ ) VALUES (
120
+ ${input.projectId}, ${input.actor.type}, ${input.sessionId}, ${state.visitId},
121
+ ${lastSeenAt}, ${state.sequence}, ${new Date(state.windowStart)}, ${state.windowCount}
122
+ )
123
+ ON CONFLICT (project_id, actor_type, session_id) DO UPDATE SET
124
+ visit_id = excluded.visit_id,
125
+ last_seen_at = excluded.last_seen_at,
126
+ sequence = excluded.sequence,
127
+ window_start = excluded.window_start,
128
+ window_count = excluded.window_count
129
+ `
130
+
131
+ return { status: "accepted", accepted: plan.accepted }
132
+ })
133
+ }
134
+
135
+ async list(input: ListActivityVisitsInput): Promise<ActivityVisitsPage> {
136
+ const before = input.before ?? NO_CURSOR
137
+ const rows = await this.sql<VisitRow[]>`
138
+ SELECT ${this.visitColumns()}
139
+ FROM activity_visits v
140
+ WHERE ${this.overlapping(input)}
141
+ AND (
142
+ v.last_seen_at < ${before.at}
143
+ OR (v.last_seen_at = ${before.at} AND v.id < ${before.id})
144
+ )
145
+ ORDER BY v.last_seen_at DESC, v.id DESC
146
+ LIMIT ${input.limit + 1}
147
+ `
148
+
149
+ return {
150
+ visits: rows.slice(0, input.limit).map(visitFromRow),
151
+ hasMore: rows.length > input.limit,
152
+ }
153
+ }
154
+
155
+ async users(input: ListActivityUsersInput): Promise<ActivityUsersPage> {
156
+ const before: ActivityCursor = input.before ?? NO_CURSOR
157
+ const rows = await this.sql<UserRow[]>`
158
+ SELECT
159
+ v.user_id,
160
+ MAX(v.last_seen_at) AS last_seen_at,
161
+ COUNT(DISTINCT v.id) AS visits,
162
+ COUNT(p.id) AS page_views
163
+ FROM activity_visits v
164
+ LEFT JOIN activity_page_views p
165
+ ON p.project_id = v.project_id
166
+ AND p.visit_id = v.id
167
+ AND p.received_at >= ${input.from}
168
+ AND p.received_at < ${input.to}
169
+ WHERE ${this.overlapping(input)} AND v.actor_type = 'user'
170
+ GROUP BY v.user_id
171
+ HAVING MAX(v.last_seen_at) < ${before.at}
172
+ OR (MAX(v.last_seen_at) = ${before.at} AND v.user_id < ${before.id})
173
+ ORDER BY last_seen_at DESC, v.user_id DESC
174
+ LIMIT ${input.limit + 1}
175
+ `
176
+
177
+ return {
178
+ users: rows.slice(0, input.limit).map((row) => ({
179
+ userId: row.user_id,
180
+ lastSeenAt: new Date(row.last_seen_at),
181
+ visits: Number(row.visits),
182
+ pageViews: Number(row.page_views),
183
+ })),
184
+ hasMore: rows.length > input.limit,
185
+ }
186
+ }
187
+
188
+ async summarize(input: ActivityPeriodInput): Promise<ActivitySummary> {
189
+ const [row] = await this.sql<SummaryRow[]>`
190
+ SELECT
191
+ COUNT(DISTINCT v.user_id) AS users,
192
+ COUNT(DISTINCT v.id) AS visits,
193
+ COUNT(DISTINCT v.id) FILTER (WHERE v.actor_type = 'share') AS shared_visits,
194
+ COUNT(p.id) AS page_views
195
+ FROM activity_visits v
196
+ LEFT JOIN activity_page_views p
197
+ ON p.project_id = v.project_id
198
+ AND p.visit_id = v.id
199
+ AND p.received_at >= ${input.from}
200
+ AND p.received_at < ${input.to}
201
+ WHERE ${this.overlapping(input)}
202
+ `
203
+
204
+ return {
205
+ users: Number(row?.users ?? 0),
206
+ visits: Number(row?.visits ?? 0),
207
+ sharedVisits: Number(row?.shared_visits ?? 0),
208
+ pageViews: Number(row?.page_views ?? 0),
209
+ }
210
+ }
211
+
212
+ async calendar(input: ActivityCalendarInput): Promise<readonly ActivityDay[]> {
213
+ const visits = await this.sql<{ started_at: Date | string; last_seen_at: Date | string }[]>`
214
+ SELECT v.started_at, v.last_seen_at
215
+ FROM activity_visits v
216
+ WHERE ${this.overlapping(input)}
217
+ `
218
+ const days = await this.sql<{ day: string; page_views: string }[]>`
219
+ SELECT
220
+ to_char(p.received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day,
221
+ COUNT(*) AS page_views
222
+ FROM activity_visits v
223
+ JOIN activity_page_views p ON p.project_id = v.project_id AND p.visit_id = v.id
224
+ WHERE ${this.overlapping(input)}
225
+ AND p.received_at >= ${input.from}
226
+ AND p.received_at < ${input.to}
227
+ GROUP BY day
228
+ `
229
+
230
+ return activityCalendarDays(
231
+ input,
232
+ visits.map((visit) => ({
233
+ startedAt: new Date(visit.started_at),
234
+ lastSeenAt: new Date(visit.last_seen_at),
235
+ })),
236
+ new Map(days.map((row) => [row.day, Number(row.page_views)]))
237
+ )
238
+ }
239
+
240
+ async read(input: ReadActivityVisitInput): Promise<StoredActivityVisitPage | null> {
241
+ const [visit] = await this.sql<(VisitRow & { readonly session_id: string })[]>`
242
+ SELECT ${this.visitColumns()}, v.session_id
243
+ FROM activity_visits v
244
+ WHERE v.project_id = ${input.projectId} AND v.id = ${input.id}
245
+ `
246
+ if (!visit) {
247
+ return null
248
+ }
249
+
250
+ // Page views and interactions share the visit's sequence, so one bound covers both.
251
+ const rows = await this.sql<EventRow[]>`
252
+ SELECT id, visit_id, sequence, route, NULL::text AS name, received_at
253
+ FROM activity_page_views
254
+ WHERE project_id = ${input.projectId}
255
+ AND visit_id = ${input.id}
256
+ UNION ALL
257
+ SELECT id, visit_id, sequence, route, name, received_at
258
+ FROM activity_interactions
259
+ WHERE project_id = ${input.projectId}
260
+ AND visit_id = ${input.id}
261
+ ORDER BY sequence
262
+ LIMIT ${input.limit + 1}
263
+ `
264
+
265
+ const pageViews: ActivityPageView[] = []
266
+ const interactions: ActivityInteraction[] = []
267
+ for (const row of rows.slice(0, input.limit)) {
268
+ if (row.name === null) {
269
+ pageViews.push(pageViewFromRow(row))
270
+ } else {
271
+ interactions.push({ ...pageViewFromRow(row), name: row.name })
272
+ }
273
+ }
274
+
275
+ return {
276
+ visit: visitFromRow(visit),
277
+ sessionId: visit.session_id,
278
+ pageViews,
279
+ interactions,
280
+ hasMore: rows.length > input.limit,
281
+ }
282
+ }
283
+
284
+ async sessionVisits(
285
+ input: ListActivitySessionVisitsInput
286
+ ): Promise<readonly ActivitySessionVisits[]> {
287
+ if (input.sessionIds.length === 0) {
288
+ return []
289
+ }
290
+
291
+ // A session opens a visit per return after inactivity, so each has a few to rank.
292
+ const rows = await this.sql<SessionVisitRow[]>`
293
+ SELECT *
294
+ FROM (
295
+ SELECT
296
+ ${this.visitColumns()},
297
+ v.session_id,
298
+ ROW_NUMBER() OVER (
299
+ PARTITION BY v.session_id ORDER BY v.started_at DESC, v.id DESC
300
+ ) AS position,
301
+ COUNT(*) OVER (PARTITION BY v.session_id) AS total
302
+ FROM activity_visits v
303
+ WHERE v.project_id = ${input.projectId}
304
+ AND v.actor_type = 'user'
305
+ AND v.session_id = ANY(${this.sql.array([...input.sessionIds])}::text[])
306
+ AND v.user_id = ${input.userId}
307
+ ) ranked
308
+ WHERE position <= ${input.limit}
309
+ ORDER BY session_id, position
310
+ `
311
+
312
+ const bySession = new Map<string, { visits: number; latest: ActivityVisit[] }>()
313
+ for (const row of rows) {
314
+ const session = bySession.get(row.session_id) ?? { visits: Number(row.total), latest: [] }
315
+ session.latest.push(visitFromRow(row))
316
+ bySession.set(row.session_id, session)
317
+ }
318
+
319
+ return [...bySession].map(([sessionId, session]) => ({ sessionId, ...session }))
320
+ }
321
+
322
+ /**
323
+ * The visits `v` active in the period, and only the account's when one is asked for. A visit
324
+ * lasts less than a day, so it started at most a day before the period: that bounds the index
325
+ * range.
326
+ */
327
+ private overlapping(input: ActivityPeriodInput | ActivityCalendarInput) {
328
+ const user = input.userId === undefined ? this.sql`` : this.sql`AND v.user_id = ${input.userId}`
329
+ const earliestStart = new Date(input.from.getTime() - ACTIVITY_MAX_VISIT_MS)
330
+
331
+ return this.sql`
332
+ v.project_id = ${input.projectId}
333
+ AND v.started_at >= ${earliestStart}
334
+ AND v.started_at < ${input.to}
335
+ AND v.last_seen_at >= ${input.from}
336
+ ${user}
337
+ `
338
+ }
339
+
340
+ private visitColumns() {
341
+ return this.sql`
342
+ v.id, v.actor_type, v.user_id, v.grant_id, v.started_at, v.last_seen_at, v.page_views,
343
+ v.interactions, v.attribution::text AS attribution
344
+ `
345
+ }
346
+ }
347
+
348
+ async function readSource(
349
+ sql: SQLClient,
350
+ input: AppendActivityInput
351
+ ): Promise<ActivitySourceState | undefined> {
352
+ const [row] = await sql<SourceRow[]>`
353
+ SELECT
354
+ s.visit_id, v.started_at AS visit_started_at, s.last_seen_at, s.sequence,
355
+ s.window_start, s.window_count
356
+ FROM activity_sources s
357
+ LEFT JOIN activity_visits v ON v.project_id = s.project_id AND v.id = s.visit_id
358
+ WHERE s.project_id = ${input.projectId}
359
+ AND s.actor_type = ${input.actor.type}
360
+ AND s.session_id = ${input.sessionId}
361
+ `
362
+ if (!row) {
363
+ return undefined
364
+ }
365
+
366
+ // A deleted visit leaves its source behind; the next batch starts another visit.
367
+ return {
368
+ visitId: row.visit_id,
369
+ visitStartedAt: row.visit_started_at === null ? null : new Date(row.visit_started_at).getTime(),
370
+ lastSeenAt: new Date(row.last_seen_at).getTime(),
371
+ sequence: Number(row.sequence),
372
+ windowStart: new Date(row.window_start).getTime(),
373
+ windowCount: Number(row.window_count),
374
+ }
375
+ }
376
+
377
+ /** The session's page views and interactions with an id of the batch, to recognize retries. */
378
+ async function readStoredEvents(
379
+ sql: SQLClient,
380
+ input: AppendActivityInput
381
+ ): Promise<StoredActivityEvent[]> {
382
+ const ids = input.events.map((event) => event.id)
383
+ const rows = await sql<
384
+ { id: string; route: string; name: string | null; attribution: string | null }[]
385
+ >`
386
+ SELECT p.id, p.route, NULL::text AS name, p.attribution::text AS attribution
387
+ FROM activity_page_views p
388
+ JOIN activity_visits v ON v.project_id = p.project_id AND v.id = p.visit_id
389
+ WHERE p.project_id = ${input.projectId}
390
+ AND p.id = ANY(${sql.array(ids)}::text[])
391
+ AND v.actor_type = ${input.actor.type}
392
+ AND v.session_id = ${input.sessionId}
393
+ UNION ALL
394
+ SELECT i.id, i.route, i.name, NULL::text AS attribution
395
+ FROM activity_interactions i
396
+ JOIN activity_visits v ON v.project_id = i.project_id AND v.id = i.visit_id
397
+ WHERE i.project_id = ${input.projectId}
398
+ AND i.id = ANY(${sql.array(ids)}::text[])
399
+ AND v.actor_type = ${input.actor.type}
400
+ AND v.session_id = ${input.sessionId}
401
+ `
402
+
403
+ return rows.map((row) => {
404
+ if (row.name !== null) {
405
+ return { id: row.id, route: row.route, name: row.name }
406
+ }
407
+
408
+ const attribution = parseActivityAttribution(row.attribution)
409
+ return { id: row.id, route: row.route, ...(attribution ? { attribution } : {}) }
410
+ })
411
+ }
412
+
413
+ interface SourceRow {
414
+ readonly visit_id: string
415
+ readonly visit_started_at: Date | string | null
416
+ readonly last_seen_at: Date | string
417
+ readonly sequence: number
418
+ readonly window_start: Date | string
419
+ readonly window_count: number
420
+ }
421
+
422
+ interface VisitRow {
423
+ readonly id: string
424
+ readonly actor_type: string
425
+ readonly user_id: string | null
426
+ readonly grant_id: string | null
427
+ readonly started_at: Date | string
428
+ readonly last_seen_at: Date | string
429
+ readonly page_views: number
430
+ readonly interactions: number
431
+ readonly attribution: string | null
432
+ }
433
+
434
+ interface SessionVisitRow extends VisitRow {
435
+ readonly session_id: string
436
+ readonly total: number | string
437
+ }
438
+
439
+ interface UserRow {
440
+ readonly user_id: string
441
+ readonly last_seen_at: Date | string
442
+ readonly visits: string
443
+ readonly page_views: string
444
+ }
445
+
446
+ interface SummaryRow {
447
+ readonly users: string
448
+ readonly visits: string
449
+ readonly shared_visits: string
450
+ readonly page_views: string
451
+ }
452
+
453
+ interface PageViewRow {
454
+ readonly id: string
455
+ readonly visit_id: string
456
+ readonly sequence: number
457
+ readonly route: string
458
+ readonly received_at: Date | string
459
+ }
460
+
461
+ /** A page view or, with a name, an interaction. */
462
+ interface EventRow extends PageViewRow {
463
+ readonly name: string | null
464
+ }
465
+
466
+ function visitFromRow(row: VisitRow): ActivityVisit {
467
+ return {
468
+ id: row.id,
469
+ actor: activityActor(row.actor_type, row.user_id, row.grant_id),
470
+ attribution: parseActivityAttribution(row.attribution),
471
+ startedAt: new Date(row.started_at),
472
+ lastSeenAt: new Date(row.last_seen_at),
473
+ pageViews: Number(row.page_views),
474
+ interactions: Number(row.interactions),
475
+ }
476
+ }
477
+
478
+ function pageViewFromRow(row: PageViewRow): ActivityPageView {
479
+ return {
480
+ id: row.id,
481
+ visitId: row.visit_id,
482
+ sequence: Number(row.sequence),
483
+ route: row.route,
484
+ receivedAt: new Date(row.received_at),
485
+ }
486
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type AiAccountingAggregateFragment,
3
3
  type AiAccountingRecordSetItem,
4
+ aiAccountingModelKind,
4
5
  aiModelCallCostDetails,
5
6
  aiModelCallCostMatchesUsage,
6
7
  buildAiAccountingOverviewFromFragments,
@@ -103,6 +104,7 @@ export class PgAiCostStorage implements AiCostStorage {
103
104
  WITH filtered AS (
104
105
  SELECT usage.provider_id,
105
106
  usage.requested_model_id,
107
+ usage.model_kind,
106
108
  usage.occurred_at,
107
109
  usage.input_tokens,
108
110
  usage.output_tokens,
@@ -112,6 +114,10 @@ export class PgAiCostStorage implements AiCostStorage {
112
114
  usage.text_output_tokens,
113
115
  usage.reasoning_output_tokens,
114
116
  usage.audio_duration_ms,
117
+ usage.image_output_tokens,
118
+ usage.audio_output_tokens,
119
+ usage.output_images,
120
+ usage.input_characters,
115
121
  usage.reporting_status,
116
122
  CASE
117
123
  WHEN direct_agent.kind = 'conversation' THEN 'agent'
@@ -174,6 +180,8 @@ export class PgAiCostStorage implements AiCostStorage {
174
180
  UNION ALL
175
181
  SELECT 'model', provider_id, requested_model_id, NULL, NULL, classified.* FROM classified
176
182
  UNION ALL
183
+ SELECT 'kind', model_kind, NULL, NULL, NULL, classified.* FROM classified
184
+ UNION ALL
177
185
  SELECT 'agent', attribution_agent_kind, attribution_workflow_id, attribution_agent_step_id, NULL, classified.*
178
186
  FROM classified WHERE attribution_agent_kind IS NOT NULL
179
187
  UNION ALL
@@ -206,7 +214,15 @@ export class PgAiCostStorage implements AiCostStorage {
206
214
  COUNT(reasoning_output_tokens)::text AS reasoning_output_tokens_count,
207
215
  SUM(reasoning_output_tokens)::text AS reasoning_output_tokens_sum,
208
216
  COUNT(audio_duration_ms)::text AS audio_duration_ms_count,
217
+ COUNT(image_output_tokens)::text AS image_output_tokens_count,
218
+ COUNT(audio_output_tokens)::text AS audio_output_tokens_count,
219
+ COUNT(output_images)::text AS output_images_count,
220
+ COUNT(input_characters)::text AS input_characters_count,
209
221
  SUM(audio_duration_ms)::text AS audio_duration_ms_sum,
222
+ SUM(image_output_tokens)::text AS image_output_tokens_sum,
223
+ SUM(audio_output_tokens)::text AS audio_output_tokens_sum,
224
+ SUM(output_images)::text AS output_images_sum,
225
+ SUM(input_characters)::text AS input_characters_sum,
210
226
  COUNT(*) FILTER (WHERE valuation_status = 'rated')::text AS rated_call_count,
211
227
  COUNT(*) FILTER (WHERE valuation_status = 'unpriceable')::text
212
228
  AS unpriceable_call_count,
@@ -347,6 +363,10 @@ interface UsageRow {
347
363
  readonly text_output_tokens: number | string | null
348
364
  readonly reasoning_output_tokens: number | string | null
349
365
  readonly audio_duration_ms: number | string | null
366
+ readonly image_output_tokens: number | string | null
367
+ readonly audio_output_tokens: number | string | null
368
+ readonly output_images: number | string | null
369
+ readonly input_characters: number | string | null
350
370
  readonly reporting_status: AiModelCallUsageRecord["usage"]["reportingStatus"]
351
371
  readonly raw_usage: ReadonlyJsonObject | string | null
352
372
  readonly provider_ids: Record<string, unknown> | string | null
@@ -402,7 +422,15 @@ interface AggregateRow {
402
422
  readonly reasoning_output_tokens_count: string
403
423
  readonly reasoning_output_tokens_sum: string | null
404
424
  readonly audio_duration_ms_count: string
425
+ readonly image_output_tokens_count: string
426
+ readonly audio_output_tokens_count: string
427
+ readonly output_images_count: string
428
+ readonly input_characters_count: string
405
429
  readonly audio_duration_ms_sum: string | null
430
+ readonly image_output_tokens_sum: string | null
431
+ readonly audio_output_tokens_sum: string | null
432
+ readonly output_images_sum: string | null
433
+ readonly input_characters_sum: string | null
406
434
  readonly rated_call_count: string
407
435
  readonly unpriceable_call_count: string
408
436
  readonly unvalued_call_count: string
@@ -447,18 +475,20 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
447
475
  const dimensionKeys =
448
476
  row.dimension === "model"
449
477
  ? { providerId: required(row.key_1), modelId: required(row.key_2) }
450
- : row.dimension === "agent"
451
- ? {
452
- agentKind: required(row.key_1) as "agent" | "workflowAgent",
453
- ...(row.key_1 === "workflowAgent"
454
- ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
455
- : {}),
456
- }
457
- : row.dimension === "workflow"
458
- ? { workflowId: required(row.key_1) }
459
- : row.dimension === "series"
460
- ? { start: new Date(required(row.bucket_start)) }
461
- : {}
478
+ : row.dimension === "kind"
479
+ ? withModelKind(aiAccountingModelKind(row.key_1))
480
+ : row.dimension === "agent"
481
+ ? {
482
+ agentKind: required(row.key_1) as "agent" | "workflowAgent",
483
+ ...(row.key_1 === "workflowAgent"
484
+ ? { workflowId: required(row.key_2), agentStepId: required(row.key_3) }
485
+ : {}),
486
+ }
487
+ : row.dimension === "workflow"
488
+ ? { workflowId: required(row.key_1) }
489
+ : row.dimension === "series"
490
+ ? { start: new Date(required(row.bucket_start)) }
491
+ : {}
462
492
  return {
463
493
  dimension: row.dimension,
464
494
  ...dimensionKeys,
@@ -486,6 +516,10 @@ function aggregateFragmentFromRow(row: AggregateRow): AiAccountingAggregateFragm
486
516
  row.reasoning_output_tokens_sum
487
517
  ),
488
518
  audioDurationMs: meterFragment(row.audio_duration_ms_count, row.audio_duration_ms_sum),
519
+ imageOutputTokens: meterFragment(row.image_output_tokens_count, row.image_output_tokens_sum),
520
+ audioOutputTokens: meterFragment(row.audio_output_tokens_count, row.audio_output_tokens_sum),
521
+ outputImages: meterFragment(row.output_images_count, row.output_images_sum),
522
+ inputCharacters: meterFragment(row.input_characters_count, row.input_characters_sum),
489
523
  },
490
524
  costs: {
491
525
  ...(row.amount_currency === null || row.amount_nanos === null
@@ -587,6 +621,18 @@ function usageFromRow(row: UsageRow, requesterGroupIds: readonly string[]): AiMo
587
621
  ...(number(row.audio_duration_ms) === undefined
588
622
  ? {}
589
623
  : { audioDurationMs: number(row.audio_duration_ms) }),
624
+ ...(number(row.image_output_tokens) === undefined
625
+ ? {}
626
+ : { imageOutputTokens: number(row.image_output_tokens) }),
627
+ ...(number(row.audio_output_tokens) === undefined
628
+ ? {}
629
+ : { audioOutputTokens: number(row.audio_output_tokens) }),
630
+ ...(number(row.output_images) === undefined
631
+ ? {}
632
+ : { outputImages: number(row.output_images) }),
633
+ ...(number(row.input_characters) === undefined
634
+ ? {}
635
+ : { inputCharacters: number(row.input_characters) }),
590
636
  reportingStatus: row.reporting_status,
591
637
  },
592
638
  ...(row.raw_usage === null ? {} : { rawUsage: parseJsonObject(row.raw_usage) }),
@@ -777,3 +823,7 @@ function required<T>(value: T | null | undefined): T {
777
823
  }
778
824
  return value
779
825
  }
826
+
827
+ function withModelKind(modelKind: AiAccountingAggregateFragment["modelKind"]) {
828
+ return modelKind === undefined ? {} : { modelKind }
829
+ }