@agent-native/core 0.114.10 → 0.114.11

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.
package/corpus/README.md CHANGED
@@ -30,4 +30,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
30
30
 
31
31
  - core files: 1519
32
32
  - toolkit files: 137
33
- - template files: 6176
33
+ - template files: 6178
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.114.11
4
+
5
+ ### Patch Changes
6
+
7
+ - b47a1b3: Add extension-data-set and extension-data-get agent actions for reading and writing extensionData from the agent side, with auto-refresh of mounted extension iframes after writes.
8
+
3
9
  ## 0.114.10
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.114.10",
3
+ "version": "0.114.11",
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": {
@@ -1,8 +1,11 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  import { ACTION_CHAT_UI_INLINE_EXTENSION_RENDERER } from "../action-ui.js";
2
4
  import { AgentActionStopError, type ActionRunContext } from "../action.js";
3
5
  import type { ActionEntry } from "../agent/production-agent.js";
4
6
  import type { AgentChatAttachment } from "../agent/types.js";
5
7
  import { writeAppState } from "../application-state/script-helpers.js";
8
+ import { getDbExec, isPostgres } from "../db/client.js";
6
9
  import { readResource } from "../resources/script-helpers.js";
7
10
  import {
8
11
  getRequestOrgId,
@@ -36,6 +39,7 @@ import {
36
39
  import {
37
40
  createExtension,
38
41
  deleteExtension,
42
+ ensureExtensionsTables,
39
43
  findRecentDuplicateExtension,
40
44
  getHiddenExtensionIdsForCurrentUser,
41
45
  getExtension,
@@ -45,6 +49,7 @@ import {
45
49
  hideExtension,
46
50
  listExtensionHistory,
47
51
  listExtensions,
52
+ notifyExtensionChangeForResource,
48
53
  restoreExtensionHistoryVersion,
49
54
  unhideExtension,
50
55
  updateExtension,
@@ -818,6 +823,220 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
818
823
  },
819
824
  },
820
825
 
826
+ "extension-data-set": {
827
+ tool: {
828
+ description:
829
+ "Write a value to an extension's extensionData store (the tool_data table) from the agent side. Use this when the agent needs to update or seed data that an extension reads via extensionData.get() at render time. Requires editor access to the extension.",
830
+ parameters: {
831
+ type: "object",
832
+ properties: {
833
+ extensionId: {
834
+ type: "string",
835
+ description: "Extension id whose data store to write to.",
836
+ },
837
+ collection: {
838
+ type: "string",
839
+ description: "Collection name within the extension's data store.",
840
+ },
841
+ itemId: {
842
+ type: "string",
843
+ description:
844
+ "Item id within the collection. Upserts if it already exists.",
845
+ },
846
+ data: {
847
+ description:
848
+ "The data value to store. Objects are JSON-serialized automatically.",
849
+ },
850
+ scope: {
851
+ type: "string",
852
+ enum: ["user", "org"],
853
+ description:
854
+ "Storage scope. 'user' (default) is private to the current user; 'org' is shared across the organization.",
855
+ },
856
+ },
857
+ required: ["extensionId", "collection", "itemId", "data"],
858
+ },
859
+ },
860
+ run: async (args) => {
861
+ const extensionId = String(args?.extensionId ?? "").trim();
862
+ const collection = String(args?.collection ?? "").trim();
863
+ const itemId = String(args?.itemId ?? "").trim();
864
+ if (!extensionId || !collection || !itemId)
865
+ return "Error: extensionId, collection, and itemId are required.";
866
+ if (args?.data === undefined) return "Error: data is required.";
867
+
868
+ await ensureExtensionsTables();
869
+ const access = await resolveAccess("extension", extensionId);
870
+ if (!access || access.role === "viewer")
871
+ return `Error: editor access required for extension ${extensionId}.`;
872
+
873
+ const userEmail = getRequestUserEmail()?.toLowerCase() ?? "";
874
+ const scope = args?.scope === "org" ? "org" : "user";
875
+ const orgId = getRequestOrgId();
876
+ if (scope === "org" && !orgId)
877
+ return "Error: org context required for scope=org.";
878
+
879
+ const data =
880
+ typeof args.data === "string" ? args.data : JSON.stringify(args.data);
881
+ const MAX_BYTES = 1024 * 1024;
882
+ if (Buffer.byteLength(data, "utf8") > MAX_BYTES)
883
+ return `Error: data exceeds ${MAX_BYTES} byte limit. Store large payloads in file storage instead.`;
884
+
885
+ const now = new Date().toISOString();
886
+ const scopeKey = scope === "org" ? `org:${orgId}` : userEmail;
887
+ const client = getDbExec();
888
+ const pg = isPostgres();
889
+ const conflictClause = pg
890
+ ? `ON CONFLICT (tool_id, collection, scope_key, item_id)
891
+ DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`
892
+ : `ON CONFLICT (tool_id, collection, scope_key, item_id)
893
+ DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at`;
894
+
895
+ await client.execute({
896
+ sql: `UPDATE tools SET updated_at = ? WHERE id = ?`,
897
+ args: [now, extensionId],
898
+ });
899
+ await client.execute({
900
+ sql: `INSERT INTO tool_data (id, tool_id, collection, item_id, data, owner_email, scope, org_id, scope_key, created_at, updated_at)
901
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
902
+ ${conflictClause}`,
903
+ args: [
904
+ randomUUID(),
905
+ extensionId,
906
+ collection,
907
+ itemId,
908
+ data,
909
+ userEmail,
910
+ scope,
911
+ scope === "org" ? orgId! : null,
912
+ scopeKey,
913
+ now,
914
+ now,
915
+ ],
916
+ });
917
+ await notifyExtensionChangeForResource(extensionId);
918
+
919
+ return {
920
+ ok: true,
921
+ id: itemId,
922
+ extensionId,
923
+ collection,
924
+ scope,
925
+ updatedAt: now,
926
+ };
927
+ },
928
+ },
929
+
930
+ "extension-data-get": {
931
+ tool: {
932
+ description:
933
+ "Read items from an extension's extensionData store (the tool_data table) from the agent side. Use this to inspect what data an extension currently has stored, or to verify a write succeeded. Requires viewer access to the extension.",
934
+ parameters: {
935
+ type: "object",
936
+ properties: {
937
+ extensionId: {
938
+ type: "string",
939
+ description: "Extension id whose data store to read from.",
940
+ },
941
+ collection: {
942
+ type: "string",
943
+ description: "Collection name within the extension's data store.",
944
+ },
945
+ itemId: {
946
+ type: "string",
947
+ description:
948
+ "Optional specific item id. If omitted, lists all items in the collection.",
949
+ },
950
+ scope: {
951
+ type: "string",
952
+ enum: ["user", "org", "all"],
953
+ description:
954
+ "Storage scope to read. 'user' (default) reads the current user's items; 'org' reads organization-shared items; 'all' reads both.",
955
+ },
956
+ limit: {
957
+ type: "number",
958
+ description:
959
+ "Maximum items to return when listing (default 100, max 1000).",
960
+ },
961
+ },
962
+ required: ["extensionId", "collection"],
963
+ },
964
+ },
965
+ run: async (args) => {
966
+ const extensionId = String(args?.extensionId ?? "").trim();
967
+ const collection = String(args?.collection ?? "").trim();
968
+ if (!extensionId || !collection)
969
+ return "Error: extensionId and collection are required.";
970
+
971
+ await ensureExtensionsTables();
972
+ const access = await resolveAccess("extension", extensionId);
973
+ if (!access) return `Error: no access to extension ${extensionId}.`;
974
+
975
+ const userEmail = getRequestUserEmail()?.toLowerCase() ?? "";
976
+ const scope = args?.scope ?? "user";
977
+ const orgId = getRequestOrgId();
978
+ if ((scope === "org" || scope === "all") && !orgId)
979
+ return "Error: org context required for scope=org or scope=all.";
980
+ const itemId = args?.itemId ? String(args.itemId).trim() : null;
981
+ const limit = Math.min(Math.max(1, Number(args?.limit) || 100), 1000);
982
+ const client = getDbExec();
983
+
984
+ if (itemId) {
985
+ const scopeClause =
986
+ scope === "org"
987
+ ? `AND scope = 'org' AND org_id = ?`
988
+ : scope === "all"
989
+ ? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))`
990
+ : `AND scope = 'user' AND lower(owner_email) = ?`;
991
+ const scopeArgs =
992
+ scope === "org"
993
+ ? [orgId ?? ""]
994
+ : scope === "all"
995
+ ? [userEmail, orgId ?? ""]
996
+ : [userEmail];
997
+
998
+ const result = await client.execute({
999
+ sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at
1000
+ FROM tool_data
1001
+ WHERE tool_id = ? AND collection = ? AND COALESCE(item_id, id) = ? ${scopeClause}`,
1002
+ args: [extensionId, collection, itemId, ...scopeArgs],
1003
+ });
1004
+ const row = result.rows?.[0];
1005
+ if (!row) return { found: false, extensionId, collection, itemId };
1006
+ return { found: true, ...row };
1007
+ }
1008
+
1009
+ const scopeClause =
1010
+ scope === "org"
1011
+ ? `AND scope = 'org' AND org_id = ?`
1012
+ : scope === "all"
1013
+ ? `AND ((scope = 'user' AND lower(owner_email) = ?) OR (scope = 'org' AND org_id = ?))`
1014
+ : `AND scope = 'user' AND lower(owner_email) = ?`;
1015
+ const scopeArgs =
1016
+ scope === "org"
1017
+ ? [orgId ?? ""]
1018
+ : scope === "all"
1019
+ ? [userEmail, orgId ?? ""]
1020
+ : [userEmail];
1021
+
1022
+ const result = await client.execute({
1023
+ sql: `SELECT COALESCE(item_id, id) AS id, data, scope, owner_email, updated_at
1024
+ FROM tool_data
1025
+ WHERE tool_id = ? AND collection = ? ${scopeClause}
1026
+ ORDER BY updated_at DESC
1027
+ LIMIT ?`,
1028
+ args: [extensionId, collection, ...scopeArgs, limit],
1029
+ });
1030
+ return {
1031
+ extensionId,
1032
+ collection,
1033
+ scope,
1034
+ count: result.rows?.length ?? 0,
1035
+ items: result.rows ?? [],
1036
+ };
1037
+ },
1038
+ },
1039
+
821
1040
  "delete-extension": {
822
1041
  tool: {
823
1042
  description:
@@ -153,6 +153,25 @@ persistence** — it handles everything automatically. Only use
153
153
  `dbQuery`/`dbExec` when querying the app's existing tables. See
154
154
  `references/api.md` for the full `get`/`remove`/scope reference.
155
155
 
156
+ ### Agent-side extension data access
157
+
158
+ The agent can read and write `extensionData` directly using two dedicated
159
+ actions — no need to go through the iframe bridge or raw SQL:
160
+
161
+ | Action | Purpose |
162
+ | --------------------- | ------------------------------------------------- |
163
+ | `extension-data-set` | Upsert an item in an extension's data store |
164
+ | `extension-data-get` | Read items from an extension's data store |
165
+
166
+ Use `extension-data-set` when the agent needs to seed, refresh, or update
167
+ data that an extension reads at render time via `extensionData.get()`. This
168
+ is the correct path for agent-driven dashboard refreshes — the agent
169
+ fetches fresh data from providers, then writes the merged result with
170
+ `extension-data-set`, and the extension picks it up on next load.
171
+
172
+ Use `extension-data-get` to inspect what data an extension currently stores,
173
+ or to verify a write succeeded.
174
+
156
175
  ## What extensions are
157
176
 
158
177
  Extensions are mini Alpine.js apps that run inside sandboxed iframes. They
@@ -277,9 +277,18 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
277
277
  // without this guard that effect would treat the two URLs as the "same
278
278
  // resource" and immediately revert our retry before `.load()` completes.
279
279
  const recoveringFromErrorRef = useRef(false);
280
+ const prevMseModeRef = useRef("");
281
+ // Render-phase mirrors of currentMs / isPlaying so the MSE-fallback effect
282
+ // below can read the pre-failure values. By the time effects run, React has
283
+ // already committed the new <video src>, causing the browser to reset
284
+ // currentTime -> 0 and paused -> true, making the element values useless.
285
+ const currentMsRef = useRef(startMs ?? 0);
286
+ const isPlayingRef = useRef(false);
280
287
  const [activeVideoSrc, setActiveVideoSrc] = useState(resolvedVideoSrc);
281
288
  const [isPlaying, setIsPlaying] = useState(false);
282
289
  const [currentMs, setCurrentMs] = useState(startMs ?? 0);
290
+ currentMsRef.current = currentMs;
291
+ isPlayingRef.current = isPlaying;
283
292
  const [loomStartMs, setLoomStartMs] = useState<number | null>(null);
284
293
  const [volume, setVolume] = useState(1);
285
294
  // Autoplaying players (e.g. the Slack unfurl embed, `?autoplay=1`) must
@@ -392,6 +401,45 @@ export const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
392
401
  ? undefined
393
402
  : activeVideoSrc;
394
403
 
404
+ // When the MSE pipeline breaks mid-stream (premature 416 or
405
+ // ERR_CONTENT_LENGTH_MISMATCH after the backing GCS object is replaced by
406
+ // the compressed version), the loader calls onFatal which flips mse.mode to
407
+ // "native". This effect detects that transition and:
408
+ // 1. saves the playback position via currentMsRef (v.currentTime is already 0)
409
+ // 2. cache-busts activeVideoSrc so the native <video> path fetches fresh
410
+ // headers rather than a proxy-cached content-length from the old object
411
+ // 3. if the user was playing, arms playAttemptPendingRef so the existing
412
+ // retryPendingPlay call in onLoadedData resumes without user interaction
413
+ useEffect(() => {
414
+ const prev = prevMseModeRef.current;
415
+ prevMseModeRef.current = mse.mode;
416
+ if (prev !== "mse" || mse.mode !== "native") return;
417
+
418
+ const wasPlaying = isPlayingRef.current;
419
+ const posMs = currentMsRef.current > 0 ? currentMsRef.current : null;
420
+ if (posMs != null) resumeAfterReloadMsRef.current = posMs;
421
+
422
+ if (activeVideoSrc) {
423
+ recoveringFromErrorRef.current = true;
424
+ setActiveVideoSrc(
425
+ setUrlSearchParam(activeVideoSrc, "cb", String(Date.now())),
426
+ );
427
+ }
428
+
429
+ if (wasPlaying) {
430
+ const nextId = playAttemptIdRef.current + 1;
431
+ playAttemptIdRef.current = nextId;
432
+ playAttemptPendingRef.current = true;
433
+ setIsPlayPending(true);
434
+ setIsBuffering(true);
435
+ } else {
436
+ setIsPlaying(false);
437
+ setIsBuffering(false);
438
+ }
439
+ setIsPreparing(true);
440
+ setCanPlay(false);
441
+ }, [mse.mode, activeVideoSrc]);
442
+
395
443
  useEffect(() => {
396
444
  if (!resolvedVideoSrc) {
397
445
  setActiveVideoSrc(undefined);
@@ -208,14 +208,36 @@ export function findMoofOffset(bytes: Uint8Array): number {
208
208
  * True when the head of an MP4 shows the fragmented shape: the `hlsf` brand in
209
209
  * `ftyp`, or an `mvex` box inside `moov` (present only in fragmented files).
210
210
  * `headBytes` should be the first few KB of the file.
211
+ *
212
+ * Both checks walk the ISO BMFF box structure rather than scanning raw bytes.
213
+ * Numeric fields inside moov children (timestamps, matrix values, codec config)
214
+ * can accidentally contain the byte sequences "mvex" or "hlsf", producing false
215
+ * positives if we just scan the whole buffer.
211
216
  */
212
217
  export function isFragmentedMp4Head(headBytes: Uint8Array): boolean {
213
218
  if (headBytes.byteLength < 8) return false;
214
- // Must look like an MP4 at all.
219
+
215
220
  if (indexOfAscii(headBytes, "ftyp") !== 4) return false;
216
- if (indexOfAscii(headBytes, "hlsf") !== -1) return true;
217
- if (indexOfAscii(headBytes, "mvex") !== -1) return true;
218
- return false;
221
+
222
+ const ftypSize = readU32(headBytes, 0);
223
+ if (ftypSize < 12) return false;
224
+ const ftypEnd = Math.min(ftypSize, headBytes.byteLength);
225
+
226
+ // Offset 12 is minor_version (uint32), not a brand — start compatible brands at 16.
227
+ if (ftypEnd >= 12 && readType(headBytes, 8) === "hlsf") return true;
228
+ for (let i = 16; i + 4 <= ftypEnd; i += 4) {
229
+ if (readType(headBytes, i) === "hlsf") return true;
230
+ }
231
+
232
+ const boxes = readTopLevelBoxes(headBytes);
233
+ const moov = boxes.find((b) => b.type === "moov");
234
+ if (!moov || moov.size === 0) return false;
235
+ const moovPayloadEnd = Math.min(moov.start + moov.size, headBytes.byteLength);
236
+ const moovPayload = headBytes.subarray(
237
+ moov.start + moov.headerSize,
238
+ moovPayloadEnd,
239
+ );
240
+ return readTopLevelBoxes(moovPayload).some((b) => b.type === "mvex");
219
241
  }
220
242
 
221
243
  /** Cache detection by URL identity so we sniff each asset only once. */
@@ -555,11 +555,22 @@ export class MseVideoLoader {
555
555
  headers: { Range: `bytes=${start}-${end}` },
556
556
  signal: controller.signal,
557
557
  });
558
- // 416 means we asked past the end — treat as a clean end-of-stream.
559
- if (res.status === 416) return { bytes: new Uint8Array(0), eof: true };
558
+ if (res.status === 416) {
559
+ // A 416 at an offset before the known file total means the backing object
560
+ // was replaced with a smaller compressed version while we were streaming.
561
+ // Throw so runPump's catch calls fail() -> onFatal -> native path recovery.
562
+ // When totalKnown is false (cross-origin CDN hides Content-Range), we
563
+ // cannot tell premature from real EOF so we keep the old safe-EOF behaviour.
564
+ if (this.totalKnown && start < this.totalBytes) {
565
+ throw new Error("Range 416 before known EOF: backing file replaced");
566
+ }
567
+ return { bytes: new Uint8Array(0), eof: true };
568
+ }
560
569
  if (!res.ok) {
561
570
  throw new Error(`Range request failed: ${res.status}`);
562
571
  }
572
+ // If the backing file shrank mid-response (ERR_CONTENT_LENGTH_MISMATCH),
573
+ // arrayBuffer() throws here, which also routes through fail() -> onFatal.
563
574
  const buffer = await res.arrayBuffer();
564
575
  const bytes = new Uint8Array(buffer);
565
576
 
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-20
4
+ ---
5
+
6
+ Rewind now reports the same live capture status on Home and in Settings without mistaking an idle moment for a permission problem.