@agent-native/core 0.81.3 → 0.82.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 (41) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/file-upload/builder.ts +199 -37
  5. package/corpus/core/src/file-upload/index.ts +2 -0
  6. package/corpus/core/src/file-upload/types.ts +38 -0
  7. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  8. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  9. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  10. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  11. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  12. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  13. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  14. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  15. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  16. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  17. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +63 -16
  18. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  19. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  20. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  21. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  22. package/dist/collab/awareness.d.ts +2 -2
  23. package/dist/collab/awareness.d.ts.map +1 -1
  24. package/dist/collab/routes.d.ts +1 -1
  25. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  26. package/dist/file-upload/builder.d.ts.map +1 -1
  27. package/dist/file-upload/builder.js +137 -25
  28. package/dist/file-upload/builder.js.map +1 -1
  29. package/dist/file-upload/index.d.ts +1 -1
  30. package/dist/file-upload/index.d.ts.map +1 -1
  31. package/dist/file-upload/index.js.map +1 -1
  32. package/dist/file-upload/types.d.ts +26 -0
  33. package/dist/file-upload/types.d.ts.map +1 -1
  34. package/dist/file-upload/types.js.map +1 -1
  35. package/dist/notifications/routes.d.ts +3 -3
  36. package/dist/observability/routes.d.ts +8 -8
  37. package/dist/progress/routes.d.ts +1 -1
  38. package/dist/resources/handlers.d.ts +3 -3
  39. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  40. package/dist/server/transcribe-voice.d.ts +1 -1
  41. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4804
31
+ - template files: 4807
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.82.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fe9fd99: Add optional `resumable` capability to `FileUploadProvider` for streaming uploads. Providers that implement `startSession`, `relayChunk`, and `completeSession` can receive video chunks during recording instead of waiting for a fully assembled file after stop. The Builder.io provider implements this via the GCS resumable upload protocol. Also exports `ResumableUploadSession` and `ResumableChunkResult` types.
8
+
3
9
  ## 0.81.3
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.81.3",
3
+ "version": "0.82.0",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -2,6 +2,8 @@ import type {
2
2
  FileUploadProvider,
3
3
  FileUploadInput,
4
4
  FileUploadResult,
5
+ ResumableUploadSession,
6
+ ResumableChunkResult,
5
7
  } from "./types.js";
6
8
 
7
9
  const DEFAULT_BUILDER_APP_HOST = "https://builder.io";
@@ -46,8 +48,6 @@ async function uploadLargeFileViaSignedUrl(
46
48
  bareMimeType: string,
47
49
  bytes: Uint8Array,
48
50
  ): Promise<FileUploadResult> {
49
- const host = builderUploadHost();
50
- const authHeader = { Authorization: `Bearer ${privateKey}` };
51
51
  const name = input.filename ?? "upload";
52
52
  const mb = (bytes.byteLength / (1024 * 1024)).toFixed(1);
53
53
 
@@ -57,32 +57,12 @@ async function uploadLargeFileViaSignedUrl(
57
57
 
58
58
  // Step 1 — request a signed URL.
59
59
  console.log(`[builder-upload] step 1: requesting signed URL`);
60
- const step1Res = await fetchWithTimeout(
61
- new URL("/api/v1/upload/signed-url", host).toString(),
62
- {
63
- method: "POST",
64
- headers: { ...authHeader, "Content-Type": "application/json" },
65
- body: JSON.stringify({
66
- fileName: name,
67
- contentType: bareMimeType,
68
- size: bytes.byteLength,
69
- }),
70
- },
60
+ const { uploadUrl, assetId, requiredHeaders } = await requestBuilderSignedUrl(
61
+ privateKey,
62
+ name,
63
+ bareMimeType,
64
+ bytes.byteLength,
71
65
  );
72
- await assertOk(step1Res, "Builder.io signed-URL request failed");
73
-
74
- const step1Json = (await step1Res.json()) as {
75
- uploadUrl?: string;
76
- assetId?: string;
77
- expiresAt?: string;
78
- requiredHeaders?: Record<string, string>;
79
- };
80
- const { uploadUrl, assetId, requiredHeaders } = step1Json;
81
- if (!uploadUrl || !assetId || !requiredHeaders) {
82
- throw new Error(
83
- `Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(step1Json))}`,
84
- );
85
- }
86
66
  console.log(`[builder-upload] step 1 ok: assetId=${assetId}`);
87
67
 
88
68
  // Step 2 — PUT bytes directly to GCS. Only requiredHeaders; no Authorization
@@ -102,21 +82,80 @@ async function uploadLargeFileViaSignedUrl(
102
82
  console.log(
103
83
  `[builder-upload] step 3: registering asset - ${assetId}, ${input.filename}`,
104
84
  );
105
- const step3Res = await fetchWithTimeout(
85
+ const { url, id } = await completeBuilderUpload(
86
+ privateKey,
87
+ assetId,
88
+ input.filename,
89
+ );
90
+ console.log(`[builder-upload] done [${assetId}]: ${url}`);
91
+ return { url, id, provider: "builder" };
92
+ }
93
+
94
+ async function requestBuilderSignedUrl(
95
+ privateKey: string,
96
+ filename: string,
97
+ mimeType: string,
98
+ size: number,
99
+ resumable = false,
100
+ ): Promise<{
101
+ uploadUrl: string;
102
+ assetId: string;
103
+ requiredHeaders: Record<string, string>;
104
+ }> {
105
+ const host = builderUploadHost();
106
+ const url = new URL("/api/v1/upload/signed-url", host);
107
+ const res = await fetchWithTimeout(url.toString(), {
108
+ method: "POST",
109
+ headers: {
110
+ Authorization: `Bearer ${privateKey}`,
111
+ "Content-Type": "application/json",
112
+ },
113
+ body: JSON.stringify({
114
+ fileName: filename,
115
+ contentType: mimeType,
116
+ size,
117
+ resumable,
118
+ }),
119
+ });
120
+ await assertOk(res, "Builder.io signed-URL request failed");
121
+ const json = (await res.json()) as {
122
+ uploadUrl?: string;
123
+ assetId?: string;
124
+ requiredHeaders?: Record<string, string>;
125
+ };
126
+ if (!json.uploadUrl || !json.assetId || !json.requiredHeaders) {
127
+ throw new Error(
128
+ `Builder.io signed-URL response missing required fields: ${JSON.stringify(Object.keys(json))}`,
129
+ );
130
+ }
131
+ return {
132
+ uploadUrl: json.uploadUrl,
133
+ assetId: json.assetId,
134
+ requiredHeaders: json.requiredHeaders,
135
+ };
136
+ }
137
+
138
+ async function completeBuilderUpload(
139
+ privateKey: string,
140
+ assetId: string,
141
+ filename: string | undefined,
142
+ ): Promise<{ url: string; id?: string }> {
143
+ const host = builderUploadHost();
144
+ const res = await fetchWithTimeout(
106
145
  new URL("/api/v1/upload/complete", host).toString(),
107
146
  {
108
147
  method: "POST",
109
- headers: { ...authHeader, "Content-Type": "application/json" },
110
- body: JSON.stringify({ assetId, name: input.filename }),
148
+ headers: {
149
+ Authorization: `Bearer ${privateKey}`,
150
+ "Content-Type": "application/json",
151
+ },
152
+ body: JSON.stringify({ assetId, name: filename }),
111
153
  },
112
154
  );
113
- await assertOk(step3Res, "Builder.io upload complete failed");
114
-
115
- const { url, id } = (await step3Res.json()) as { url?: string; id?: string };
116
- if (!url) throw new Error("Builder.io upload/complete returned no URL");
117
-
118
- console.log(`[builder-upload] done [${assetId}]: ${url}`);
119
- return { url, id, provider: "builder" };
155
+ await assertOk(res, "Builder.io upload complete failed");
156
+ const json = (await res.json()) as { url?: string; id?: string };
157
+ if (!json.url) throw new Error("Builder.io upload/complete returned no URL");
158
+ return { url: json.url, id: json.id };
120
159
  }
121
160
 
122
161
  // Retry transient 5xx once with backoff. Builder.io's upload service
@@ -221,4 +260,127 @@ export const builderFileUploadProvider: FileUploadProvider = {
221
260
  console.log(`[builder-upload] done: ${json.url}`);
222
261
  return { url: json.url, id: json.id, provider: "builder" };
223
262
  },
263
+
264
+ resumable: {
265
+ async startSession(filename, mimeType, maxBytes) {
266
+ const { resolveBuilderPrivateKey } =
267
+ await import("../server/credential-provider.js");
268
+ const privateKey = await resolveBuilderPrivateKey();
269
+ if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
270
+
271
+ console.log(
272
+ `[builder-resumable] starting session: ${filename} ${mimeType} ${maxBytes} bytes`,
273
+ );
274
+ const { uploadUrl, assetId, requiredHeaders } =
275
+ await requestBuilderSignedUrl(
276
+ privateKey,
277
+ filename,
278
+ mimeType,
279
+ maxBytes,
280
+ true,
281
+ );
282
+ console.log(`[builder-resumable] session step 1 ok: assetId=${assetId}`);
283
+
284
+ const initHeaders: Record<string, string> = {
285
+ "Content-Type": mimeType,
286
+ "x-goog-resumable": "start",
287
+ };
288
+ const contentLengthRange =
289
+ requiredHeaders?.["x-goog-content-length-range"];
290
+ if (contentLengthRange)
291
+ initHeaders["x-goog-content-length-range"] = contentLengthRange;
292
+
293
+ console.log(`[builder-resumable] session step 2: initiating GCS session`);
294
+ const initRes = await fetchWithTimeout(uploadUrl, {
295
+ method: "POST",
296
+ headers: initHeaders,
297
+ body: new Uint8Array(0),
298
+ });
299
+ if (!initRes.ok) {
300
+ const body = await initRes.text().catch(() => "");
301
+ throw new Error(
302
+ `GCS resumable session initiation failed (${initRes.status}): ${body}`,
303
+ );
304
+ }
305
+ const sessionUri = initRes.headers.get("location");
306
+ if (!sessionUri)
307
+ throw new Error(
308
+ "GCS did not return a Location header for the resumable session",
309
+ );
310
+
311
+ console.log(`[builder-resumable] session ready: assetId=${assetId}`);
312
+ return {
313
+ sessionId: sessionUri,
314
+ meta: { assetId, filename, mimeType },
315
+ } satisfies ResumableUploadSession;
316
+ },
317
+
318
+ async relayChunk(session, contentRange, bytes, options) {
319
+ const sessionUri = session.sessionId;
320
+ const MAX_ATTEMPTS = 4;
321
+ const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
322
+ const delayMs = (attempt: number) =>
323
+ Math.min(2000, 300 * 2 ** (attempt - 1));
324
+
325
+ let lastError: unknown = null;
326
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
327
+ try {
328
+ const headers: Record<string, string> = {
329
+ "Content-Range": contentRange,
330
+ };
331
+ if (options?.mimeType) headers["Content-Type"] = options.mimeType;
332
+ const res = await fetch(sessionUri, {
333
+ method: "PUT",
334
+ headers,
335
+ body: bytes as unknown as BodyInit,
336
+ });
337
+ if (res.status === 308 || res.ok)
338
+ return {
339
+ ok: true,
340
+ status: res.status,
341
+ } satisfies ResumableChunkResult;
342
+ if (RETRYABLE.has(res.status) && attempt < MAX_ATTEMPTS) {
343
+ await res.text().catch(() => "");
344
+ console.warn(
345
+ `[builder-resumable] transient ${res.status} on attempt ${attempt}, retrying`,
346
+ );
347
+ await new Promise((r) => setTimeout(r, delayMs(attempt)));
348
+ continue;
349
+ }
350
+ return {
351
+ ok: false,
352
+ status: res.status,
353
+ } satisfies ResumableChunkResult;
354
+ } catch (err) {
355
+ lastError = err;
356
+ if (attempt >= MAX_ATTEMPTS) break;
357
+ console.warn(
358
+ `[builder-resumable] network error on attempt ${attempt}:`,
359
+ err instanceof Error ? err.message : String(err),
360
+ );
361
+ await new Promise((r) => setTimeout(r, delayMs(attempt)));
362
+ }
363
+ }
364
+ throw lastError instanceof Error
365
+ ? lastError
366
+ : new Error("GCS PUT failed after retries");
367
+ },
368
+
369
+ async completeSession(session, filename) {
370
+ const { resolveBuilderPrivateKey } =
371
+ await import("../server/credential-provider.js");
372
+ const privateKey = await resolveBuilderPrivateKey();
373
+ if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");
374
+
375
+ const assetId = session.meta.assetId as string;
376
+ console.log(`[builder-resumable] completing upload: assetId=${assetId}`);
377
+ const { url } = await completeBuilderUpload(
378
+ privateKey,
379
+ assetId,
380
+ filename,
381
+ );
382
+ console.log(`[builder-resumable] upload complete: ${url}`);
383
+ return url;
384
+ },
385
+ },
224
386
  };
@@ -2,6 +2,8 @@ export type {
2
2
  FileUploadInput,
3
3
  FileUploadProvider,
4
4
  FileUploadResult,
5
+ ResumableUploadSession,
6
+ ResumableChunkResult,
5
7
  } from "./types.js";
6
8
  export {
7
9
  registerFileUploadProvider,
@@ -27,6 +27,22 @@ export interface FileUploadResult {
27
27
  provider: string;
28
28
  }
29
29
 
30
+ /** Opaque session handle returned by {@link FileUploadProvider.resumable.startSession}.
31
+ * `sessionId` is provider-specific (GCS Location URI, S3 UploadId, etc.).
32
+ * `meta` holds any provider state needed for subsequent relay and complete calls. */
33
+ export interface ResumableUploadSession {
34
+ sessionId: string;
35
+ meta: Record<string, unknown>;
36
+ }
37
+
38
+ export interface ResumableChunkResult {
39
+ ok: boolean;
40
+ status: number;
41
+ /** Providers that need per-chunk state (e.g. S3 ETags) return updated meta
42
+ * here; the chunk route merges it back into the stored session. */
43
+ updatedMeta?: Record<string, unknown>;
44
+ }
45
+
30
46
  export interface FileUploadProvider {
31
47
  /** Unique id, e.g. "builder", "s3". */
32
48
  id: string;
@@ -41,4 +57,26 @@ export interface FileUploadProvider {
41
57
  isConfiguredForRequest?: () => Promise<boolean>;
42
58
  /** Upload a file and return a URL. Throw on failure. */
43
59
  upload: (input: FileUploadInput) => Promise<FileUploadResult>;
60
+ /**
61
+ * Optional resumable/streaming upload capability.
62
+ * When present, create-recording will initialise a session and stream chunks
63
+ * during recording instead of assembling the full blob after stop().
64
+ */
65
+ resumable?: {
66
+ startSession(
67
+ filename: string,
68
+ mimeType: string,
69
+ maxBytes: number,
70
+ ): Promise<ResumableUploadSession>;
71
+ relayChunk(
72
+ session: ResumableUploadSession,
73
+ contentRange: string,
74
+ bytes: Uint8Array,
75
+ options?: { mimeType?: string },
76
+ ): Promise<ResumableChunkResult>;
77
+ completeSession(
78
+ session: ResumableUploadSession,
79
+ filename: string,
80
+ ): Promise<string>;
81
+ };
44
82
  }
@@ -11,6 +11,9 @@
11
11
 
12
12
  import { defineAction } from "@agent-native/core";
13
13
  import { writeAppState } from "@agent-native/core/application-state";
14
+ import { getActiveFileUploadProviderForRequest } from "@agent-native/core/file-upload";
15
+ import type { UploadMode } from "@shared/recording-core.js";
16
+ import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
14
17
 
15
18
  import { getDb, schema } from "../server/db/index.js";
16
19
  import {
@@ -19,6 +22,7 @@ import {
19
22
  requireOrganizationAccess,
20
23
  stringifySpaceIds,
21
24
  } from "../server/lib/recordings.js";
25
+ import { setResumableSession } from "../server/lib/resumable-session.js";
22
26
  import { createRecordingSchema } from "./lib/create-recording-schema.js";
23
27
  import { DEFAULT_RECORDING_TITLE } from "./lib/title-source.js";
24
28
 
@@ -76,6 +80,45 @@ export default defineAction({
76
80
 
77
81
  console.log(`Created recording "${title}" (${id})`);
78
82
 
83
+ // Initialize a resumable upload session so chunks are streamed to the
84
+ // provider during recording (no post-stop assembly). Falls back gracefully
85
+ // to the SQL chunk path when no provider supports resumable uploads or the
86
+ // init fails.
87
+ let uploadMode: UploadMode = "buffered";
88
+ const uploadProvider = await getActiveFileUploadProviderForRequest();
89
+ if (args.requestStreaming && uploadProvider?.resumable) {
90
+ try {
91
+ const recordingMimeType =
92
+ args.mimeType?.split(";")[0]?.trim() || "video/webm";
93
+ const ext = /mp4|quicktime/i.test(recordingMimeType) ? "mp4" : "webm";
94
+ const filename = `${id}.${ext}`;
95
+ console.log(
96
+ `[create-recording] starting resumable session: provider=${uploadProvider.id} mimeType=${recordingMimeType}`,
97
+ );
98
+ const session = await uploadProvider.resumable.startSession(
99
+ filename,
100
+ recordingMimeType,
101
+ MAX_UPLOAD_BYTES,
102
+ );
103
+ await setResumableSession(id, {
104
+ providerId: uploadProvider.id,
105
+ sessionId: session.sessionId,
106
+ meta: session.meta,
107
+ bytesUploaded: 0,
108
+ lastCommittedIndex: -1,
109
+ });
110
+ uploadMode = "streaming";
111
+ console.log(
112
+ `[create-recording] resumable session ready for ${id}: provider=${uploadProvider.id}`,
113
+ );
114
+ } catch (err) {
115
+ console.warn(
116
+ `[create-recording] resumable session init failed, falling back to buffered:`,
117
+ err instanceof Error ? err.message : String(err),
118
+ );
119
+ }
120
+ }
121
+
79
122
  return {
80
123
  id,
81
124
  organizationId,
@@ -84,6 +127,7 @@ export default defineAction({
84
127
  abortUrl: `/api/uploads/${id}/abort`,
85
128
  // Frontend substitutes {index}/{total}/{isFinal}
86
129
  uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`,
130
+ uploadMode,
87
131
  };
88
132
  },
89
133
  });