@agent-native/core 0.84.13 → 0.84.16

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 (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +25 -0
  3. package/corpus/core/package.json +2 -2
  4. package/corpus/core/src/agent/durable-background.ts +18 -5
  5. package/corpus/core/src/agent/engine/builder-engine.ts +7 -3
  6. package/corpus/core/src/agent/production-agent.ts +48 -7
  7. package/corpus/core/src/agent/run-manager.ts +39 -2
  8. package/corpus/core/src/agent/run-store.ts +55 -11
  9. package/corpus/core/src/client/AssistantChat.tsx +30 -4
  10. package/corpus/core/src/client/MultiTabAssistantChat.tsx +11 -3
  11. package/corpus/core/src/client/agent-chat-adapter.ts +21 -3
  12. package/corpus/core/src/client/agent-chat.ts +18 -3
  13. package/corpus/core/src/client/blocks/library/diagram.tsx +3 -3
  14. package/corpus/core/src/client/chat/message-components.tsx +17 -0
  15. package/corpus/core/src/client/use-agent-engine-configured.ts +7 -4
  16. package/corpus/core/src/db/client.ts +10 -1
  17. package/corpus/core/src/observability/traces.ts +17 -3
  18. package/corpus/core/src/server/agent-chat-plugin.ts +80 -44
  19. package/corpus/core/src/styles/blocks.css +44 -1
  20. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +18 -24
  21. package/corpus/templates/analytics/changelog/2026-07-01-dashboard-charts-no-longer-show-future-dates-when-clients-se.md +6 -0
  22. package/corpus/templates/analytics/changelog/2026-07-01-dashboard-filters-take-less-space-and-keep-view-actions-tucked.md +6 -0
  23. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
  24. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +6 -2
  25. package/corpus/templates/analytics/server/lib/session-replay.ts +109 -50
  26. package/corpus/templates/analytics/server/plugins/db.ts +21 -0
  27. package/corpus/templates/clips/changelog/2026-07-01-chrome-extension-recordings-stream-uploads-while-recording.md +6 -0
  28. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  29. package/corpus/templates/clips/chrome-extension/src/background.ts +8 -0
  30. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +61 -16
  31. package/corpus/templates/design/app/pages/DesignEditor.tsx +176 -13
  32. package/corpus/templates/design/changelog/2026-07-01-exporting-a-design-as-png-or-svg-now-downloads-just-the-sele.md +6 -0
  33. package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +44 -1
  34. package/corpus/templates/plan/changelog/2026-07-01-visual-plan-and-recap-diagram-labels-now-wrap-inside-their-b.md +6 -0
  35. package/dist/agent/durable-background.d.ts +1 -0
  36. package/dist/agent/durable-background.d.ts.map +1 -1
  37. package/dist/agent/durable-background.js +17 -3
  38. package/dist/agent/durable-background.js.map +1 -1
  39. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  40. package/dist/agent/engine/builder-engine.js +7 -1
  41. package/dist/agent/engine/builder-engine.js.map +1 -1
  42. package/dist/agent/production-agent.d.ts.map +1 -1
  43. package/dist/agent/production-agent.js +32 -7
  44. package/dist/agent/production-agent.js.map +1 -1
  45. package/dist/agent/run-manager.d.ts +2 -0
  46. package/dist/agent/run-manager.d.ts.map +1 -1
  47. package/dist/agent/run-manager.js +28 -3
  48. package/dist/agent/run-manager.js.map +1 -1
  49. package/dist/agent/run-store.d.ts +9 -0
  50. package/dist/agent/run-store.d.ts.map +1 -1
  51. package/dist/agent/run-store.js +47 -11
  52. package/dist/agent/run-store.js.map +1 -1
  53. package/dist/client/AssistantChat.d.ts +8 -2
  54. package/dist/client/AssistantChat.d.ts.map +1 -1
  55. package/dist/client/AssistantChat.js +18 -2
  56. package/dist/client/AssistantChat.js.map +1 -1
  57. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  58. package/dist/client/MultiTabAssistantChat.js +7 -3
  59. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  60. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  61. package/dist/client/agent-chat-adapter.js +19 -3
  62. package/dist/client/agent-chat-adapter.js.map +1 -1
  63. package/dist/client/agent-chat.d.ts +7 -0
  64. package/dist/client/agent-chat.d.ts.map +1 -1
  65. package/dist/client/agent-chat.js +5 -1
  66. package/dist/client/agent-chat.js.map +1 -1
  67. package/dist/client/blocks/library/diagram.js +3 -3
  68. package/dist/client/blocks/library/diagram.js.map +1 -1
  69. package/dist/client/chat/message-components.d.ts +3 -1
  70. package/dist/client/chat/message-components.d.ts.map +1 -1
  71. package/dist/client/chat/message-components.js +16 -2
  72. package/dist/client/chat/message-components.js.map +1 -1
  73. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  74. package/dist/client/use-agent-engine-configured.js +7 -1
  75. package/dist/client/use-agent-engine-configured.js.map +1 -1
  76. package/dist/collab/routes.d.ts +2 -2
  77. package/dist/db/client.d.ts +6 -0
  78. package/dist/db/client.d.ts.map +1 -1
  79. package/dist/db/client.js +8 -1
  80. package/dist/db/client.js.map +1 -1
  81. package/dist/notifications/routes.d.ts +2 -2
  82. package/dist/observability/routes.d.ts +3 -3
  83. package/dist/observability/traces.d.ts +5 -0
  84. package/dist/observability/traces.d.ts.map +1 -1
  85. package/dist/observability/traces.js +9 -3
  86. package/dist/observability/traces.js.map +1 -1
  87. package/dist/progress/routes.d.ts +1 -1
  88. package/dist/resources/handlers.d.ts +3 -3
  89. package/dist/server/agent-chat-plugin.d.ts +2 -1
  90. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  91. package/dist/server/agent-chat-plugin.js +64 -42
  92. package/dist/server/agent-chat-plugin.js.map +1 -1
  93. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  94. package/dist/server/transcribe-voice.d.ts +1 -1
  95. package/dist/styles/blocks.css +44 -1
  96. package/package.json +2 -2
@@ -50,6 +50,10 @@ function nowIso(): string {
50
50
  return new Date().toISOString();
51
51
  }
52
52
 
53
+ function todayIsoDate(): string {
54
+ return nowIso().slice(0, 10);
55
+ }
56
+
53
57
  function randomHex(bytes: number): string {
54
58
  const arr = new Uint8Array(bytes);
55
59
  if (!globalThis.crypto?.getRandomValues) {
@@ -204,17 +208,30 @@ function asString(value: unknown): string | null {
204
208
  return null;
205
209
  }
206
210
 
207
- function normalizeTimestamp(value: unknown): string {
208
- if (value instanceof Date) return value.toISOString();
211
+ export function normalizeAnalyticsTimestamp(
212
+ value: unknown,
213
+ receivedAt = nowIso(),
214
+ ): string {
215
+ const fallback = (() => {
216
+ const date = new Date(receivedAt);
217
+ return Number.isNaN(date.getTime()) ? nowIso() : date.toISOString();
218
+ })();
219
+ const fallbackTime = new Date(fallback).getTime();
220
+ const normalize = (date: Date) => {
221
+ if (Number.isNaN(date.getTime())) return fallback;
222
+ return date.getTime() > fallbackTime ? fallback : date.toISOString();
223
+ };
224
+
225
+ if (value instanceof Date) return normalize(value);
209
226
  if (typeof value === "number") {
210
227
  const d = new Date(value);
211
- return Number.isNaN(d.getTime()) ? nowIso() : d.toISOString();
228
+ return normalize(d);
212
229
  }
213
230
  if (typeof value === "string" && value.trim()) {
214
231
  const d = new Date(value);
215
- return Number.isNaN(d.getTime()) ? nowIso() : d.toISOString();
232
+ return normalize(d);
216
233
  }
217
- return nowIso();
234
+ return fallback;
218
235
  }
219
236
 
220
237
  function eventDateFromTimestamp(timestamp: string): string {
@@ -362,7 +379,7 @@ export async function recordAnalyticsEvents(
362
379
  asString((properties as any).anonymousId) ??
363
380
  asString((properties as any).distinctId);
364
381
  const userKey = userId || anonymousId;
365
- const timestamp = normalizeTimestamp(event.timestamp);
382
+ const timestamp = normalizeAnalyticsTimestamp(event.timestamp, receivedAt);
366
383
 
367
384
  return {
368
385
  id: id("evt"),
@@ -516,9 +533,17 @@ function scopeClause(scope: AnalyticsScope): {
516
533
  };
517
534
  }
518
535
 
519
- function scopedAnalyticsSql(
536
+ function freshnessClause(tableName: string): string {
537
+ if (tableName === "analytics_events") {
538
+ return "(COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) <= ?)";
539
+ }
540
+ return "(substr(started_at, 1, 10) <= ?)";
541
+ }
542
+
543
+ export function scopedAnalyticsSql(
520
544
  sql: string,
521
545
  scope: AnalyticsScope,
546
+ today = todayIsoDate(),
522
547
  ): { sql: string; args: Array<string | null> } {
523
548
  const args: Array<string | null> = [];
524
549
  const aliasRe =
@@ -536,8 +561,8 @@ function scopedAnalyticsSql(
536
561
  ? aliasPart
537
562
  : ` AS ${normalizedTable}`;
538
563
  const scopeDef = scopeClause(scope);
539
- args.push(...scopeDef.args);
540
- return `${keyword} (SELECT * FROM ${normalizedTable} WHERE ${scopeDef.sql})${usableAlias}`;
564
+ args.push(...scopeDef.args, today);
565
+ return `${keyword} (SELECT * FROM ${normalizedTable} WHERE ${scopeDef.sql} AND ${freshnessClause(normalizedTable)})${usableAlias}`;
541
566
  },
542
567
  );
543
568
  return { sql: rewritten, args };
@@ -128,6 +128,10 @@ function daysAgoSql(days: number): string {
128
128
  return `to_char(CURRENT_DATE - INTERVAL '${days} ${unit}', 'YYYY-MM-DD')`;
129
129
  }
130
130
 
131
+ function todaySql(): string {
132
+ return "to_char(CURRENT_DATE, 'YYYY-MM-DD')";
133
+ }
134
+
131
135
  function windowStartFilter(days: number): string {
132
136
  return `${EVENT_DATE_SQL} >= ${daysAgoSql(days)}`;
133
137
  }
@@ -140,14 +144,14 @@ function rollingWindowStartSql(
140
144
  }
141
145
 
142
146
  function dashboardTimeRangeFilter(dateExpr = EVENT_DATE_FILTER_SQL): string {
143
- return `('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365)}))`;
147
+ return `(${dateExpr} <= ${todaySql()} AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365)})))`;
144
148
  }
145
149
 
146
150
  function dashboardLookbackTimeRangeFilter(
147
151
  dateExpr = EVENT_DATE_FILTER_SQL,
148
152
  lookbackDays = 0,
149
153
  ): string {
150
- return `('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7 + lookbackDays)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30 + lookbackDays)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90 + lookbackDays)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180 + lookbackDays)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365 + lookbackDays)}))`;
154
+ return `(${dateExpr} <= ${todaySql()} AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7 + lookbackDays)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30 + lookbackDays)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90 + lookbackDays)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180 + lookbackDays)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365 + lookbackDays)})))`;
151
155
  }
152
156
 
153
157
  const DASHBOARD_TIME_RANGE_FILTER = dashboardTimeRangeFilter();
@@ -275,6 +275,53 @@ function replayMaxIso(values: Array<string | null | undefined>): string | null {
275
275
  return present.reduce((max, value) => (value > max ? value : max));
276
276
  }
277
277
 
278
+ function replayClampIso(
279
+ value: string | null | undefined,
280
+ latestIso: string,
281
+ ): string | null {
282
+ if (!value) return null;
283
+ const parsed = Date.parse(value);
284
+ const latest = Date.parse(latestIso);
285
+ if (!Number.isFinite(parsed)) return null;
286
+ if (!Number.isFinite(latest)) return value;
287
+ return parsed > latest ? latestIso : value;
288
+ }
289
+
290
+ function clampReplayChunkTiming(
291
+ chunk: NormalizedSessionReplayChunk,
292
+ latestIso: string,
293
+ ): NormalizedSessionReplayChunk {
294
+ const startedAt = replayClampIso(chunk.startedAt, latestIso);
295
+ let endedAt = replayClampIso(chunk.endedAt, latestIso);
296
+ if (startedAt && endedAt && endedAt < startedAt) {
297
+ endedAt = startedAt;
298
+ }
299
+ return { ...chunk, startedAt, endedAt };
300
+ }
301
+
302
+ function clampReplayIngestTiming(
303
+ input: ParsedSessionReplayIngest,
304
+ latestIso: string,
305
+ ): ParsedSessionReplayIngest {
306
+ const chunks = input.chunks.map((chunk) =>
307
+ clampReplayChunkTiming(chunk, latestIso),
308
+ );
309
+ const startedAt =
310
+ replayMinIso([
311
+ replayClampIso(input.startedAt, latestIso),
312
+ ...chunks.map((chunk) => chunk.startedAt),
313
+ ]) ?? latestIso;
314
+ let endedAt = replayMaxIso([
315
+ replayClampIso(input.endedAt, latestIso),
316
+ ...chunks.map((chunk) => chunk.endedAt),
317
+ ]);
318
+ if (endedAt && endedAt < startedAt) endedAt = startedAt;
319
+ const durationMs = endedAt
320
+ ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
321
+ : input.durationMs;
322
+ return { ...input, startedAt, endedAt, durationMs, chunks };
323
+ }
324
+
278
325
  function normalizeReplayUrl(url: string | null): {
279
326
  url: string | null;
280
327
  path: string | null;
@@ -1101,7 +1148,8 @@ export async function recordSessionReplayChunks(
1101
1148
  }> {
1102
1149
  const key = await resolveReplayPublicKey(input.publicKey, context);
1103
1150
  const db = getDb() as any;
1104
- const ingestedAt = replayNowIso();
1151
+ const ingestedAt = replayTimestamp(context.now) ?? replayNowIso();
1152
+ const clampedInput = clampReplayIngestTiming(input, ingestedAt);
1105
1153
 
1106
1154
  let [recording] = await db
1107
1155
  .select()
@@ -1109,7 +1157,10 @@ export async function recordSessionReplayChunks(
1109
1157
  .where(
1110
1158
  and(
1111
1159
  eq(schema.sessionRecordings.publicKeyId, key.id),
1112
- eq(schema.sessionRecordings.clientRecordingId, input.clientRecordingId),
1160
+ eq(
1161
+ schema.sessionRecordings.clientRecordingId,
1162
+ clampedInput.clientRecordingId,
1163
+ ),
1113
1164
  ),
1114
1165
  )
1115
1166
  .limit(1);
@@ -1121,27 +1172,27 @@ export async function recordSessionReplayChunks(
1121
1172
  .values({
1122
1173
  id: newRecordingId,
1123
1174
  publicKeyId: key.id,
1124
- clientRecordingId: input.clientRecordingId,
1125
- sessionId: input.sessionId,
1126
- userId: input.userId,
1127
- anonymousId: input.anonymousId,
1128
- userKey: input.userKey,
1129
- startedAt: input.startedAt,
1130
- endedAt: input.endedAt,
1131
- durationMs: input.durationMs,
1132
- pageCount: input.pageCount,
1133
- errorCount: input.errorCount,
1134
- rageClickCount: input.rageClickCount,
1135
- privacyMode: input.privacyMode,
1136
- firstUrl: input.url,
1137
- lastUrl: input.url,
1138
- path: input.path,
1139
- hostname: input.hostname,
1140
- referrer: input.referrer,
1141
- app: input.app,
1142
- template: input.template,
1143
- status: input.status,
1144
- metadata: JSON.stringify(input.metadata),
1175
+ clientRecordingId: clampedInput.clientRecordingId,
1176
+ sessionId: clampedInput.sessionId,
1177
+ userId: clampedInput.userId,
1178
+ anonymousId: clampedInput.anonymousId,
1179
+ userKey: clampedInput.userKey,
1180
+ startedAt: clampedInput.startedAt,
1181
+ endedAt: clampedInput.endedAt,
1182
+ durationMs: clampedInput.durationMs,
1183
+ pageCount: clampedInput.pageCount,
1184
+ errorCount: clampedInput.errorCount,
1185
+ rageClickCount: clampedInput.rageClickCount,
1186
+ privacyMode: clampedInput.privacyMode,
1187
+ firstUrl: clampedInput.url,
1188
+ lastUrl: clampedInput.url,
1189
+ path: clampedInput.path,
1190
+ hostname: clampedInput.hostname,
1191
+ referrer: clampedInput.referrer,
1192
+ app: clampedInput.app,
1193
+ template: clampedInput.template,
1194
+ status: clampedInput.status,
1195
+ metadata: JSON.stringify(clampedInput.metadata),
1145
1196
  lastIngestedAt: ingestedAt,
1146
1197
  ownerEmail: key.ownerEmail,
1147
1198
  orgId: key.orgId,
@@ -1162,7 +1213,7 @@ export async function recordSessionReplayChunks(
1162
1213
  eq(schema.sessionRecordings.publicKeyId, key.id),
1163
1214
  eq(
1164
1215
  schema.sessionRecordings.clientRecordingId,
1165
- input.clientRecordingId,
1216
+ clampedInput.clientRecordingId,
1166
1217
  ),
1167
1218
  ),
1168
1219
  )
@@ -1193,7 +1244,7 @@ export async function recordSessionReplayChunks(
1193
1244
  existingChunks.length === 0;
1194
1245
 
1195
1246
  try {
1196
- for (const rawChunk of input.chunks) {
1247
+ for (const rawChunk of clampedInput.chunks) {
1197
1248
  const existing = existingBySeq.get(rawChunk.seq);
1198
1249
  if (existing) {
1199
1250
  if (existing.checksum !== rawChunk.checksum) {
@@ -1272,13 +1323,15 @@ export async function recordSessionReplayChunks(
1272
1323
  id: replayId("sri"),
1273
1324
  publicKeyId: key.id,
1274
1325
  recordingId: recording.id,
1275
- byteLength: replayIngestByteLength(input, context),
1326
+ byteLength: replayIngestByteLength(clampedInput, context),
1276
1327
  createdAt: ingestedAt,
1277
1328
  ownerEmail: key.ownerEmail,
1278
1329
  orgId: key.orgId,
1279
1330
  });
1280
1331
 
1281
- const allChunks = [...existingChunks, ...rowsToInsert];
1332
+ const allChunks = [...existingChunks, ...rowsToInsert].map((chunk: any) =>
1333
+ clampReplayChunkTiming(chunk, ingestedAt),
1334
+ );
1282
1335
  const chunkCount = allChunks.length;
1283
1336
  const eventCount = allChunks.reduce(
1284
1337
  (sum, chunk: any) => sum + Number(chunk.eventCount ?? 0),
@@ -1291,57 +1344,63 @@ export async function recordSessionReplayChunks(
1291
1344
  const startedAt =
1292
1345
  replayMinIso([
1293
1346
  recording.startedAt,
1294
- input.startedAt,
1347
+ clampedInput.startedAt,
1295
1348
  ...allChunks.map((chunk: any) => chunk.startedAt),
1296
- ]) ?? input.startedAt;
1349
+ ]) ?? clampedInput.startedAt;
1297
1350
  const endedAt =
1298
1351
  replayMaxIso([
1299
1352
  recording.endedAt,
1300
- input.endedAt,
1353
+ clampedInput.endedAt,
1301
1354
  ...allChunks.map((chunk: any) => chunk.endedAt),
1302
1355
  ]) ?? null;
1303
1356
  const durationMs =
1304
- input.durationMs ??
1357
+ clampedInput.durationMs ??
1305
1358
  (endedAt
1306
1359
  ? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
1307
1360
  : (recording.durationMs ?? null));
1308
1361
  const metadata = mergeReplayMetadata(
1309
1362
  parseRecordingMetadata(recording),
1310
- input.metadata,
1363
+ clampedInput.metadata,
1311
1364
  );
1312
1365
 
1313
1366
  await db
1314
1367
  .update(schema.sessionRecordings)
1315
1368
  .set({
1316
- sessionId: input.sessionId,
1317
- userId: input.userId ?? recording.userId ?? null,
1318
- anonymousId: input.anonymousId ?? recording.anonymousId ?? null,
1319
- userKey: input.userKey ?? recording.userKey ?? null,
1369
+ sessionId: clampedInput.sessionId,
1370
+ userId: clampedInput.userId ?? recording.userId ?? null,
1371
+ anonymousId: clampedInput.anonymousId ?? recording.anonymousId ?? null,
1372
+ userKey: clampedInput.userKey ?? recording.userKey ?? null,
1320
1373
  startedAt,
1321
1374
  endedAt,
1322
1375
  durationMs,
1323
1376
  chunkCount,
1324
1377
  eventCount,
1325
1378
  totalBytes,
1326
- pageCount: Math.max(Number(recording.pageCount ?? 0), input.pageCount),
1327
- errorCount: Math.max(Number(recording.errorCount ?? 0), input.errorCount),
1379
+ pageCount: Math.max(
1380
+ Number(recording.pageCount ?? 0),
1381
+ clampedInput.pageCount,
1382
+ ),
1383
+ errorCount: Math.max(
1384
+ Number(recording.errorCount ?? 0),
1385
+ clampedInput.errorCount,
1386
+ ),
1328
1387
  rageClickCount: Math.max(
1329
1388
  Number(recording.rageClickCount ?? 0),
1330
- input.rageClickCount,
1389
+ clampedInput.rageClickCount,
1331
1390
  ),
1332
1391
  privacyMode:
1333
- input.privacyMode !== "unknown"
1334
- ? input.privacyMode
1392
+ clampedInput.privacyMode !== "unknown"
1393
+ ? clampedInput.privacyMode
1335
1394
  : (recording.privacyMode ?? "unknown"),
1336
- firstUrl: recording.firstUrl ?? input.url,
1337
- lastUrl: input.url ?? recording.lastUrl ?? null,
1338
- path: input.path ?? recording.path ?? null,
1339
- hostname: input.hostname ?? recording.hostname ?? null,
1340
- referrer: input.referrer ?? recording.referrer ?? null,
1341
- app: input.app ?? recording.app ?? null,
1342
- template: input.template ?? recording.template ?? null,
1395
+ firstUrl: recording.firstUrl ?? clampedInput.url,
1396
+ lastUrl: clampedInput.url ?? recording.lastUrl ?? null,
1397
+ path: clampedInput.path ?? recording.path ?? null,
1398
+ hostname: clampedInput.hostname ?? recording.hostname ?? null,
1399
+ referrer: clampedInput.referrer ?? recording.referrer ?? null,
1400
+ app: clampedInput.app ?? recording.app ?? null,
1401
+ template: clampedInput.template ?? recording.template ?? null,
1343
1402
  status:
1344
- input.status === "completed" || recording.status === "completed"
1403
+ clampedInput.status === "completed" || recording.status === "completed"
1345
1404
  ? "completed"
1346
1405
  : "active",
1347
1406
  metadata: JSON.stringify(metadata),
@@ -1364,7 +1423,7 @@ export async function recordSessionReplayChunks(
1364
1423
 
1365
1424
  return {
1366
1425
  recordingId: recording.id,
1367
- sessionId: input.sessionId,
1426
+ sessionId: clampedInput.sessionId,
1368
1427
  acceptedChunks: rowsToInsert.length,
1369
1428
  duplicateChunks,
1370
1429
  chunkCount,
@@ -492,6 +492,27 @@ export default runMigrations(
492
492
  version: 71,
493
493
  sql: `CREATE INDEX IF NOT EXISTS session_replay_ingests_recording_idx ON session_replay_ingests (recording_id)`,
494
494
  },
495
+ {
496
+ version: 72,
497
+ sql: {
498
+ postgres: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), event_date = substr(COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > to_char(CURRENT_DATE, 'YYYY-MM-DD')`,
499
+ sqlite: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), datetime('now')), event_date = substr(COALESCE(NULLIF(received_at, ''), date('now')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > date('now')`,
500
+ },
501
+ },
502
+ {
503
+ version: 73,
504
+ sql: {
505
+ postgres: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')))`,
506
+ sqlite: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > date('now') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now')))`,
507
+ },
508
+ },
509
+ {
510
+ version: 74,
511
+ sql: {
512
+ postgres: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD'))`,
513
+ sqlite: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now'))`,
514
+ },
515
+ },
495
516
  ],
496
517
  { table: "analytics_migrations" },
497
518
  );
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-01
4
+ ---
5
+
6
+ Chrome extension recordings now stream uploads while recording so saving is less likely to time out.
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Agent-Native Clips",
4
4
  "short_name": "Clips",
5
- "version": "0.1.7",
5
+ "version": "0.1.8",
6
6
  "description": "Start Clips recordings from Chrome, capture optional diagnostics, and preview Clips links on GitHub.",
7
7
  "minimum_chrome_version": "116",
8
8
  "action": {
@@ -32,6 +32,7 @@ const UNQUOTED_SECRET_VALUE_RE = new RegExp(
32
32
  type CaptureSurface = "browser" | "window" | "monitor" | "camera";
33
33
  type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error";
34
34
  type NetworkType = "fetch" | "xhr";
35
+ type UploadMode = "streaming" | "buffered";
35
36
 
36
37
  type ExtensionSettings = {
37
38
  clipsBaseUrl: string;
@@ -134,6 +135,7 @@ type NativeRecording = {
134
135
  recordingId: string;
135
136
  clipsBaseUrl: string;
136
137
  uploadUrl: string;
138
+ uploadMode: UploadMode;
137
139
  targetTabId: number;
138
140
  targetTitle: string | null;
139
141
  targetUrl: string | null;
@@ -1049,6 +1051,7 @@ async function armRecording(args: {
1049
1051
  id?: string;
1050
1052
  uploadChunkUrl?: string;
1051
1053
  abortUrl?: string;
1054
+ uploadMode?: UploadMode;
1052
1055
  };
1053
1056
  let created: CreatedRecording;
1054
1057
  try {
@@ -1061,6 +1064,8 @@ async function armRecording(args: {
1061
1064
  hasAudio:
1062
1065
  settings.includeMicrophone || settings.captureSurface !== "camera",
1063
1066
  visibility: "public",
1067
+ mimeType: "video/webm",
1068
+ requestStreaming: true,
1064
1069
  });
1065
1070
  } catch (err) {
1066
1071
  captureExtensionError(err, {
@@ -1091,6 +1096,7 @@ async function armRecording(args: {
1091
1096
  recordingId: created.id,
1092
1097
  clipsBaseUrl: settings.clipsBaseUrl,
1093
1098
  uploadUrl,
1099
+ uploadMode: created.uploadMode ?? "buffered",
1094
1100
  targetTabId: tab.id as number,
1095
1101
  targetTitle: tab.title?.trim() || null,
1096
1102
  targetUrl: tab.url?.trim() || null,
@@ -1122,6 +1128,7 @@ async function armRecording(args: {
1122
1128
  sessionId,
1123
1129
  recordingId: created.id,
1124
1130
  uploadUrl,
1131
+ uploadMode: created.uploadMode ?? "buffered",
1125
1132
  hasCamera: cameraInvolved,
1126
1133
  startDelayMs,
1127
1134
  authToken,
@@ -1274,6 +1281,7 @@ async function handleOverlayRestart() {
1274
1281
  sessionId: recording.sessionId,
1275
1282
  recordingId: recording.recordingId,
1276
1283
  uploadUrl: recording.uploadUrl,
1284
+ uploadMode: recording.uploadMode ?? "buffered",
1277
1285
  hasCamera:
1278
1286
  recording.captureSurface === "camera" || recording.includeCamera,
1279
1287
  startDelayMs: COUNTDOWN_SECONDS * 1000,
@@ -17,7 +17,11 @@
17
17
  // app recorder via @shared/recording-core so the server contract can't drift.
18
18
 
19
19
  import { scheduleReadyChime } from "@shared/recording-audio";
20
- import { chunkUploadUrl, pickMimeType } from "@shared/recording-core";
20
+ import {
21
+ chunkUploadUrl,
22
+ pickMimeType,
23
+ type UploadMode,
24
+ } from "@shared/recording-core";
21
25
  import { MAX_UPLOAD_BYTES } from "@shared/upload-limits";
22
26
 
23
27
  import { waitForReadyRecordingAfterFinalizeError } from "./finalize-recovery";
@@ -63,6 +67,7 @@ type BeginMessage = {
63
67
  sessionId: string;
64
68
  recordingId: string;
65
69
  uploadUrl: string;
70
+ uploadMode?: UploadMode;
66
71
  hasCamera?: boolean;
67
72
  // Pre-roll countdown delay, owned here in the offscreen document (a reliable
68
73
  // context) rather than the service worker (which can suspend and drop timers).
@@ -114,6 +119,7 @@ type ActiveRecording = {
114
119
  sessionId: string;
115
120
  recordingId: string;
116
121
  uploadUrl: string;
122
+ uploadMode: UploadMode;
117
123
  authToken: string | null;
118
124
  mode: CaptureMode;
119
125
  startedAtMs: number;
@@ -134,6 +140,8 @@ type ActiveRecording = {
134
140
  // Set if the recording grew past the buffer ceiling and we stopped retaining
135
141
  // — at that point a local save can't be guaranteed, so we don't promise one.
136
142
  localBufferOverflow: boolean;
143
+ pendingStreamBlobs: Blob[];
144
+ pendingStreamBytes: number;
137
145
  cancelled: boolean;
138
146
  // Set when the recorder is being torn down to start over on the same source
139
147
  // streams, so the stop handler skips the usual track cleanup.
@@ -155,6 +163,8 @@ type ActiveRecording = {
155
163
  rejectStopped: (error: Error) => void;
156
164
  };
157
165
 
166
+ const GCS_CHUNK_ALIGN_BYTES = 256 * 1024;
167
+ const STREAM_CHUNK_BYTES = 15 * GCS_CHUNK_ALIGN_BYTES; // 3.75 MiB
158
168
  const UPLOAD_SLICE_BYTES = 3 * 1024 * 1024;
159
169
 
160
170
  // Don't retain more than the upload ceiling — past it the server rejects the
@@ -578,6 +588,32 @@ async function uploadBlobInSlices(
578
588
  }
579
589
  }
580
590
 
591
+ async function uploadStreamingBlob(
592
+ recording: ActiveRecording,
593
+ blob: Blob,
594
+ ): Promise<void> {
595
+ if (blob.size === 0) return;
596
+ recording.pendingStreamBlobs.push(blob);
597
+ recording.pendingStreamBytes += blob.size;
598
+
599
+ while (recording.pendingStreamBytes >= STREAM_CHUNK_BYTES) {
600
+ if (recording.cancelled || recording.uploadFailure) return;
601
+ const combined = new Blob(recording.pendingStreamBlobs, {
602
+ type: recording.mimeType,
603
+ });
604
+ const head = combined.slice(0, STREAM_CHUNK_BYTES, recording.mimeType);
605
+ const tail = combined.slice(
606
+ STREAM_CHUNK_BYTES,
607
+ undefined,
608
+ recording.mimeType,
609
+ );
610
+ recording.pendingStreamBlobs = tail.size > 0 ? [tail] : [];
611
+ recording.pendingStreamBytes = tail.size;
612
+ const index = recording.chunkIndex++;
613
+ await uploadChunk(recording, head, index);
614
+ }
615
+ }
616
+
581
617
  function stopStreams(streams: (MediaStream | null)[]): void {
582
618
  for (const stream of streams) {
583
619
  if (!stream) continue;
@@ -744,6 +780,7 @@ async function begin(message: BeginMessage): Promise<{
744
780
  sessionId: ready.sessionId,
745
781
  recordingId: message.recordingId,
746
782
  uploadUrl: message.uploadUrl,
783
+ uploadMode: message.uploadMode ?? "buffered",
747
784
  authToken: message.authToken ?? null,
748
785
  mode: ready.mode,
749
786
  startedAtMs: 0,
@@ -763,6 +800,8 @@ async function begin(message: BeginMessage): Promise<{
763
800
  recordedBlobs: [],
764
801
  recordedBytes: 0,
765
802
  localBufferOverflow: false,
803
+ pendingStreamBlobs: [],
804
+ pendingStreamBytes: 0,
766
805
  cancelled: false,
767
806
  restarting: false,
768
807
  startTimer: null,
@@ -798,7 +837,11 @@ async function begin(message: BeginMessage): Promise<{
798
837
  // rejected promise that surfaces as an "Uncaught (in promise)" error (bad
799
838
  // look in a Chrome Web Store review). finalizeStop reads recording.upload-
800
839
  // Failure and surfaces it through the normal error path instead.
801
- const upload = uploadBlobInSlices(recording, event.data).catch((err) => {
840
+ const upload = (
841
+ recording.uploadMode === "streaming"
842
+ ? uploadStreamingBlob(recording, event.data)
843
+ : uploadBlobInSlices(recording, event.data)
844
+ ).catch((err) => {
802
845
  recording.uploadFailure =
803
846
  err instanceof Error ? err : new Error(String(err));
804
847
  captureExtensionError(recording.uploadFailure, {
@@ -1001,20 +1044,22 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
1001
1044
  }
1002
1045
  let result: UploadResult;
1003
1046
  try {
1004
- result = await uploadChunk(
1005
- recording,
1006
- new Blob([], { type: recording.mimeType }),
1007
- recording.chunkIndex,
1008
- {
1009
- isFinal: true,
1010
- total: recording.chunkIndex,
1011
- durationMs,
1012
- width: recording.dimensions.width,
1013
- height: recording.dimensions.height,
1014
- hasAudio: recording.hasAudio,
1015
- hasCamera: recording.hasCamera,
1016
- },
1017
- );
1047
+ const finalBlob =
1048
+ recording.uploadMode === "streaming"
1049
+ ? new Blob(recording.pendingStreamBlobs, { type: recording.mimeType })
1050
+ : new Blob([], { type: recording.mimeType });
1051
+ recording.pendingStreamBlobs = [];
1052
+ recording.pendingStreamBytes = 0;
1053
+ const finalIndex = recording.chunkIndex;
1054
+ result = await uploadChunk(recording, finalBlob, finalIndex, {
1055
+ isFinal: true,
1056
+ total: finalIndex + (finalBlob.size > 0 ? 1 : 0),
1057
+ durationMs,
1058
+ width: recording.dimensions.width,
1059
+ height: recording.dimensions.height,
1060
+ hasAudio: recording.hasAudio,
1061
+ hasCamera: recording.hasCamera,
1062
+ });
1018
1063
  } catch (err) {
1019
1064
  const error = err instanceof Error ? err : new Error(String(err));
1020
1065
  (error as { finalUpload?: boolean }).finalUpload = true;