@agent-native/core 0.84.58 → 0.84.60

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.
@@ -399,19 +399,23 @@ export default function SettingsIndexRoute() {
399
399
  const [transcriptCleanupEnabled, setTranscriptCleanupEnabled] =
400
400
  useState(true);
401
401
  const [s3Values, setS3Values] = useState<Record<string, string>>({});
402
+ const [s3Errors, setS3Errors] = useState<Record<string, string>>({});
403
+ const [clearingS3, setClearingS3] = useState(false);
402
404
  const [s3Expanded, setS3Expanded] = useState(false);
403
405
  const [apiKeysExpanded, setApiKeysExpanded] = useState(false);
404
406
  const [apiKeyValues, setApiKeyValues] = useState<Record<string, string>>({});
405
407
  const [apiKeyStatus, setApiKeyStatus] = useState<Record<string, boolean>>({});
408
+ const [secretLast4, setSecretLast4] = useState<Record<string, string>>({});
406
409
  const [apiKeyStatusLoading, setApiKeyStatusLoading] = useState(true);
407
410
  const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
408
411
 
409
412
  const refreshApiKeyStatus = useCallback(async () => {
410
413
  setApiKeyStatusLoading(true);
411
414
  try {
412
- const [envRes, secretsRes] = await Promise.all([
415
+ const [envRes, secretsRes, adhocRes] = await Promise.all([
413
416
  fetch(agentNativePath("/_agent-native/env-status")),
414
417
  fetch(agentNativePath("/_agent-native/secrets")),
418
+ fetch(agentNativePath("/_agent-native/secrets/adhoc")),
415
419
  ]);
416
420
  const envData = envRes.ok
417
421
  ? ((await envRes.json()) as Array<{
@@ -425,12 +429,24 @@ export default function SettingsIndexRoute() {
425
429
  status?: string;
426
430
  }>)
427
431
  : [];
432
+ const adhocData = adhocRes.ok
433
+ ? ((await adhocRes.json()) as Array<{
434
+ name: string;
435
+ last4?: string;
436
+ }>)
437
+ : [];
428
438
  const next = Object.fromEntries(
429
439
  envData.map((entry) => [entry.key, Boolean(entry.configured)]),
430
440
  );
431
441
  for (const entry of secretsData) {
432
442
  next[entry.key] = entry.status === "set";
433
443
  }
444
+ const nextLast4: Record<string, string> = {};
445
+ for (const entry of adhocData) {
446
+ next[entry.name] = true;
447
+ if (entry.last4) nextLast4[entry.name] = entry.last4;
448
+ }
449
+ setSecretLast4(nextLast4);
434
450
  setApiKeyStatus(next);
435
451
  } catch {
436
452
  setApiKeyStatus({});
@@ -477,7 +493,36 @@ export default function SettingsIndexRoute() {
477
493
  }
478
494
  }
479
495
 
496
+ function validateS3Values(
497
+ values: Record<string, string>,
498
+ ): Record<string, string> {
499
+ const errors: Record<string, string> = {};
500
+ const urlFields = ["S3_ENDPOINT", "S3_PUBLIC_BASE_URL"];
501
+ for (const key of urlFields) {
502
+ const val = (values[key] ?? "").trim();
503
+ if (!val) continue;
504
+ try {
505
+ const parsed = new URL(val);
506
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
507
+ errors[key] = t("settings.s3UrlInvalid");
508
+ }
509
+ } catch {
510
+ errors[key] = t("settings.s3UrlInvalid");
511
+ }
512
+ }
513
+ const bucket = (values["S3_BUCKET"] ?? "").trim();
514
+ if (bucket && !/^[a-z0-9][a-z0-9\-.]{1,61}[a-z0-9]$/.test(bucket)) {
515
+ errors["S3_BUCKET"] = t("settings.s3BucketInvalid");
516
+ }
517
+ return errors;
518
+ }
519
+
480
520
  async function handleSaveS3Storage() {
521
+ const validationErrors = validateS3Values(s3Values);
522
+ if (Object.keys(validationErrors).length > 0) {
523
+ setS3Errors(validationErrors);
524
+ return;
525
+ }
481
526
  const s3Configured = storageStatus.data?.activeProvider?.id === "s3";
482
527
  const missing = s3Configured
483
528
  ? []
@@ -499,7 +544,7 @@ export default function SettingsIndexRoute() {
499
544
  ...current,
500
545
  S3_SECRET_ACCESS_KEY: "",
501
546
  }));
502
- await storageStatus.refetch();
547
+ await Promise.all([storageStatus.refetch(), refreshApiKeyStatus()]);
503
548
  toast.success(t("settings.storageSaved"));
504
549
  } catch (err) {
505
550
  toast.error(
@@ -510,6 +555,51 @@ export default function SettingsIndexRoute() {
510
555
  }
511
556
  }
512
557
 
558
+ async function handleClearAllS3() {
559
+ setClearingS3(true);
560
+ try {
561
+ const results = await Promise.all(
562
+ S3_STORAGE_FIELDS.filter((field) => apiKeyStatus[field.key]).map(
563
+ async (field) => {
564
+ const res = await fetch(
565
+ agentNativePath(
566
+ `/_agent-native/secrets/adhoc/${encodeURIComponent(field.key)}`,
567
+ ),
568
+ { method: "DELETE" },
569
+ );
570
+ if (!res.ok) {
571
+ const body = (await res.json().catch(() => null)) as {
572
+ error?: string;
573
+ } | null;
574
+ throw new Error(
575
+ body?.error ?? `Failed to clear ${field.key} (${res.status})`,
576
+ );
577
+ }
578
+ const body = (await res.json().catch(() => null)) as {
579
+ removed?: boolean;
580
+ } | null;
581
+ return { key: field.key, removed: body?.removed !== false };
582
+ },
583
+ ),
584
+ );
585
+ const failed = results.filter((r) => !r.removed).map((r) => r.key);
586
+ if (failed.length > 0) {
587
+ throw new Error(
588
+ `Could not remove: ${failed.join(", ")}. You may not have permission.`,
589
+ );
590
+ }
591
+ setS3Values({});
592
+ await Promise.all([refreshApiKeyStatus(), storageStatus.refetch()]);
593
+ toast.success(t("settings.keyCleared"));
594
+ } catch (err) {
595
+ toast.error(
596
+ err instanceof Error ? err.message : t("settings.saveFailed"),
597
+ );
598
+ } finally {
599
+ setClearingS3(false);
600
+ }
601
+ }
602
+
513
603
  async function handleSaveApiKey(key: string) {
514
604
  const value = (apiKeyValues[key] ?? "").trim();
515
605
  if (!value) {
@@ -785,34 +875,90 @@ export default function SettingsIndexRoute() {
785
875
  <CollapsibleContent>
786
876
  <div className="space-y-4 border-t border-border px-3 py-4">
787
877
  <div className="grid gap-4 sm:grid-cols-2">
788
- {S3_STORAGE_FIELDS.map((field) => (
789
- <div key={field.key} className="space-y-1.5">
790
- <Label htmlFor={field.key}>
791
- {t(field.labelKey)}
792
- </Label>
793
- <Input
794
- id={field.key}
795
- type={
796
- "secret" in field && field.secret
797
- ? "password"
798
- : "text"
799
- }
800
- value={s3Values[field.key] ?? ""}
801
- onChange={(event) =>
802
- setS3Values((current) => ({
803
- ...current,
804
- [field.key]: event.target.value,
805
- }))
806
- }
807
- placeholder={field.placeholder}
808
- autoComplete="off"
809
- disabled={savingStorage}
810
- />
811
- </div>
812
- ))}
878
+ {S3_STORAGE_FIELDS.map((field) => {
879
+ const configured = Boolean(
880
+ apiKeyStatus[field.key],
881
+ );
882
+ const last4 = secretLast4[field.key];
883
+ return (
884
+ <div key={field.key} className="space-y-1.5">
885
+ <div className="flex items-center justify-between gap-2">
886
+ <Label htmlFor={field.key}>
887
+ {t(field.labelKey)}
888
+ </Label>
889
+ {configured ? (
890
+ <span className="flex items-center gap-1 text-[10px] font-medium text-primary">
891
+ <IconCheck className="h-3 w-3" />
892
+ {last4
893
+ ? `••••${last4}`
894
+ : t("settings.keySet")}
895
+ </span>
896
+ ) : null}
897
+ </div>
898
+ <Input
899
+ id={field.key}
900
+ type={
901
+ "secret" in field && field.secret
902
+ ? "password"
903
+ : "text"
904
+ }
905
+ value={s3Values[field.key] ?? ""}
906
+ onChange={(event) => {
907
+ setS3Values((current) => ({
908
+ ...current,
909
+ [field.key]: event.target.value,
910
+ }));
911
+ if (s3Errors[field.key]) {
912
+ setS3Errors((current) => {
913
+ const next = { ...current };
914
+ delete next[field.key];
915
+ return next;
916
+ });
917
+ }
918
+ }}
919
+ placeholder={
920
+ configured
921
+ ? t("settings.replaceKey")
922
+ : field.placeholder
923
+ }
924
+ autoComplete="off"
925
+ disabled={savingStorage}
926
+ className={
927
+ s3Errors[field.key]
928
+ ? "border-destructive"
929
+ : undefined
930
+ }
931
+ />
932
+ {s3Errors[field.key] ? (
933
+ <p className="text-[11px] text-destructive">
934
+ {s3Errors[field.key]}
935
+ </p>
936
+ ) : null}
937
+ </div>
938
+ );
939
+ })}
813
940
  </div>
814
941
 
815
- <div className="flex justify-end">
942
+ <div className="flex items-center justify-end gap-2">
943
+ {S3_STORAGE_FIELDS.some(
944
+ (field) => apiKeyStatus[field.key],
945
+ ) ? (
946
+ <Button
947
+ type="button"
948
+ variant="ghost"
949
+ size="sm"
950
+ onClick={handleClearAllS3}
951
+ disabled={clearingS3 || savingStorage}
952
+ className="text-muted-foreground hover:text-destructive"
953
+ >
954
+ {clearingS3 ? (
955
+ <IconLoader2 className="h-4 w-4 animate-spin" />
956
+ ) : (
957
+ <IconTrash className="h-4 w-4" />
958
+ )}
959
+ {t("settings.clearAllS3")}
960
+ </Button>
961
+ ) : null}
816
962
  <Button
817
963
  onClick={handleSaveS3Storage}
818
964
  disabled={
@@ -462,6 +462,16 @@ function uploadTooLargeMessage(size: number, detail?: string): string {
462
462
  )}) after automatic compression. Trim or export a shorter copy and upload again.`;
463
463
  }
464
464
 
465
+ /** Pre-upload size rejection for a picked file — no compression has been
466
+ * attempted yet, so the message must not imply it has. */
467
+ function fileTooLargeMessage(size: number): string {
468
+ return `This file is too large to upload (${formatMb(
469
+ size,
470
+ )}, limit is ${formatMb(
471
+ MAX_UPLOAD_BYTES,
472
+ )}). Trim it or export a shorter copy and try again.`;
473
+ }
474
+
465
475
  function isUploadFailureError(error: string): boolean {
466
476
  return (
467
477
  isUploadSizeError(error) ||
@@ -471,7 +481,7 @@ function isUploadFailureError(error: string): boolean {
471
481
 
472
482
  function friendlyRecordingErrorMessage(error: string): string {
473
483
  if (isUploadSizeError(error)) {
474
- return `This video is too large for Clips after automatic compression. Trim or export a shorter copy under ${formatMb(
484
+ return `This video is too large for Clips. Trim or export a shorter copy under ${formatMb(
475
485
  MAX_UPLOAD_BYTES,
476
486
  )} and upload again.`;
477
487
  }
@@ -835,6 +845,12 @@ export default function RecordRoute() {
835
845
  const [compressionProgress, setCompressionProgress] = useState<number | null>(
836
846
  null,
837
847
  );
848
+ // Fraction (0-1) of upload chunks confirmed sent so far. Chunks are fixed-size
849
+ // slices of the already-recorded blob, so chunksSent / totalChunks is a
850
+ // truthful proxy for bytes uploaded — not simulated. Null means the total
851
+ // chunk count isn't known yet (e.g. the brief live-streaming remainder
852
+ // upload), so the overlay falls back to an indeterminate spinner.
853
+ const [uploadProgress, setUploadProgress] = useState<number | null>(null);
838
854
 
839
855
  const queryClient = useQueryClient();
840
856
  const { isDesktopApp } = useDesktopPromo();
@@ -1103,19 +1119,29 @@ export default function RecordRoute() {
1103
1119
  // upload — applies whether or not we just came from
1104
1120
  // compressing.
1105
1121
  setCompressionProgress(null);
1122
+ // Reset upload progress at the start of each upload attempt so
1123
+ // a retry doesn't briefly show the previous attempt's percent.
1124
+ setUploadProgress(null);
1106
1125
  // Always sync the UI back to "uploading"; if we were already
1107
1126
  // there from doStop's pre-stop transition, this is a no-op.
1108
1127
  setUiState("uploading");
1109
1128
  }
1110
1129
  },
1111
- onChunk: ({ index, bytes }) => {
1130
+ onChunk: ({ index, total }) => {
1131
+ // `total` is only known once the full recording is sliced into
1132
+ // fixed-size chunks after stop(); the live per-chunk uploads
1133
+ // during recording report `total: null` and don't drive this bar.
1134
+ const fraction = total ? (index + 1) / total : null;
1135
+ setUploadProgress(fraction);
1112
1136
  const recordingId = pendingRef.current?.id;
1113
1137
  if (!recordingId) return;
1138
+ // Only expose a percentage here — this state is agent-visible, and
1139
+ // chunk/byte counts are an internal transport detail, not
1140
+ // something to surface to the user.
1114
1141
  void writeAppState(`recording-upload-${recordingId}`, {
1115
1142
  recordingId,
1116
1143
  status: "uploading",
1117
- chunksReceived: index + 1,
1118
- lastChunkBytes: bytes,
1144
+ progress: fraction !== null ? Math.round(fraction * 100) : null,
1119
1145
  updatedAt: new Date().toISOString(),
1120
1146
  }).catch(() => {});
1121
1147
  },
@@ -1378,6 +1404,7 @@ export default function RecordRoute() {
1378
1404
  setError(null);
1379
1405
  setUiState("uploading");
1380
1406
  setCompressionProgress(null);
1407
+ setUploadProgress(null);
1381
1408
 
1382
1409
  const acceptedMime = new Set([
1383
1410
  "video/mp4",
@@ -1406,6 +1433,22 @@ export default function RecordRoute() {
1406
1433
  return;
1407
1434
  }
1408
1435
 
1436
+ // Fail fast on oversized files before we probe metadata, attempt
1437
+ // compression, or open the upload session — no point spending time or
1438
+ // chunking bytes for a file the server will reject anyway. Uses the
1439
+ // same MAX_UPLOAD_BYTES ceiling as the (currently compression-gated)
1440
+ // post-compression check below and the server chunk/finalize routes.
1441
+ if (file.size > MAX_UPLOAD_BYTES) {
1442
+ const message = fileTooLargeMessage(file.size);
1443
+ if (fileUploadAbortRef.current === abort) {
1444
+ fileUploadAbortRef.current = null;
1445
+ }
1446
+ setError(message);
1447
+ setUiState("error");
1448
+ toast.error(message);
1449
+ return;
1450
+ }
1451
+
1409
1452
  let createdId: string | null = null;
1410
1453
  try {
1411
1454
  const status = await fetchVideoStorageStatus();
@@ -1628,6 +1671,7 @@ export default function RecordRoute() {
1628
1671
  unknown
1629
1672
  > | null) ?? null;
1630
1673
  }
1674
+ setUploadProgress((i + 1) / totalChunks);
1631
1675
  }
1632
1676
 
1633
1677
  setUiState("complete");
@@ -1699,6 +1743,7 @@ export default function RecordRoute() {
1699
1743
  fileUploadAbortRef.current = null;
1700
1744
  }
1701
1745
  setCompressionProgress(null);
1746
+ setUploadProgress(null);
1702
1747
  }
1703
1748
  },
1704
1749
  [markStorageConfigured, navigate, probeVideoMetadata],
@@ -1863,6 +1908,7 @@ export default function RecordRoute() {
1863
1908
  setCameraStream(null);
1864
1909
  setPreviewStream(null);
1865
1910
  setCompressionProgress(null);
1911
+ setUploadProgress(null);
1866
1912
  setUiState("complete");
1867
1913
  if (result.waitingForStorage) {
1868
1914
  toast.info(t("recordRoute.recordingReadyToUpload"), {
@@ -2006,6 +2052,7 @@ export default function RecordRoute() {
2006
2052
 
2007
2053
  setError(null);
2008
2054
  setCompressionProgress(null);
2055
+ setUploadProgress(null);
2009
2056
  setUiState("uploading");
2010
2057
  try {
2011
2058
  const retryResult = await engine.retryUpload();
@@ -2022,6 +2069,7 @@ export default function RecordRoute() {
2022
2069
  body: JSON.stringify({ reason: message }),
2023
2070
  }).catch(() => {});
2024
2071
  setCompressionProgress(null);
2072
+ setUploadProgress(null);
2025
2073
  setError(message);
2026
2074
  setUiState("error");
2027
2075
  toast.error(t("recordRoute.uploadFailed"), {
@@ -2085,6 +2133,7 @@ export default function RecordRoute() {
2085
2133
  setPreviewStream(null);
2086
2134
  setIsPaused(false);
2087
2135
  setUiState("idle");
2136
+ setUploadProgress(null);
2088
2137
  pendingRef.current = null;
2089
2138
  engineRef.current = null;
2090
2139
  }, [extensionCapture, liveTranscription]);
@@ -2427,7 +2476,9 @@ export default function RecordRoute() {
2427
2476
  users wonder if the app froze). */}
2428
2477
  {(uiState === "uploading" || uiState === "compressing") && (
2429
2478
  <div className="fixed inset-0 z-[120] flex flex-col items-center justify-center gap-3 bg-black/70 text-white backdrop-blur">
2430
- <Spinner className="h-10 w-10 text-white/70" />
2479
+ {!(uiState === "uploading" && uploadProgress !== null) && (
2480
+ <Spinner className="h-10 w-10 text-white/70" />
2481
+ )}
2431
2482
  {uiState === "compressing" ? (
2432
2483
  <>
2433
2484
  <div className="text-sm">
@@ -2441,7 +2492,24 @@ export default function RecordRoute() {
2441
2492
  </div>
2442
2493
  </>
2443
2494
  ) : (
2444
- <div className="text-sm">{t("recordRoute.savingRecording")}</div>
2495
+ <>
2496
+ <div className="text-sm">{t("recordRoute.savingRecording")}</div>
2497
+ {uploadProgress !== null && (
2498
+ <div className="flex w-48 flex-col items-center gap-1">
2499
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-white/20">
2500
+ <div
2501
+ className="h-full rounded-full bg-white transition-all"
2502
+ style={{
2503
+ width: `${Math.min(100, Math.max(0, Math.round(uploadProgress * 100)))}%`,
2504
+ }}
2505
+ />
2506
+ </div>
2507
+ <div className="text-[11px] text-white/50">
2508
+ {Math.round(uploadProgress * 100)}%
2509
+ </div>
2510
+ </div>
2511
+ )}
2512
+ </>
2445
2513
  )}
2446
2514
  <button
2447
2515
  onClick={doCancel}
package/dist/action.js CHANGED
@@ -210,6 +210,80 @@ function wrapRunWithAudit(run, auditConfig) {
210
210
  // ---------------------------------------------------------------------------
211
211
  // Schema → JSON Schema conversion
212
212
  // ---------------------------------------------------------------------------
213
+ // Keywords whose value is a single subschema.
214
+ const SUBSCHEMA_VALUE_KEYS = [
215
+ "items",
216
+ "additionalItems",
217
+ "contains",
218
+ "additionalProperties",
219
+ "not",
220
+ "if",
221
+ "then",
222
+ "else",
223
+ ];
224
+ // Keywords whose value is an array of subschemas.
225
+ const SUBSCHEMA_ARRAY_KEYS = [
226
+ "allOf",
227
+ "anyOf",
228
+ "oneOf",
229
+ "prefixItems",
230
+ ];
231
+ // Keywords whose value is a map of name → subschema.
232
+ const SUBSCHEMA_MAP_KEYS = [
233
+ "properties",
234
+ "patternProperties",
235
+ "$defs",
236
+ "definitions",
237
+ "dependentSchemas",
238
+ ];
239
+ /**
240
+ * Remove JSON Schema keywords that some providers' function-calling schema
241
+ * validators reject. OpenAI (and Gemini via the Builder gateway) reject
242
+ * `propertyNames` — which Zod v4 emits for `z.record(z.string(), …)` — with a
243
+ * `400 invalid_function_parameters` error, causing the model turn to produce no
244
+ * content (surfacing as an empty assistant response). Anthropic ignores the
245
+ * keyword, so stripping it is safe across providers and keeps action schemas
246
+ * portable. `propertyNames` only constrained object *keys*; the value/shape of
247
+ * the object is unaffected by its removal.
248
+ *
249
+ * Only descends through actual subschema positions (properties, items, union
250
+ * branches, definitions, etc.) — never through value-bearing keywords like
251
+ * `default`, `const`, `enum`, or `examples`, whose objects may legitimately
252
+ * contain a `propertyNames` data key that must be preserved.
253
+ */
254
+ function stripUnsupportedSchemaKeywords(node) {
255
+ if (!node || typeof node !== "object" || Array.isArray(node))
256
+ return node;
257
+ const obj = node;
258
+ delete obj.propertyNames;
259
+ for (const key of SUBSCHEMA_VALUE_KEYS) {
260
+ // `items`/`additionalItems` may also be an array of subschemas.
261
+ const value = obj[key];
262
+ if (Array.isArray(value)) {
263
+ for (const sub of value)
264
+ stripUnsupportedSchemaKeywords(sub);
265
+ }
266
+ else {
267
+ stripUnsupportedSchemaKeywords(value);
268
+ }
269
+ }
270
+ for (const key of SUBSCHEMA_ARRAY_KEYS) {
271
+ const value = obj[key];
272
+ if (Array.isArray(value)) {
273
+ for (const sub of value)
274
+ stripUnsupportedSchemaKeywords(sub);
275
+ }
276
+ }
277
+ for (const key of SUBSCHEMA_MAP_KEYS) {
278
+ const value = obj[key];
279
+ if (value && typeof value === "object" && !Array.isArray(value)) {
280
+ for (const sub of Object.values(value)) {
281
+ stripUnsupportedSchemaKeywords(sub);
282
+ }
283
+ }
284
+ }
285
+ return node;
286
+ }
213
287
  /**
214
288
  * Convert a Standard Schema to JSON Schema for the Claude API.
215
289
  * Tries vendor-specific toJSONSchema first (Zod v4), then falls back
@@ -229,7 +303,7 @@ function schemaToJsonSchema(schema, _description) {
229
303
  if (result && typeof result === "object") {
230
304
  delete result.$schema;
231
305
  }
232
- return result;
306
+ return stripUnsupportedSchemaKeywords(result);
233
307
  }
234
308
  catch {
235
309
  // Fall through to manual converter
@@ -237,7 +311,7 @@ function schemaToJsonSchema(schema, _description) {
237
311
  }
238
312
  // Fallback: manual conversion from Zod v4 internal defs
239
313
  if (s._zod?.def) {
240
- return zodDefToJsonSchema(s._zod.def);
314
+ return stripUnsupportedSchemaKeywords(zodDefToJsonSchema(s._zod.def));
241
315
  }
242
316
  // Last resort: empty object schema
243
317
  return { type: "object", properties: {} };