@agent-native/core 0.101.3 → 0.101.5

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 (79) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/action.ts +15 -0
  5. package/corpus/core/src/agent/production-agent.ts +215 -19
  6. package/corpus/core/src/client/RunStuckBanner.tsx +12 -5
  7. package/corpus/core/src/client/sse-event-processor.ts +36 -1
  8. package/corpus/core/src/client/use-run-stuck-detection.ts +162 -25
  9. package/corpus/core/src/coding-tools/run-code.ts +11 -5
  10. package/corpus/core/src/deploy/build.ts +78 -25
  11. package/corpus/core/src/extensions/actions.ts +15 -0
  12. package/corpus/core/src/extensions/url-safety.ts +11 -1
  13. package/corpus/core/src/notifications/channels.ts +61 -30
  14. package/corpus/core/src/server/action-discovery.ts +4 -1
  15. package/corpus/core/src/server/agent-chat/context-tools.ts +4 -0
  16. package/corpus/templates/chat/changelog/2026-07-14-chat-opens-reliably-on-hosted-deployments-instead-of-failing.md +6 -0
  17. package/corpus/templates/clips/app/components/player/media-duration.ts +24 -0
  18. package/corpus/templates/clips/app/components/player/video-player.tsx +9 -18
  19. package/corpus/templates/clips/changelog/2026-07-14-paused-time-no-longer-counts-toward-chrome-extension-recordi.md +6 -0
  20. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +30 -4
  21. package/corpus/templates/clips/chrome-extension/src/recording-duration.ts +32 -0
  22. package/corpus/templates/slides/actions/_uploaded-files.ts +13 -2
  23. package/corpus/templates/slides/actions/import-docx.ts +3 -9
  24. package/corpus/templates/slides/actions/import-file.ts +11 -14
  25. package/corpus/templates/slides/actions/import-pptx.ts +3 -9
  26. package/corpus/templates/slides/changelog/2026-07-14-powerpoint-template-uploads-now-work-in-hosted-slides-deploy.md +6 -0
  27. package/corpus/templates/slides/server/handlers/uploads.ts +100 -50
  28. package/corpus/templates/slides/server/lib/tenant-files.ts +38 -11
  29. package/corpus/templates/slides/server/lib/uploaded-reference-storage.ts +108 -0
  30. package/dist/action.d.ts +12 -0
  31. package/dist/action.d.ts.map +1 -1
  32. package/dist/action.js +2 -0
  33. package/dist/action.js.map +1 -1
  34. package/dist/agent/production-agent.d.ts +24 -0
  35. package/dist/agent/production-agent.d.ts.map +1 -1
  36. package/dist/agent/production-agent.js +175 -18
  37. package/dist/agent/production-agent.js.map +1 -1
  38. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  39. package/dist/client/RunStuckBanner.js +9 -5
  40. package/dist/client/RunStuckBanner.js.map +1 -1
  41. package/dist/client/sse-event-processor.d.ts.map +1 -1
  42. package/dist/client/sse-event-processor.js +33 -1
  43. package/dist/client/sse-event-processor.js.map +1 -1
  44. package/dist/client/use-run-stuck-detection.d.ts +4 -4
  45. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  46. package/dist/client/use-run-stuck-detection.js +104 -19
  47. package/dist/client/use-run-stuck-detection.js.map +1 -1
  48. package/dist/coding-tools/run-code.d.ts +5 -2
  49. package/dist/coding-tools/run-code.d.ts.map +1 -1
  50. package/dist/coding-tools/run-code.js +11 -5
  51. package/dist/coding-tools/run-code.js.map +1 -1
  52. package/dist/collab/awareness.d.ts +2 -2
  53. package/dist/collab/awareness.d.ts.map +1 -1
  54. package/dist/collab/struct-routes.d.ts +1 -1
  55. package/dist/deploy/build.d.ts +15 -14
  56. package/dist/deploy/build.d.ts.map +1 -1
  57. package/dist/deploy/build.js +64 -23
  58. package/dist/deploy/build.js.map +1 -1
  59. package/dist/extensions/actions.d.ts.map +1 -1
  60. package/dist/extensions/actions.js +14 -0
  61. package/dist/extensions/actions.js.map +1 -1
  62. package/dist/extensions/url-safety.d.ts +6 -0
  63. package/dist/extensions/url-safety.d.ts.map +1 -1
  64. package/dist/extensions/url-safety.js +6 -0
  65. package/dist/extensions/url-safety.js.map +1 -1
  66. package/dist/notifications/channels.d.ts.map +1 -1
  67. package/dist/notifications/channels.js +38 -22
  68. package/dist/notifications/channels.js.map +1 -1
  69. package/dist/notifications/routes.d.ts +3 -3
  70. package/dist/observability/routes.d.ts +1 -1
  71. package/dist/progress/routes.d.ts +1 -1
  72. package/dist/resources/handlers.d.ts +3 -3
  73. package/dist/server/action-discovery.d.ts.map +1 -1
  74. package/dist/server/action-discovery.js +4 -1
  75. package/dist/server/action-discovery.js.map +1 -1
  76. package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
  77. package/dist/server/agent-chat/context-tools.js +4 -0
  78. package/dist/server/agent-chat/context-tools.js.map +1 -1
  79. package/package.json +3 -3
@@ -192,6 +192,9 @@ function preserveActionFlags(entry: Record<string, any>): Partial<ActionEntry> {
192
192
  if (typeof entry.parallelSafe === "boolean") {
193
193
  out.parallelSafe = entry.parallelSafe;
194
194
  }
195
+ if (typeof entry.dedupe === "boolean") {
196
+ out.dedupe = entry.dedupe;
197
+ }
195
198
  if (typeof entry.toolCallable === "boolean") {
196
199
  out.toolCallable = entry.toolCallable;
197
200
  }
@@ -716,7 +719,7 @@ export async function mergeCoreSharingActions(
716
719
  run: def.run,
717
720
  ...(def.http !== undefined ? { http: def.http } : {}),
718
721
  // Carry security-relevant flags (toolCallable, publicAgent, link,
719
- // mcpApp) plus readOnly/parallelSafe. Without this, the sharing
722
+ // mcpApp) plus readOnly/parallelSafe/dedupe. Without this, the sharing
720
723
  // actions' `toolCallable: false` (audit-H5) is dropped and the
721
724
  // tools-iframe bridge 403 in action-routes.ts never fires.
722
725
  ...preserveActionFlags(def),
@@ -241,6 +241,10 @@ export function createRefreshScreenEntry(): Record<string, ActionEntry> {
241
241
  // distinct `screen-refresh` poll event. Don't double-emit a generic
242
242
  // `action` event on top of that.
243
243
  readOnly: true,
244
+ // Refetching volatile on-screen state is the entire point of this
245
+ // tool — an identical repeat call (even with the same scope) is a
246
+ // legitimate re-refresh, not a redundant read to skip.
247
+ dedupe: false,
244
248
  tool: {
245
249
  description:
246
250
  "Manually refresh the user's current screen. The framework ALREADY auto-refreshes after any successful mutating action tool call (template actions and any enabled raw DB write tools) — you do NOT need to call this after a normal action. Use it only when (a) you mutated data via a path the framework can't detect (e.g. a direct write to an external system the app mirrors), or (b) you want to pass a `scope` hint so the UI narrows which queries to refetch. The UI re-fetches its queries without a full page reload.",
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ Chat opens reliably on hosted deployments instead of failing during startup
@@ -0,0 +1,24 @@
1
+ const MEDIARECORDER_DURATION_DRIFT_MS = 3_000;
2
+
3
+ function finitePositiveMs(value: number): number {
4
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
5
+ }
6
+
7
+ /**
8
+ * Prefer recorder metadata for normal MediaRecorder timeslice drift, but use
9
+ * the playable media's duration when the two values clearly describe
10
+ * different timelines (for example, metadata that accidentally counted a
11
+ * long pause).
12
+ */
13
+ export function resolveMediaDurationMs(
14
+ recordedDurationMs: number,
15
+ mediaDurationSeconds: number,
16
+ ): number {
17
+ const recordedMs = finitePositiveMs(recordedDurationMs);
18
+ const mediaMs = finitePositiveMs(mediaDurationSeconds * 1000);
19
+ if (mediaMs === 0) return recordedMs;
20
+ if (recordedMs === 0) return mediaMs;
21
+ return Math.abs(recordedMs - mediaMs) > MEDIARECORDER_DURATION_DRIFT_MS
22
+ ? mediaMs
23
+ : recordedMs;
24
+ }
@@ -19,6 +19,7 @@ import {
19
19
  useState,
20
20
  } from "react";
21
21
 
22
+ import { resolveMediaDurationMs } from "@/components/player/media-duration";
22
23
  import {
23
24
  DropdownMenu,
24
25
  DropdownMenuContent,
@@ -824,22 +825,16 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
824
825
  durationProbedRef.current = false;
825
826
  }, [activeVideoSrc, durationMs]);
826
827
 
827
- // The recorder's elapsed-time counter (durationMs prop) is the most
828
- // trustworthy length we have. A MediaRecorder WebM's own duration is
829
- // cluster-estimated and lands short by up to one timeslice, so we never
830
- // let it overwrite a real prop — doing so makes the scrubber jump to a
831
- // shorter length than the actual recording on first watch.
832
- const hasReliableDurationProp =
833
- Number.isFinite(durationMs) && durationMs > 0;
834
-
828
+ // Prefer the recorder's elapsed-time counter for ordinary WebM timeslice
829
+ // drift, but let clearly different playable-media metadata win. This also
830
+ // repairs playback controls for older clips whose stored duration counted
831
+ // time spent paused.
835
832
  const probeDurationIfNeeded = useCallback(
836
833
  (v: HTMLVideoElement) => {
837
834
  if (durationProbedRef.current) return;
838
835
  if (Number.isFinite(v.duration) && v.duration > 0) {
839
836
  durationProbedRef.current = true;
840
- if (!hasReliableDurationProp) {
841
- setResolvedDurationMs(Math.round(v.duration * 1000));
842
- }
837
+ setResolvedDurationMs(resolveMediaDurationMs(durationMs, v.duration));
843
838
  return;
844
839
  }
845
840
  if (playAttemptPendingRef.current || !v.paused) return;
@@ -855,7 +850,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
855
850
  // picks up the real duration.
856
851
  }
857
852
  },
858
- [hasReliableDurationProp],
853
+ [durationMs],
859
854
  );
860
855
 
861
856
  // Resolve the WebM-duration-is-Infinity Chrome quirk: when a video created
@@ -873,11 +868,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
873
868
 
874
869
  const onDurationChange = () => {
875
870
  if (Number.isFinite(v.duration) && v.duration > 0) {
876
- // Don't downgrade a trustworthy recorder duration to the
877
- // cluster-estimated WebM duration; only adopt it as a fallback.
878
- if (!hasReliableDurationProp) {
879
- setResolvedDurationMs(Math.round(v.duration * 1000));
880
- }
871
+ setResolvedDurationMs(resolveMediaDurationMs(durationMs, v.duration));
881
872
  // After we've resolved the real duration, rewind back to 0 so the
882
873
  // user isn't sitting at the end of the clip.
883
874
  if (durationProbedRef.current && v.currentTime > v.duration) {
@@ -900,7 +891,7 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
900
891
  v.removeEventListener("loadedmetadata", onLoadedMetadata);
901
892
  v.removeEventListener("durationchange", onDurationChange);
902
893
  };
903
- }, [activeVideoSrc, hasReliableDurationProp, probeDurationIfNeeded]);
894
+ }, [activeVideoSrc, durationMs, probeDurationIfNeeded]);
904
895
 
905
896
  // Reset the thumbnail-capture flag when the source changes (e.g. the
906
897
  // player is reused for a different recording via React Router).
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ Paused time no longer counts toward Chrome extension recording durations.
@@ -36,6 +36,13 @@ import {
36
36
  import { MAX_UPLOAD_BYTES } from "@shared/upload-limits";
37
37
 
38
38
  import { waitForReadyRecordingAfterFinalizeError } from "./finalize-recovery";
39
+ import {
40
+ createRecordingDurationState,
41
+ pauseRecordingDuration,
42
+ resumeRecordingDuration,
43
+ startRecordingDuration,
44
+ type RecordingDurationState,
45
+ } from "./recording-duration";
39
46
  import { captureExtensionError, initExtensionSentry } from "./sentry";
40
47
 
41
48
  initExtensionSentry("offscreen");
@@ -187,6 +194,7 @@ type ActiveRecording = {
187
194
  authToken: string | null;
188
195
  mode: CaptureMode;
189
196
  startedAtMs: number;
197
+ duration: RecordingDurationState;
190
198
  mimeType: string;
191
199
  recorder: MediaRecorder;
192
200
  outputStream: MediaStream;
@@ -1218,6 +1226,7 @@ async function begin(message: BeginMessage): Promise<{
1218
1226
  authToken: message.authToken ?? null,
1219
1227
  mode: ready.mode,
1220
1228
  startedAtMs: 0,
1229
+ duration: createRecordingDurationState(),
1221
1230
  mimeType,
1222
1231
  recorder,
1223
1232
  outputStream,
@@ -1350,6 +1359,7 @@ function startRecorderNow(recording: ActiveRecording): void {
1350
1359
  playStartChime();
1351
1360
  recording.recorder.start(2000);
1352
1361
  recording.startedAtMs = Date.now();
1362
+ recording.duration = startRecordingDuration(recording.startedAtMs);
1353
1363
  console.log("[clips-offscreen] recorder.start ok");
1354
1364
  reportStatus(recording.sessionId, "recording", {
1355
1365
  recordingId: recording.recordingId,
@@ -1446,6 +1456,11 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
1446
1456
  recording.resolveStopped({ ok: true, status: "cancelled" });
1447
1457
  return;
1448
1458
  }
1459
+ // Freeze the media duration as soon as MediaRecorder stops. Waiting for
1460
+ // outstanding uploads below must not make the clip appear longer, and time
1461
+ // spent paused is excluded by pauseRecordingDuration().
1462
+ recording.duration = pauseRecordingDuration(recording.duration, Date.now());
1463
+ const durationMs = recording.duration.elapsedMs;
1449
1464
  reportStatus(recording.sessionId, "uploading", {
1450
1465
  recordingId: recording.recordingId,
1451
1466
  });
@@ -1460,7 +1475,6 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
1460
1475
  ? rejected.reason
1461
1476
  : new Error(String(rejected.reason));
1462
1477
  }
1463
- const durationMs = Math.max(0, Date.now() - recording.startedAtMs);
1464
1478
  // Surface WHY a finalize might fail before the server's cryptic "No chunks
1465
1479
  // found": 0 chunks means the recorder emitted no non-empty data (empty
1466
1480
  // capture / never started), which is a different problem than an auth 401.
@@ -1549,7 +1563,7 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
1549
1563
  recordingId: recording.recordingId,
1550
1564
  chunkCount: recording.chunkIndex,
1551
1565
  mimeType: recording.mimeType,
1552
- durationMs: Math.max(0, Date.now() - recording.startedAtMs),
1566
+ durationMs,
1553
1567
  },
1554
1568
  });
1555
1569
  // The upload failed — save the buffered recording to disk so it isn't lost.
@@ -1574,7 +1588,13 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
1574
1588
  function pause(message: SimpleMessage): { ok: boolean } {
1575
1589
  const recording = activeRecording;
1576
1590
  if (recording && recording.sessionId === message.sessionId) {
1577
- if (recording.recorder.state === "recording") recording.recorder.pause();
1591
+ if (recording.recorder.state === "recording") {
1592
+ recording.recorder.pause();
1593
+ recording.duration = pauseRecordingDuration(
1594
+ recording.duration,
1595
+ Date.now(),
1596
+ );
1597
+ }
1578
1598
  reportStatus(recording.sessionId, "paused", {
1579
1599
  recordingId: recording.recordingId,
1580
1600
  });
@@ -1585,7 +1605,13 @@ function pause(message: SimpleMessage): { ok: boolean } {
1585
1605
  function resume(message: SimpleMessage): { ok: boolean } {
1586
1606
  const recording = activeRecording;
1587
1607
  if (recording && recording.sessionId === message.sessionId) {
1588
- if (recording.recorder.state === "paused") recording.recorder.resume();
1608
+ if (recording.recorder.state === "paused") {
1609
+ recording.recorder.resume();
1610
+ recording.duration = resumeRecordingDuration(
1611
+ recording.duration,
1612
+ Date.now(),
1613
+ );
1614
+ }
1589
1615
  reportStatus(recording.sessionId, "recording", {
1590
1616
  recordingId: recording.recordingId,
1591
1617
  });
@@ -0,0 +1,32 @@
1
+ export type RecordingDurationState = {
2
+ elapsedMs: number;
3
+ activeSinceMs: number | null;
4
+ };
5
+
6
+ export function createRecordingDurationState(): RecordingDurationState {
7
+ return { elapsedMs: 0, activeSinceMs: null };
8
+ }
9
+
10
+ export function startRecordingDuration(nowMs: number): RecordingDurationState {
11
+ return { elapsedMs: 0, activeSinceMs: nowMs };
12
+ }
13
+
14
+ export function pauseRecordingDuration(
15
+ state: RecordingDurationState,
16
+ nowMs: number,
17
+ ): RecordingDurationState {
18
+ if (state.activeSinceMs === null) return state;
19
+ return {
20
+ elapsedMs:
21
+ state.elapsedMs + Math.max(0, Math.round(nowMs - state.activeSinceMs)),
22
+ activeSinceMs: null,
23
+ };
24
+ }
25
+
26
+ export function resumeRecordingDuration(
27
+ state: RecordingDurationState,
28
+ nowMs: number,
29
+ ): RecordingDurationState {
30
+ if (state.activeSinceMs !== null) return state;
31
+ return { ...state, activeSinceMs: nowMs };
32
+ }
@@ -4,11 +4,19 @@ import path from "path";
4
4
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
5
5
 
6
6
  import { tenantUploadDir } from "../server/lib/tenant-files.js";
7
+ import { readUploadedReferenceBlob } from "../server/lib/uploaded-reference-storage.js";
7
8
 
8
- export function resolveUserUploadedFile(filePath: string): string {
9
+ export async function readUserUploadedFile(
10
+ filePath: string,
11
+ ): Promise<{ data: Buffer; filename: string }> {
9
12
  const email = getRequestUserEmail();
10
13
  if (!email) throw new Error("no authenticated user");
11
14
 
15
+ const privateUpload = await readUploadedReferenceBlob(filePath, email);
16
+ if (privateUpload) {
17
+ return privateUpload;
18
+ }
19
+
12
20
  const allowedDir = tenantUploadDir(email);
13
21
  const absPath = path.isAbsolute(filePath)
14
22
  ? filePath
@@ -23,5 +31,8 @@ export function resolveUserUploadedFile(filePath: string): string {
23
31
  if (!fs.existsSync(resolved)) {
24
32
  throw new Error(`File not found: ${filePath}`);
25
33
  }
26
- return resolved;
34
+ return {
35
+ data: await fs.promises.readFile(resolved),
36
+ filename: path.basename(resolved),
37
+ };
27
38
  }
@@ -1,11 +1,9 @@
1
- import fs from "fs";
2
-
3
1
  import { defineAction } from "@agent-native/core";
4
2
  import { z } from "zod";
5
3
 
6
4
  import { parseDocx } from "../server/handlers/import/docx-parser.js";
7
5
  import { convertSectionsToSlides } from "../server/handlers/import/html-converter.js";
8
- import { resolveUserUploadedFile } from "./_uploaded-files.js";
6
+ import { readUserUploadedFile } from "./_uploaded-files.js";
9
7
 
10
8
  export default defineAction({
11
9
  description:
@@ -16,9 +14,7 @@ export default defineAction({
16
14
  schema: z.object({
17
15
  filePath: z
18
16
  .string()
19
- .describe(
20
- "Server path to the uploaded DOCX file (e.g. data/uploads/document.docx)",
21
- ),
17
+ .describe("Uploaded DOCX path or opaque hosted upload reference"),
22
18
  convertToSlides: z
23
19
  .boolean()
24
20
  .optional()
@@ -28,9 +24,7 @@ export default defineAction({
28
24
  ),
29
25
  }),
30
26
  run: async ({ filePath, convertToSlides }) => {
31
- const absPath = resolveUserUploadedFile(filePath);
32
-
33
- const fileBuffer = await fs.promises.readFile(absPath);
27
+ const { data: fileBuffer } = await readUserUploadedFile(filePath);
34
28
  const doc = await parseDocx(fileBuffer);
35
29
 
36
30
  const result: Record<string, unknown> = {
@@ -1,4 +1,3 @@
1
- import fs from "fs";
2
1
  import path from "path";
3
2
 
4
3
  import { defineAction } from "@agent-native/core";
@@ -15,7 +14,7 @@ import { z } from "zod";
15
14
  import { getDb, schema } from "../server/db/index.js";
16
15
  import { notifyClients } from "../server/handlers/decks.js";
17
16
  import { upsertBuilderProxyDesignSystem } from "../server/lib/builder-design-system-proxy.js";
18
- import { resolveUserUploadedFile } from "./_uploaded-files.js";
17
+ import { readUserUploadedFile } from "./_uploaded-files.js";
19
18
 
20
19
  const DEFAULT_MAX_SOURCE_CHARS = 60_000;
21
20
 
@@ -30,9 +29,7 @@ export default defineAction({
30
29
  schema: z.object({
31
30
  filePath: z
32
31
  .string()
33
- .describe(
34
- "Server path to the uploaded file (e.g. data/uploads/file.pptx)",
35
- ),
32
+ .describe("Uploaded file path or opaque hosted upload reference"),
36
33
  format: z
37
34
  .enum(["pptx", "docx", "pdf", "fig", "auto"])
38
35
  .optional()
@@ -60,15 +57,15 @@ export default defineAction({
60
57
  ),
61
58
  }),
62
59
  run: async ({ filePath, format, deckId, importIntoDeck, maxChars }) => {
63
- const absPath = resolveUserUploadedFile(filePath);
60
+ const uploaded = await readUserUploadedFile(filePath);
64
61
  const sourceLimit = maxChars ?? DEFAULT_MAX_SOURCE_CHARS;
65
-
66
- const fileBuffer = await fs.promises.readFile(absPath);
62
+ const fileBuffer = uploaded.data;
63
+ const filename = uploaded.filename;
67
64
 
68
65
  // Detect format from extension if auto
69
66
  let detectedFormat = format;
70
67
  if (detectedFormat === "auto") {
71
- const ext = path.extname(absPath).toLowerCase();
68
+ const ext = path.extname(filename).toLowerCase();
72
69
  if (ext === ".pptx") detectedFormat = "pptx";
73
70
  else if (ext === ".docx") detectedFormat = "docx";
74
71
  else if (ext === ".pdf") detectedFormat = "pdf";
@@ -86,12 +83,12 @@ export default defineAction({
86
83
  "Figma .fig imports start Builder design-system indexing, not slide replacements. Re-run without importIntoDeck.",
87
84
  );
88
85
  }
89
- const title = titleFromPath(absPath);
86
+ const title = titleFromPath(filename);
90
87
  const result = await startBuilderDesignSystemIndex({
91
88
  projectName: title,
92
89
  files: [
93
90
  {
94
- name: path.basename(absPath),
91
+ name: path.basename(filename),
95
92
  data: fileBuffer,
96
93
  mimeType: "application/octet-stream",
97
94
  },
@@ -126,7 +123,7 @@ export default defineAction({
126
123
  const { convertToSlideHtml } =
127
124
  await import("../server/handlers/import/html-converter.js");
128
125
  const presentation = await parsePptx(fileBuffer);
129
- const title = presentation.title || titleFromPath(absPath);
126
+ const title = presentation.title || titleFromPath(filename);
130
127
 
131
128
  if (importIntoDeck) {
132
129
  if (!deckId) throw new Error("deckId is required to import into deck");
@@ -172,7 +169,7 @@ export default defineAction({
172
169
  await import("../server/handlers/import/html-converter.js");
173
170
  const doc = await parseDocx(fileBuffer);
174
171
  const slideHtmlArray = convertSectionsToSlides(doc.sections);
175
- const title = doc.title || titleFromPath(absPath);
172
+ const title = doc.title || titleFromPath(filename);
176
173
 
177
174
  if (importIntoDeck) {
178
175
  if (!deckId) throw new Error("deckId is required to import into deck");
@@ -221,7 +218,7 @@ export default defineAction({
221
218
  const result = await pdf.getText();
222
219
  const pages = normalizePdfPages(result);
223
220
  const textPages = pages.filter((p) => p.text.trim());
224
- const title = titleFromPath(absPath);
221
+ const title = titleFromPath(filename);
225
222
 
226
223
  if (textPages.length === 0) {
227
224
  throw new Error(
@@ -1,5 +1,3 @@
1
- import fs from "fs";
2
-
3
1
  import { defineAction } from "@agent-native/core";
4
2
  import { writeAppState } from "@agent-native/core/application-state";
5
3
  import {
@@ -15,7 +13,7 @@ import { notifyClients } from "../server/handlers/decks.js";
15
13
  import { convertToSlideHtml } from "../server/handlers/import/html-converter.js";
16
14
  import { parsePptx } from "../server/handlers/import/pptx-parser.js";
17
15
  import { getDeckUrl } from "./_app-url.js";
18
- import { resolveUserUploadedFile } from "./_uploaded-files.js";
16
+ import { readUserUploadedFile } from "./_uploaded-files.js";
19
17
 
20
18
  export default defineAction({
21
19
  description:
@@ -26,9 +24,7 @@ export default defineAction({
26
24
  schema: z.object({
27
25
  filePath: z
28
26
  .string()
29
- .describe(
30
- "Server path to the uploaded PPTX file (e.g. data/uploads/presentation.pptx)",
31
- ),
27
+ .describe("Uploaded PPTX path or opaque hosted upload reference"),
32
28
  deckId: z
33
29
  .string()
34
30
  .optional()
@@ -43,9 +39,7 @@ export default defineAction({
43
39
  ),
44
40
  }),
45
41
  run: async ({ filePath, deckId, title }) => {
46
- const absPath = resolveUserUploadedFile(filePath);
47
-
48
- const fileBuffer = await fs.promises.readFile(absPath);
42
+ const { data: fileBuffer } = await readUserUploadedFile(filePath);
49
43
  const presentation = await parsePptx(fileBuffer);
50
44
 
51
45
  const deckTitle = title || presentation.title || "Imported Presentation";
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-14
4
+ ---
5
+
6
+ PowerPoint template uploads now work in hosted Slides deployments.
@@ -1,7 +1,6 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
 
4
- import { getSession } from "@agent-native/core/server";
5
4
  import {
6
5
  defineEventHandler,
7
6
  setResponseStatus,
@@ -14,7 +13,15 @@ import {
14
13
  isSlidesReferenceFileExtension,
15
14
  } from "../../shared/upload-types";
16
15
  import { tenantUploadDir } from "../lib/tenant-files.js";
16
+ import {
17
+ isHostedSlidesRuntime,
18
+ storeUploadedReferenceBlob,
19
+ } from "../lib/uploaded-reference-storage.js";
17
20
  import { canSaveAsUploadedAsset, uploadImageAsset } from "./assets.js";
21
+ import {
22
+ resolveSlidesRequestAuthContext,
23
+ withSlidesRequestContext,
24
+ } from "./request-auth-context.js";
18
25
 
19
26
  export const MAX_REFERENCE_FILE_BYTES = 50 * 1024 * 1024;
20
27
  export const MAX_FIG_REFERENCE_FILE_BYTES = 200 * 1024 * 1024;
@@ -113,6 +120,7 @@ function pathForAgent(absPath: string): string {
113
120
 
114
121
  export async function saveUploadedReferenceFile(args: {
115
122
  email: string;
123
+ orgId?: string | null;
116
124
  originalName: string;
117
125
  data: Uint8Array;
118
126
  type?: string;
@@ -127,14 +135,43 @@ export async function saveUploadedReferenceFile(args: {
127
135
  if (!hasExpectedSignature(ext, args.data)) {
128
136
  throw new Error(`File contents do not match ${ext} upload type`);
129
137
  }
130
- const uploadDir = tenantUploadDir(args.email);
131
- await fs.promises.mkdir(uploadDir, { recursive: true });
132
- const destPath = path.join(uploadDir, filename);
133
- await fs.promises.writeFile(destPath, args.data);
134
- // For images, also push to the file-upload provider so the agent can embed
135
- // a hosted URL (in slide HTML, chat replies, etc.). For non-image reference
136
- // files (PPTX, DOCX, PDF), the on-disk path is what the agent uses via
137
- // shell — no provider URL is required.
138
+ let uploadedPath: string;
139
+ if (isHostedSlidesRuntime()) {
140
+ let reference: string | null;
141
+ try {
142
+ reference = await storeUploadedReferenceBlob({
143
+ email: args.email,
144
+ orgId: args.orgId,
145
+ data: args.data,
146
+ filename,
147
+ mimeType: args.type || "application/octet-stream",
148
+ });
149
+ } catch {
150
+ throw Object.assign(
151
+ new Error("Private file storage failed while saving the upload."),
152
+ { statusCode: 503 },
153
+ );
154
+ }
155
+ if (!reference) {
156
+ throw Object.assign(
157
+ new Error(
158
+ "Private file storage is not configured. Connect Builder.io or another file provider before uploading reference files in a hosted Slides deployment.",
159
+ ),
160
+ { statusCode: 503 },
161
+ );
162
+ }
163
+ uploadedPath = reference;
164
+ } else {
165
+ const uploadDir = tenantUploadDir(args.email);
166
+ await fs.promises.mkdir(uploadDir, { recursive: true });
167
+ const destPath = path.join(uploadDir, filename);
168
+ await fs.promises.writeFile(destPath, args.data);
169
+ uploadedPath = pathForAgent(destPath);
170
+ }
171
+ // For images, also push to the public file-upload provider so the agent can
172
+ // embed a hosted URL (in slide HTML, chat replies, etc.). The `path` above
173
+ // remains the private import source: a tenant path locally and an encrypted,
174
+ // owner-scoped blob reference in hosted deployments.
138
175
  let url: string | undefined;
139
176
  if (
140
177
  canSaveAsUploadedAsset({
@@ -159,7 +196,7 @@ export async function saveUploadedReferenceFile(args: {
159
196
  }
160
197
  }
161
198
  return {
162
- path: pathForAgent(destPath),
199
+ path: uploadedPath,
163
200
  url,
164
201
  originalName: args.originalName,
165
202
  filename,
@@ -170,54 +207,67 @@ export async function saveUploadedReferenceFile(args: {
170
207
 
171
208
  // Upload one or more files
172
209
  export const uploadFiles = defineEventHandler(async (event) => {
173
- const session = await getSession(event).catch(() => null);
174
- if (!session?.email) {
210
+ const authContext = await resolveSlidesRequestAuthContext(event);
211
+ const email = authContext.email;
212
+ if (!email) {
175
213
  setResponseStatus(event, 401);
176
214
  return { error: "Unauthorized" };
177
215
  }
178
216
 
179
- const parts = await readMultipartFormData(event);
180
- const fileParts =
181
- parts?.filter((p) => (p.name === "files" || p.name === "file") && p.data) ??
182
- [];
217
+ return withSlidesRequestContext(
218
+ event,
219
+ async ({ orgId }) => {
220
+ const parts = await readMultipartFormData(event);
221
+ const fileParts =
222
+ parts?.filter(
223
+ (p) => (p.name === "files" || p.name === "file") && p.data,
224
+ ) ?? [];
183
225
 
184
- if (fileParts.length === 0) {
185
- setResponseStatus(event, 400);
186
- return { error: "No files uploaded" };
187
- }
226
+ if (fileParts.length === 0) {
227
+ setResponseStatus(event, 400);
228
+ return { error: "No files uploaded" };
229
+ }
188
230
 
189
- const MAX_FILES = 20;
231
+ const MAX_FILES = 20;
190
232
 
191
- if (fileParts.length > MAX_FILES) {
192
- setResponseStatus(event, 413);
193
- return { error: `Too many files (max ${MAX_FILES})` };
194
- }
233
+ if (fileParts.length > MAX_FILES) {
234
+ setResponseStatus(event, 413);
235
+ return { error: `Too many files (max ${MAX_FILES})` };
236
+ }
195
237
 
196
- const oversized = fileParts.find(
197
- (p) => p.data.length > maxReferenceFileBytes(p.filename),
198
- );
199
- if (oversized) {
200
- const limit = maxReferenceFileBytes(oversized.filename);
201
- setResponseStatus(event, 413);
202
- return { error: `File too large (max ${formatMaxFileSize(limit)})` };
203
- }
238
+ const oversized = fileParts.find(
239
+ (p) => p.data.length > maxReferenceFileBytes(p.filename),
240
+ );
241
+ if (oversized) {
242
+ const limit = maxReferenceFileBytes(oversized.filename);
243
+ setResponseStatus(event, 413);
244
+ return { error: `File too large (max ${formatMaxFileSize(limit)})` };
245
+ }
204
246
 
205
- let results;
206
- try {
207
- results = await Promise.all(
208
- fileParts.map(async (part) => {
209
- return saveUploadedReferenceFile({
210
- email: session.email,
211
- originalName: part.filename || "upload",
212
- data: part.data,
213
- type: part.type,
214
- });
215
- }),
216
- );
217
- } catch (err) {
218
- setResponseStatus(event, 400);
219
- return { error: err instanceof Error ? err.message : "Invalid upload" };
220
- }
247
+ let results;
248
+ try {
249
+ results = await Promise.all(
250
+ fileParts.map(async (part) => {
251
+ return saveUploadedReferenceFile({
252
+ email,
253
+ orgId,
254
+ originalName: part.filename || "upload",
255
+ data: part.data,
256
+ type: part.type,
257
+ });
258
+ }),
259
+ );
260
+ } catch (err) {
261
+ const statusCode =
262
+ typeof (err as { statusCode?: unknown })?.statusCode === "number"
263
+ ? (err as { statusCode: number }).statusCode
264
+ : 400;
265
+ setResponseStatus(event, statusCode);
266
+ return { error: err instanceof Error ? err.message : "Invalid upload" };
267
+ }
221
268
 
222
- return results;
269
+ return results;
270
+ },
271
+ authContext,
272
+ );
223
273
  });