@agent-native/core 0.81.3 → 0.83.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 (92) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/CommandMenu.tsx +34 -12
  5. package/corpus/core/src/file-upload/builder.ts +199 -37
  6. package/corpus/core/src/file-upload/index.ts +2 -0
  7. package/corpus/core/src/file-upload/types.ts +38 -0
  8. package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
  9. package/corpus/templates/clips/actions/create-recording.ts +44 -0
  10. package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
  11. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
  12. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
  13. package/corpus/templates/clips/app/routes/record.tsx +12 -1
  14. package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
  15. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
  16. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
  17. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
  18. package/corpus/templates/clips/shared/recording-core.ts +5 -0
  19. package/corpus/templates/content/.env.local.example +6 -0
  20. package/corpus/templates/content/AGENTS.md +10 -1
  21. package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
  22. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
  23. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
  24. package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
  25. package/corpus/templates/content/actions/_database-utils.ts +41 -0
  26. package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
  27. package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
  28. package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
  29. package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
  30. package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
  31. package/corpus/templates/content/actions/list-content-databases.ts +41 -33
  32. package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
  33. package/corpus/templates/content/actions/update-document.ts +93 -0
  34. package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
  35. package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
  36. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
  37. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
  38. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
  39. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
  40. package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
  41. package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
  42. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
  43. package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
  44. package/corpus/templates/content/app/global.css +10 -3
  45. package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
  46. package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
  47. package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
  48. package/corpus/templates/content/app/i18n-data.ts +212 -0
  49. package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
  50. package/corpus/templates/content/app/root.tsx +236 -8
  51. package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
  52. package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
  53. package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
  54. package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
  55. package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
  56. package/corpus/templates/content/package.json +3 -1
  57. package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
  58. package/corpus/templates/content/scripts/dev-database.mjs +83 -0
  59. package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
  60. package/corpus/templates/content/server/db/schema.ts +27 -0
  61. package/corpus/templates/content/server/plugins/db.ts +33 -0
  62. package/corpus/templates/content/shared/api.ts +45 -0
  63. package/corpus/templates/content/shared/builder-mdx.ts +905 -5
  64. package/corpus/templates/content/shared/nfm-registry.ts +2 -0
  65. package/corpus/templates/content/shared/source-component-block.ts +141 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
  67. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
  68. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
  69. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
  70. package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
  71. package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
  72. package/dist/client/CommandMenu.d.ts +6 -2
  73. package/dist/client/CommandMenu.d.ts.map +1 -1
  74. package/dist/client/CommandMenu.js +25 -14
  75. package/dist/client/CommandMenu.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/routes.d.ts +1 -1
  79. package/dist/file-upload/builder.d.ts.map +1 -1
  80. package/dist/file-upload/builder.js +137 -25
  81. package/dist/file-upload/builder.js.map +1 -1
  82. package/dist/file-upload/index.d.ts +1 -1
  83. package/dist/file-upload/index.d.ts.map +1 -1
  84. package/dist/file-upload/index.js.map +1 -1
  85. package/dist/file-upload/types.d.ts +26 -0
  86. package/dist/file-upload/types.d.ts.map +1 -1
  87. package/dist/file-upload/types.js.map +1 -1
  88. package/dist/notifications/routes.d.ts +3 -3
  89. package/dist/progress/routes.d.ts +1 -1
  90. package/dist/resources/handlers.d.ts +3 -3
  91. package/dist/server/transcribe-voice.d.ts +1 -1
  92. 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: 4820
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.83.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 1a8d939: Add dynamic command menu results and an opt-in contenteditable Cmd+K shortcut path for editor-backed apps.
8
+
9
+ ## 0.82.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 0.81.3
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.81.3",
3
+ "version": "0.83.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": {
@@ -261,6 +261,8 @@ export interface CommandMenuProps {
261
261
  open: boolean;
262
262
  onOpenChange: (open: boolean) => void;
263
263
  children: ReactNode;
264
+ /** Render app-specific dynamic results from the current search value. */
265
+ renderResults?: (search: string) => ReactNode;
264
266
  /** Placeholder text for the search input */
265
267
  placeholder?: string;
266
268
  /** Text shown when no results match (before showing agent fallback) */
@@ -291,6 +293,7 @@ export function CommandMenu({
291
293
  open,
292
294
  onOpenChange,
293
295
  children,
296
+ renderResults,
294
297
  placeholder = "Type a command or ask AI...",
295
298
  emptyText: _emptyText = "No commands found.",
296
299
  showAgentFallback = true,
@@ -325,8 +328,15 @@ export function CommandMenu({
325
328
  setTimeout(() => setChangelogOpen(true), 50);
326
329
  }, [onOpenChange, markChangelogSeen]);
327
330
 
328
- // Focus input when opening
331
+ // Focus input when opening; clear search while closed so reopen never renders
332
+ // dynamic results for the previous query.
329
333
  useEffect(() => {
334
+ if (!open) {
335
+ setSearch("");
336
+ setSelectedIndex(0);
337
+ return;
338
+ }
339
+
330
340
  if (open) {
331
341
  setSearch("");
332
342
  setSelectedIndex(0);
@@ -497,6 +507,8 @@ export function CommandMenu({
497
507
  React.isValidElement(child) &&
498
508
  (child.type === CommandGroup || child.type === CommandDocsGroup),
499
509
  );
510
+ const dynamicResults = open ? renderResults?.(search) : null;
511
+ const hasDynamicResults = Boolean(dynamicResults);
500
512
 
501
513
  return (
502
514
  <>
@@ -528,6 +540,7 @@ export function CommandMenu({
528
540
 
529
541
  {/* Command list */}
530
542
  <div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
543
+ {dynamicResults}
531
544
  {hasResults && filteredChildren}
532
545
 
533
546
  {/* What's new — built-in changelog entry */}
@@ -567,7 +580,9 @@ export function CommandMenu({
567
580
  {/* Ask AI — always visible at the bottom */}
568
581
  {showAgentFallback && (
569
582
  <>
570
- {(hasResults || showChangelogRow) && <CommandSeparator />}
583
+ {(hasResults || showChangelogRow || hasDynamicResults) && (
584
+ <CommandSeparator />
585
+ )}
571
586
  <div className="p-1">
572
587
  <div
573
588
  className="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-2 text-sm outline-none"
@@ -658,16 +673,21 @@ CommandMenu.Separator = CommandSeparator;
658
673
  /**
659
674
  * Hook to handle Cmd+K (or Ctrl+K) to open the command menu
660
675
  */
661
- export function useCommandMenuShortcut(onOpen: () => void) {
676
+ export function useCommandMenuShortcut(
677
+ onOpen: () => void,
678
+ options: { allowContentEditable?: boolean } = {},
679
+ ) {
662
680
  useEffect(() => {
663
681
  const handleKeyDown = (e: KeyboardEvent) => {
664
- if ((e.metaKey || e.ctrlKey) && e.key === "k") {
665
- // Don't trigger if user is typing in an input/textarea
666
- const target = e.target as HTMLElement;
682
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
683
+ // Don't trigger if user is typing in a native form control.
684
+ const target = e.target instanceof HTMLElement ? e.target : null;
685
+ const isContentEditable = target?.isContentEditable;
667
686
  if (
668
- target.tagName === "INPUT" ||
669
- target.tagName === "TEXTAREA" ||
670
- target.isContentEditable
687
+ target?.tagName === "INPUT" ||
688
+ target?.tagName === "TEXTAREA" ||
689
+ target?.tagName === "SELECT" ||
690
+ (!options.allowContentEditable && isContentEditable)
671
691
  ) {
672
692
  return;
673
693
  }
@@ -675,9 +695,11 @@ export function useCommandMenuShortcut(onOpen: () => void) {
675
695
  onOpen();
676
696
  }
677
697
  };
678
- document.addEventListener("keydown", handleKeyDown);
679
- return () => document.removeEventListener("keydown", handleKeyDown);
680
- }, [onOpen]);
698
+ const useCapture = Boolean(options.allowContentEditable);
699
+ document.addEventListener("keydown", handleKeyDown, useCapture);
700
+ return () =>
701
+ document.removeEventListener("keydown", handleKeyDown, useCapture);
702
+ }, [onOpen, options.allowContentEditable]);
681
703
  }
682
704
 
683
705
  export type {
@@ -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
  }
@@ -264,6 +264,12 @@ function hasStructuredFilters(filters: ReturnType<typeof buildFilterSummary>) {
264
264
  function buildGuidance(options: {
265
265
  query: string | undefined;
266
266
  structuredFilters: boolean;
267
+ truncated: boolean;
268
+ hasMore: boolean;
269
+ total: number;
270
+ returned: number;
271
+ offset: number;
272
+ limit: number;
267
273
  }) {
268
274
  const guidance: string[] = [];
269
275
 
@@ -285,6 +291,15 @@ function buildGuidance(options: {
285
291
  );
286
292
  }
287
293
 
294
+ if (options.truncated) {
295
+ const more = options.hasMore
296
+ ? `Fetch the next page with offset ${options.offset + options.returned}.`
297
+ : "This is the last page of the cohort.";
298
+ guidance.push(
299
+ `Returned ${options.returned} of ${options.total} matching deals (limit ${options.limit}, offset ${options.offset}). This is a partial slice — do NOT treat it as the full cohort. Use total for counts/aggregates. ${more} Narrow the filters or, for exhaustive cohort analysis, use provider-api-request with provider = hubspot and stageAs.`,
300
+ );
301
+ }
302
+
288
303
  return guidance.join(" ");
289
304
  }
290
305
 
@@ -293,7 +308,7 @@ export default defineAction({
293
308
  // reusable across continuation retries (no re-fetch on resume).
294
309
  readOnly: true,
295
310
  description:
296
- "Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
311
+ "Get HubSpot deals with normalized stage, pipeline, owner, forecast, and NBM fields. This is a bounded deal analytics shortcut, not the full HubSpot capability surface. Use query for a specific customer/deal/account deep dive. For cohorts like products field = Publish, closed-won, pipeline = New Business, or close date in a range, use the structured product, pipeline, closedStatus, closedDateFrom, and closedDateTo filters instead of query when the answer is the deal list itself. If the cohort feeds a cross-source join, transcript/message/ticket search, exhaustive absence check, or downstream code/corpus workflow, prefer provider-api-catalog/provider-api-request with provider = hubspot and stageAs so the cohort is available as a staged dataset. Both paths are bounded: at most limit deals are returned (default 25, max 100). The structured-filter path returns total as the true matched count and a truncated flag; page with offset (or narrow filters) instead of expecting the whole cohort in one call, since a full enriched cohort can be several MB and overruns extension and context budgets. For non-deal CRM records use hubspot-records; for arbitrary HubSpot endpoints, filters, associations, batch APIs, or payloads use provider-api-catalog/provider-api-docs/provider-api-request with provider = hubspot.",
297
312
  schema: z.object({
298
313
  properties: StringListSchema.describe(
299
314
  "Optional comma-separated extra HubSpot deal property names to include.",
@@ -344,7 +359,17 @@ export default defineAction({
344
359
  .min(1)
345
360
  .max(100)
346
361
  .default(25)
347
- .describe("Maximum records to return when query is provided."),
362
+ .describe(
363
+ "Maximum deals to return. Applies to BOTH full-text query results and structured-filter cohorts. The structured-filter path returns at most this many enriched deals (use total for the true matched count and offset to page).",
364
+ ),
365
+ offset: z.coerce
366
+ .number()
367
+ .int()
368
+ .min(0)
369
+ .default(0)
370
+ .describe(
371
+ "Number of structured-filter results to skip before returning limit deals. Use for paging through a large cohort; ignored when query is provided.",
372
+ ),
348
373
  after: z
349
374
  .string()
350
375
  .optional()
@@ -362,6 +387,7 @@ export default defineAction({
362
387
  closedDateTo,
363
388
  query,
364
389
  limit = 25,
390
+ offset = 0,
365
391
  after,
366
392
  }) => {
367
393
  const trimmedQuery = query?.trim();
@@ -414,7 +440,7 @@ export default defineAction({
414
440
  closedDateTo: closedDateTo?.trim(),
415
441
  });
416
442
  const structuredFilters = hasStructuredFilters(filters);
417
- const deals = rawDeals
443
+ const matchedDeals = rawDeals
418
444
  .filter((d) => visibleIds.has(String(d.properties.pipeline)))
419
445
  .map((deal) => enrichDeal(deal, lookups, owners))
420
446
  .filter((deal) => {
@@ -435,18 +461,52 @@ export default defineAction({
435
461
  return matchesDateRange(deal, fromMs, toMs);
436
462
  });
437
463
 
464
+ // The full-text query path is already bounded by HubSpot's own `limit`.
465
+ // EVERY non-query call (with or without structured filters) scans the whole
466
+ // visible deal set, so bound the returned cohort here. This is deliberately
467
+ // NOT gated on `structuredFilters`: the unfiltered `hubspot-deals({})` call
468
+ // is the LARGEST payload of all (every visible deal), so it must be bounded
469
+ // too — a full enriched cohort can be multiple MB, which overruns the
470
+ // extension iframe bridge and the agent context. We
471
+ // keep `total` as the true matched count and signal partial slices via
472
+ // `truncated` so callers never mistake a page for the full cohort.
473
+ // `truncated` means "this response is only part of the cohort" (returned <
474
+ // total) and stays true on the LAST page of a paginated read too; pagination
475
+ // (is there a next page) is reported separately via `hasMore` / `nextOffset`.
476
+ const matchedTotal = matchedDeals.length;
477
+ const deals = trimmedQuery
478
+ ? matchedDeals
479
+ : matchedDeals.slice(offset, offset + limit);
480
+ const truncated = !trimmedQuery && deals.length < matchedTotal;
481
+ const hasMore = !trimmedQuery && offset + deals.length < matchedTotal;
482
+
438
483
  return {
439
484
  deals,
440
485
  stageLabels: lookups.stageLabels,
441
486
  pipelineLabels: lookups.pipelineLabels,
442
- total: deals.length,
487
+ total: matchedTotal,
443
488
  count: deals.length,
444
489
  query: trimmedQuery || null,
445
490
  filters,
446
491
  nextAfter: Array.isArray(dealResult) ? null : dealResult.nextAfter,
492
+ ...(trimmedQuery
493
+ ? {}
494
+ : {
495
+ limit,
496
+ offset,
497
+ truncated,
498
+ hasMore,
499
+ nextOffset: hasMore ? offset + deals.length : null,
500
+ }),
447
501
  guidance: buildGuidance({
448
502
  query: trimmedQuery,
449
503
  structuredFilters,
504
+ truncated,
505
+ hasMore,
506
+ total: matchedTotal,
507
+ returned: deals.length,
508
+ offset,
509
+ limit,
450
510
  }),
451
511
  ...(Array.isArray(dealResult)
452
512
  ? {}
@@ -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
  });