@agent-native/core 0.70.1 → 0.70.3

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 (26) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/action.ts +21 -10
  5. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +275 -0
  6. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +33 -6
  7. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +8 -2
  8. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -9
  9. package/corpus/templates/clips/changelog/2026-06-23-screen-recordings-now-capture-at-a-crisp-1080p-bitrate-inste.md +6 -0
  10. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  11. package/corpus/templates/clips/chrome-extension/scripts/dev.ts +102 -0
  12. package/corpus/templates/clips/chrome-extension/src/background.ts +34 -0
  13. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +3 -1
  14. package/corpus/templates/clips/desktop/src/lib/recorder.ts +7 -3
  15. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +2 -2
  16. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +9 -1
  17. package/corpus/templates/clips/shared/upload-limits.ts +5 -2
  18. package/corpus/templates/plan/app/pages/PlansPage.tsx +11 -7
  19. package/corpus/templates/plan/changelog/2026-06-23-plan-loading-skeleton-no-longer-cuts-off-the-canvas-preview-.md +6 -0
  20. package/dist/action.js +21 -10
  21. package/dist/action.js.map +1 -1
  22. package/dist/cli/sync-builder-starter-manifest.d.ts +32 -0
  23. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -0
  24. package/dist/cli/sync-builder-starter-manifest.js +179 -0
  25. package/dist/cli/sync-builder-starter-manifest.js.map +1 -0
  26. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -27,5 +27,5 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
27
27
 
28
28
  ## Generated Counts
29
29
 
30
- - core files: 1146
31
- - template files: 3993
30
+ - core files: 1147
31
+ - template files: 3996
@@ -1,5 +1,17 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.70.3
4
+
5
+ ### Patch Changes
6
+
7
+ - 3c80603: Document and regression-test that `useActionQuery` / `callAction` GET calls round-trip boolean and number params. Browser query params are serialized through `URLSearchParams`, which stringifies everything — so `useActionQuery("instrument-overview", { includeSeries: true, limit: 5 })` sends `includeSeries: "true"` / `limit: "5"`. Schema-aware coercion (added in 0.70.2) already restores native types before validation, but it was framed and tested only as a model-gateway concern. This adds an end-to-end regression test through the action route for the GET path and broadens the coercion doc comment so it is not narrowed to gateway-only and silently re-break browser GET calls. No runtime behavior change.
8
+
9
+ ## 0.70.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 2d36525: Add a sync helper for keeping Builder Agent Native Starter's standalone manifest aligned with the chat template scaffold output.
14
+
3
15
  ## 0.70.1
4
16
 
5
17
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.70.1",
3
+ "version": "0.70.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -956,16 +956,27 @@ function coerceStringToSchemaType(raw: string, types: string[]): unknown {
956
956
  }
957
957
 
958
958
  /**
959
- * Defensively coerce gateway-stringified tool arguments to the types the schema
960
- * expects. Some model gateways (notably Builder's Gemini-backed gateway) hand
961
- * back structured tool-call arguments as JSON strings — an array param arrives
962
- * as `"[{...}]"`, a boolean as `"true"`. Standard Schema (zod) `validate` does
963
- * not coerce, so these fail validation and the agent thrashes retrying shapes
964
- * (and can hang). We only touch a string value when the schema expects a
965
- * non-string type and the string parses cleanly to it; anything ambiguous
966
- * (schema also allows string) or unparseable is left as-is. Operates on
967
- * top-level properties only once an array/object param is parsed, its nested
968
- * members are already native and validate normally.
959
+ * Defensively coerce stringified action arguments to the types the schema
960
+ * expects. Two callers depend on this:
961
+ *
962
+ * 1. Model gateways (notably Builder's Gemini-backed gateway) hand back
963
+ * structured tool-call arguments as JSON strings an array param arrives
964
+ * as `"[{...}]"`, a boolean as `"true"`.
965
+ * 2. GET actions called from the browser via `useActionQuery` / `callAction`.
966
+ * Those serialize params into the query string, where `URLSearchParams`
967
+ * stringifies everythingso `includeSeries: true` arrives as the string
968
+ * `"true"` and `limit: 5` as `"5"` (see `action-routes.ts`).
969
+ *
970
+ * In both cases Standard Schema (zod) `validate` does not coerce, so the call
971
+ * fails validation ("expected boolean, received string") — the agent thrashes
972
+ * retrying shapes and the frontend query errors. We only touch a string value
973
+ * when the schema expects a non-string type and the string parses cleanly to
974
+ * it; anything ambiguous (schema also allows string) or unparseable is left
975
+ * as-is. Operates on top-level properties only — once an array/object param is
976
+ * parsed, its nested members are already native and validate normally.
977
+ *
978
+ * Do NOT narrow this to "gateway-only": the GET query-string path relies on it
979
+ * too, and `action-routes.spec.ts` guards that round-trip.
969
980
  */
970
981
  function coerceGatewayStringifiedArgs(
971
982
  args: unknown,
@@ -0,0 +1,275 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { _postProcessStandalone } from "./create.js";
6
+
7
+ export const STARTER_APP_NAME = "builder-agent-native-starter";
8
+ export const CHAT_TEMPLATE = "chat";
9
+
10
+ type PackageJson = Record<string, unknown>;
11
+
12
+ export function findAgentNativeRoot(startDir = process.cwd()): string {
13
+ let dir = path.resolve(startDir);
14
+ for (let i = 0; i < 12; i++) {
15
+ const chatPackageJson = path.join(
16
+ dir,
17
+ "templates",
18
+ CHAT_TEMPLATE,
19
+ "package.json",
20
+ );
21
+ if (fs.existsSync(chatPackageJson)) {
22
+ return dir;
23
+ }
24
+ const parent = path.dirname(dir);
25
+ if (parent === dir) break;
26
+ dir = parent;
27
+ }
28
+ throw new Error(
29
+ "Could not find agent-native repo root (expected templates/chat/package.json).",
30
+ );
31
+ }
32
+
33
+ export function generateStandaloneChatManifest(repoRoot?: string): {
34
+ packageJson: PackageJson;
35
+ pnpmWorkspaceYaml: string | null;
36
+ } {
37
+ const root = repoRoot ?? findAgentNativeRoot();
38
+ const chatTemplateDir = path.join(root, "templates", CHAT_TEMPLATE);
39
+ const tempDir = fs.mkdtempSync(
40
+ path.join(os.tmpdir(), "an-builder-starter-sync-"),
41
+ );
42
+
43
+ try {
44
+ fs.cpSync(chatTemplateDir, tempDir, { recursive: true });
45
+ _postProcessStandalone(STARTER_APP_NAME, tempDir, CHAT_TEMPLATE);
46
+
47
+ const packageJson = JSON.parse(
48
+ fs.readFileSync(path.join(tempDir, "package.json"), "utf-8"),
49
+ ) as PackageJson;
50
+
51
+ const workspacePath = path.join(tempDir, "pnpm-workspace.yaml");
52
+ const pnpmWorkspaceYaml = fs.existsSync(workspacePath)
53
+ ? fs.readFileSync(workspacePath, "utf-8")
54
+ : null;
55
+
56
+ return { packageJson, pnpmWorkspaceYaml };
57
+ } finally {
58
+ fs.rmSync(tempDir, { recursive: true, force: true });
59
+ }
60
+ }
61
+
62
+ export function mergeStarterManifest(
63
+ starterPackageJson: PackageJson,
64
+ canonicalPackageJson: PackageJson,
65
+ ): PackageJson {
66
+ const merged = structuredClone(canonicalPackageJson) as PackageJson;
67
+
68
+ merged.name = starterPackageJson.name ?? STARTER_APP_NAME;
69
+ if (typeof starterPackageJson.displayName === "string") {
70
+ merged.displayName = starterPackageJson.displayName;
71
+ }
72
+ if (typeof starterPackageJson.description === "string") {
73
+ merged.description = starterPackageJson.description;
74
+ }
75
+ if (starterPackageJson.private !== undefined) {
76
+ merged.private = starterPackageJson.private;
77
+ }
78
+
79
+ const starterDeps =
80
+ (starterPackageJson.dependencies as Record<string, string> | undefined) ??
81
+ {};
82
+ const canonicalDeps =
83
+ (canonicalPackageJson.dependencies as Record<string, string> | undefined) ??
84
+ {};
85
+ merged.dependencies = {
86
+ ...canonicalDeps,
87
+ ...(starterDeps["@agent-native/core"]
88
+ ? { "@agent-native/core": starterDeps["@agent-native/core"] }
89
+ : {}),
90
+ };
91
+
92
+ return merged;
93
+ }
94
+
95
+ export function workspaceFileSyncChanged(
96
+ existingWorkspace: string | null,
97
+ canonicalWorkspace: string | null,
98
+ ): boolean {
99
+ if (canonicalWorkspace === null) {
100
+ return existingWorkspace !== null;
101
+ }
102
+ return existingWorkspace !== canonicalWorkspace;
103
+ }
104
+
105
+ export function applyWorkspaceFileSync(
106
+ targetPath: string,
107
+ canonicalWorkspace: string | null,
108
+ ): void {
109
+ if (canonicalWorkspace === null) {
110
+ if (fs.existsSync(targetPath)) {
111
+ fs.unlinkSync(targetPath);
112
+ }
113
+ return;
114
+ }
115
+ fs.writeFileSync(targetPath, canonicalWorkspace);
116
+ }
117
+
118
+ function stableJson(value: unknown): string {
119
+ return `${JSON.stringify(value, null, 2)}\n`;
120
+ }
121
+
122
+ export type SyncStarterManifestResult = {
123
+ changed: boolean;
124
+ packageJson: PackageJson;
125
+ pnpmWorkspaceYaml: string | null;
126
+ };
127
+
128
+ export function syncStarterManifestFiles(options: {
129
+ starterPackageJsonPath: string;
130
+ starterPnpmWorkspacePath?: string;
131
+ repoRoot?: string;
132
+ write?: boolean;
133
+ }): SyncStarterManifestResult {
134
+ const { packageJson: canonical, pnpmWorkspaceYaml } =
135
+ generateStandaloneChatManifest(options.repoRoot);
136
+
137
+ const starterPackageJson = JSON.parse(
138
+ fs.readFileSync(options.starterPackageJsonPath, "utf-8"),
139
+ ) as PackageJson;
140
+ const mergedPackageJson = mergeStarterManifest(starterPackageJson, canonical);
141
+
142
+ let workspaceChanged = false;
143
+ const existingWorkspace =
144
+ options.starterPnpmWorkspacePath &&
145
+ fs.existsSync(options.starterPnpmWorkspacePath)
146
+ ? fs.readFileSync(options.starterPnpmWorkspacePath, "utf-8")
147
+ : null;
148
+
149
+ workspaceChanged = workspaceFileSyncChanged(
150
+ existingWorkspace,
151
+ pnpmWorkspaceYaml,
152
+ );
153
+
154
+ const packageChanged =
155
+ stableJson(starterPackageJson) !== stableJson(mergedPackageJson);
156
+ const changed = packageChanged || workspaceChanged;
157
+
158
+ if (options.write && changed) {
159
+ if (packageChanged) {
160
+ fs.writeFileSync(
161
+ options.starterPackageJsonPath,
162
+ stableJson(mergedPackageJson),
163
+ );
164
+ }
165
+ if (workspaceChanged && options.starterPnpmWorkspacePath) {
166
+ applyWorkspaceFileSync(
167
+ options.starterPnpmWorkspacePath,
168
+ pnpmWorkspaceYaml,
169
+ );
170
+ }
171
+ }
172
+
173
+ return {
174
+ changed,
175
+ packageJson: mergedPackageJson,
176
+ pnpmWorkspaceYaml,
177
+ };
178
+ }
179
+
180
+ export function parseSyncStarterManifestArgs(argv: string[]): {
181
+ command: "merge" | "generate";
182
+ starterPackageJsonPath?: string;
183
+ starterPnpmWorkspacePath?: string;
184
+ write: boolean;
185
+ repoRoot?: string;
186
+ } {
187
+ const [commandRaw, ...rest] = argv;
188
+ const command = commandRaw === "generate" ? "generate" : "merge";
189
+ let starterPackageJsonPath: string | undefined;
190
+ let starterPnpmWorkspacePath: string | undefined;
191
+ let write = false;
192
+ let repoRoot: string | undefined;
193
+
194
+ for (let i = 0; i < rest.length; i++) {
195
+ const arg = rest[i];
196
+ if (arg === "--write") {
197
+ write = true;
198
+ continue;
199
+ }
200
+ if (arg === "--starter-package-json") {
201
+ starterPackageJsonPath = rest[++i];
202
+ continue;
203
+ }
204
+ if (arg === "--starter-pnpm-workspace") {
205
+ starterPnpmWorkspacePath = rest[++i];
206
+ continue;
207
+ }
208
+ if (arg === "--repo-root") {
209
+ repoRoot = rest[++i];
210
+ continue;
211
+ }
212
+ throw new Error(`Unknown argument: ${arg}`);
213
+ }
214
+
215
+ if (command === "merge" && !starterPackageJsonPath) {
216
+ throw new Error(
217
+ "merge requires --starter-package-json <path> [--starter-pnpm-workspace <path>] [--write] [--repo-root <path>]",
218
+ );
219
+ }
220
+
221
+ return {
222
+ command,
223
+ starterPackageJsonPath,
224
+ starterPnpmWorkspacePath,
225
+ write,
226
+ repoRoot,
227
+ };
228
+ }
229
+
230
+ export function runSyncStarterManifestCli(argv: string[]): number {
231
+ const args = parseSyncStarterManifestArgs(argv);
232
+
233
+ if (args.command === "generate") {
234
+ const { packageJson, pnpmWorkspaceYaml } = generateStandaloneChatManifest(
235
+ args.repoRoot,
236
+ );
237
+ process.stdout.write(stableJson(packageJson));
238
+ if (pnpmWorkspaceYaml) {
239
+ process.stdout.write("\n--- pnpm-workspace.yaml ---\n");
240
+ process.stdout.write(pnpmWorkspaceYaml);
241
+ }
242
+ return 0;
243
+ }
244
+
245
+ const result = syncStarterManifestFiles({
246
+ starterPackageJsonPath: args.starterPackageJsonPath!,
247
+ starterPnpmWorkspacePath: args.starterPnpmWorkspacePath,
248
+ repoRoot: args.repoRoot,
249
+ write: args.write,
250
+ });
251
+
252
+ if (result.changed) {
253
+ console.log(
254
+ args.write
255
+ ? "Updated builder-agent-native-starter manifest from templates/chat."
256
+ : "builder-agent-native-starter manifest is out of date with templates/chat.",
257
+ );
258
+ return args.write ? 0 : 1;
259
+ }
260
+
261
+ console.log(
262
+ "builder-agent-native-starter manifest already matches templates/chat.",
263
+ );
264
+ return 0;
265
+ }
266
+
267
+ const isDirectRun =
268
+ process.argv[1] &&
269
+ path.resolve(process.argv[1]) ===
270
+ path.resolve(fileURLToPath(import.meta.url));
271
+
272
+ if (isDirectRun) {
273
+ const exitCode = runSyncStarterManifestCli(process.argv.slice(2));
274
+ process.exit(exitCode);
275
+ }
@@ -103,6 +103,34 @@ function formatYValue(
103
103
  return value.toLocaleString();
104
104
  }
105
105
 
106
+ /**
107
+ * Format a single metric value for display. Coerces Postgres numeric/bigint
108
+ * columns (returned as strings, e.g. a rate of "0.00000000000000000000") to a
109
+ * number so the formatter applies — SQLite returns JS numbers, so this only
110
+ * bites on Postgres/Neon, where the raw high-scale decimal would otherwise be
111
+ * dumped verbatim. A configured `valueLabels` mapping wins; a non-numeric
112
+ * string falls through unformatted.
113
+ */
114
+ export function formatMetricValue(
115
+ raw: unknown,
116
+ formatter?: "number" | "currency" | "percent",
117
+ valueLabels?: Record<string, string>,
118
+ ): string {
119
+ const valueLabel = valueLabels?.[String(raw)];
120
+ if (valueLabel !== undefined) return valueLabel;
121
+ const numericRaw =
122
+ typeof raw === "number"
123
+ ? raw
124
+ : typeof raw === "string" &&
125
+ raw.trim() !== "" &&
126
+ Number.isFinite(Number(raw))
127
+ ? Number(raw)
128
+ : null;
129
+ return numericRaw !== null
130
+ ? formatYValue(numericRaw, formatter)
131
+ : String(raw ?? "-");
132
+ }
133
+
106
134
  function parsePrometheusSeriesLabel(label: string): {
107
135
  metric: string;
108
136
  labels: Record<string, string>;
@@ -647,12 +675,11 @@ function MetricRenderer({
647
675
  } else {
648
676
  raw = row[valueCol];
649
677
  }
650
- const valueLabel = panel.config?.valueLabels?.[String(raw)];
651
- const value =
652
- valueLabel ??
653
- (typeof raw === "number"
654
- ? formatYValue(raw, panel.config?.yFormatter)
655
- : String(raw ?? "-"));
678
+ const value = formatMetricValue(
679
+ raw,
680
+ panel.config?.yFormatter,
681
+ panel.config?.valueLabels,
682
+ );
656
683
 
657
684
  return (
658
685
  <div className="flex flex-1 flex-col items-center justify-center py-2 text-center">
@@ -162,8 +162,14 @@ const RETRYABLE_CHUNK_UPLOAD_STATUSES = new Set([
162
162
  const SCREEN_CAPTURE_FRAME_RATE = 24;
163
163
  const SCREEN_CAPTURE_MAX_WIDTH = 1920;
164
164
  const SCREEN_CAPTURE_MAX_HEIGHT = 1080;
165
- const RECORDING_VIDEO_BITRATE_BPS = 1_200_000;
166
- const RECORDING_AUDIO_BITRATE_BPS = 96_000;
165
+ // Capture quality for the browser MediaRecorder. We no longer shrink files
166
+ // client-side (ffmpeg.wasm re-encode is disabled — see COMPRESSION_ENABLED in
167
+ // `@/lib/compress`) and the upload provider streams large files directly, so we
168
+ // capture at a crisp 1080p bitrate instead of a tight budget. 1.2 Mbps left
169
+ // dense UI (fine text, Figma, code) visibly fuzzy; 8 Mbps keeps it sharp.
170
+ // Dial down here if file sizes become a concern for a deployment.
171
+ const RECORDING_VIDEO_BITRATE_BPS = 8_000_000;
172
+ const RECORDING_AUDIO_BITRATE_BPS = 128_000;
167
173
  type CaptureSource = "screen" | "camera" | "microphone" | "unknown";
168
174
 
169
175
  const VOICE_FOCUSED_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
@@ -927,16 +927,8 @@ export default function RecordingPage() {
927
927
  <TabsTrigger value="agent" className="min-w-0 px-2 text-xs">
928
928
  Agent
929
929
  </TabsTrigger>
930
- <TabsTrigger
931
- value="comments"
932
- className="min-w-0 gap-1 px-2 text-xs"
933
- >
930
+ <TabsTrigger value="comments" className="min-w-0 px-2 text-xs">
934
931
  Activity
935
- {comments.length > 0 ? (
936
- <span className="ml-0.5 rounded-full bg-accent px-1.5 text-[10px] tabular-nums">
937
- {comments.length}
938
- </span>
939
- ) : null}
940
932
  </TabsTrigger>
941
933
  <TabsTrigger value="transcript" className="min-w-0 px-2 text-xs">
942
934
  Transcript
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-23
4
+ ---
5
+
6
+ Screen recordings now capture at a crisp 1080p bitrate instead of a compressed, fuzzy one
@@ -5,6 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite build --watch",
8
+ "dev:hot": "tsx scripts/dev.ts",
8
9
  "build": "vite build",
9
10
  "typecheck": "tsc --noEmit",
10
11
  "package": "pnpm build && tsx scripts/package.ts"
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Dev loop with auto-reload for the unpacked extension.
3
+ *
4
+ * MV3 has no built-in hot reload for unpacked extensions, so this script:
5
+ * 1. runs `vite build --watch` (rebuilds dist/ on every source change), and
6
+ * 2. serves a tiny localhost stream that emits "reload" after each rebuild.
7
+ *
8
+ * The background service worker (in dev / unpacked only) holds that stream open
9
+ * and calls chrome.runtime.reload() when it sees "reload" — so saving a file
10
+ * rebuilds AND reloads the extension with no clicking in chrome://extensions.
11
+ * The open fetch also keeps the worker alive while you iterate.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import http from "node:http";
15
+ import { watch } from "node:fs";
16
+ import { dirname, resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
20
+ const distDir = resolve(root, "dist");
21
+ const PORT = 8123;
22
+
23
+ const clients = new Set<http.ServerResponse>();
24
+
25
+ const server = http.createServer((req, res) => {
26
+ if (req.url === "/dev-reload-stream") {
27
+ res.writeHead(200, {
28
+ "Content-Type": "text/plain; charset=utf-8",
29
+ "Cache-Control": "no-store",
30
+ "Access-Control-Allow-Origin": "*",
31
+ Connection: "keep-alive",
32
+ });
33
+ res.write("connected\n");
34
+ clients.add(res);
35
+ req.on("close", () => clients.delete(res));
36
+ return;
37
+ }
38
+ res.writeHead(404);
39
+ res.end();
40
+ });
41
+
42
+ server.on("error", (err) => {
43
+ console.error(`[clips-dev] reload server error:`, err);
44
+ });
45
+
46
+ server.listen(PORT, () => {
47
+ console.log(`[clips-dev] reload server → http://localhost:${PORT}`);
48
+ console.log(
49
+ `[clips-dev] load ${distDir} as an unpacked extension; it auto-reloads on rebuild.`,
50
+ );
51
+ });
52
+
53
+ let debounce: ReturnType<typeof setTimeout> | undefined;
54
+ function notifyReload(): void {
55
+ clearTimeout(debounce);
56
+ debounce = setTimeout(() => {
57
+ if (clients.size === 0) return;
58
+ for (const res of clients) {
59
+ try {
60
+ res.write("reload\n");
61
+ } catch {
62
+ /* client went away */
63
+ }
64
+ }
65
+ console.log(
66
+ `[clips-dev] rebuild → reload sent to ${clients.size} client(s)`,
67
+ );
68
+ }, 250);
69
+ }
70
+
71
+ // Vite owns the actual build; we watch its output so the reload fires only after
72
+ // dist/ is fully rewritten.
73
+ const vite = spawn("pnpm", ["exec", "vite", "build", "--watch"], {
74
+ cwd: root,
75
+ stdio: "inherit",
76
+ });
77
+ vite.on("exit", (code) => {
78
+ console.log(`[clips-dev] vite exited (${code ?? 0})`);
79
+ server.close();
80
+ process.exit(code ?? 0);
81
+ });
82
+
83
+ let watching = false;
84
+ function startWatchingDist(): void {
85
+ if (watching) return;
86
+ try {
87
+ watch(distDir, { recursive: true }, () => notifyReload());
88
+ watching = true;
89
+ } catch {
90
+ // dist may not exist yet on the very first run — retry shortly.
91
+ setTimeout(startWatchingDist, 500);
92
+ }
93
+ }
94
+ startWatchingDist();
95
+
96
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
97
+ process.on(signal, () => {
98
+ vite.kill();
99
+ server.close();
100
+ process.exit(0);
101
+ });
102
+ }
@@ -2027,3 +2027,37 @@ chrome.debugger.onDetach.addListener((source) => {
2027
2027
  if (session) session.attached = false;
2028
2028
  tabToSession.delete(tabId);
2029
2029
  });
2030
+
2031
+ // ---- Dev auto-reload (unpacked installs only) ------------------------------
2032
+ // `pnpm dev:hot` runs a localhost server that streams "reload" after each
2033
+ // rebuild; we hold the stream open and reload the extension when it fires, so
2034
+ // edits land without touching chrome://extensions. The open fetch also keeps
2035
+ // this worker alive while iterating. No-ops for packed/Web Store installs
2036
+ // (they have an update_url) and quietly retries when no dev server is running.
2037
+ function startDevHotReload(): void {
2038
+ if ("update_url" in chrome.runtime.getManifest()) return;
2039
+ const url = "http://localhost:8123/dev-reload-stream";
2040
+ const connect = (): void => {
2041
+ fetch(url, { cache: "no-store" })
2042
+ .then(async (res) => {
2043
+ const reader = res.body?.getReader();
2044
+ if (!reader) {
2045
+ setTimeout(connect, 2500);
2046
+ return;
2047
+ }
2048
+ const decoder = new TextDecoder();
2049
+ for (;;) {
2050
+ const { value, done } = await reader.read();
2051
+ if (done) break;
2052
+ if (decoder.decode(value, { stream: true }).includes("reload")) {
2053
+ chrome.runtime.reload();
2054
+ return;
2055
+ }
2056
+ }
2057
+ setTimeout(connect, 1000);
2058
+ })
2059
+ .catch(() => setTimeout(connect, 2500));
2060
+ };
2061
+ connect();
2062
+ }
2063
+ startDevHotReload();
@@ -417,7 +417,9 @@ async function begin(message: BeginMessage): Promise<{
417
417
  const mimeType = chooseMimeType();
418
418
  const recorder = new MediaRecorder(outputStream, {
419
419
  mimeType,
420
- videoBitsPerSecond: 4_000_000,
420
+ // Crisp 1080p capture — matches the web/desktop recorders. Files upload
421
+ // directly (no client-side shrink), so we favor sharpness over a budget.
422
+ videoBitsPerSecond: 8_000_000,
421
423
  audioBitsPerSecond: 128_000,
422
424
  });
423
425
 
@@ -88,9 +88,13 @@ const LIVE_UPLOAD_CHUNK_MS = 1_000;
88
88
  const CLOUD_CAPTURE_FRAME_RATE = 24;
89
89
  const CLOUD_CAPTURE_MAX_WIDTH = 1920;
90
90
  const CLOUD_CAPTURE_MAX_HEIGHT = 1080;
91
- const CLOUD_RECORDING_MAX_LONG_EDGE = 1280;
92
- const CLOUD_RECORDING_VIDEO_BITRATE_BPS = 900_000;
93
- const CLOUD_RECORDING_AUDIO_BITRATE_BPS = 96_000;
91
+ // Crisp capture for the desktop browser MediaRecorder fallback. Files are no
92
+ // longer shrunk client-side and the upload provider streams large files, so we
93
+ // keep full 1080p (was downscaled to a 1280 long edge) and a sharp bitrate (was
94
+ // 900 kbps, which left UI and text fuzzy). Dial down if file size matters.
95
+ const CLOUD_RECORDING_MAX_LONG_EDGE = 1920;
96
+ const CLOUD_RECORDING_VIDEO_BITRATE_BPS = 8_000_000;
97
+ const CLOUD_RECORDING_AUDIO_BITRATE_BPS = 128_000;
94
98
 
95
99
  function isMacPlatform(): boolean {
96
100
  if (typeof navigator === "undefined") return false;
@@ -38,9 +38,9 @@ const COMPRESSION_ENABLED: bool = true;
38
38
  const TRANSCODE_THRESHOLD_BYTES: u64 = 24 * 1024 * 1024;
39
39
  const TARGET_UPLOAD_BYTES: u64 = 18 * 1024 * 1024;
40
40
  // Mirror of the shared `MAX_UPLOAD_BYTES` limit (see
41
- // `templates/clips/shared/upload-limits.ts`). Same default (256 MB) and same
41
+ // `templates/clips/shared/upload-limits.ts`). Same default (2 GB) and same
42
42
  // env var (CLIPS_MAX_UPLOAD_BYTES) so desktop and web stay in lockstep.
43
- const DEFAULT_MAX_UPLOAD_BYTES: u64 = 256 * 1024 * 1024;
43
+ const DEFAULT_MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
44
44
  const MIN_TRANSCODE_VIDEO_RATE_KBPS: u32 = 350;
45
45
  const TRANSCODE_RATE_LIMIT_OVERHEAD_KBPS: f64 = 64.0;
46
46
  const TRANSCODE_FRAME_RATE_LIMIT: u32 = 30;
@@ -405,7 +405,15 @@ export default defineEventHandler(async (event: H3Event) => {
405
405
  return loomEmbedResponse(embedUrl);
406
406
  }
407
407
 
408
- const blob = await readAppState(`recording-blob-${recordingId}`);
408
+ // The `recording-blob-*` fallback only exists for local/dev recordings
409
+ // (production uses provider storage), and `readAppState` THROWS when there
410
+ // is no authenticated identity in context. An anonymous viewer of a public
411
+ // clip therefore has no blob to read anyway — swallow the missing-context
412
+ // error and fall through to the provider media URL instead of surfacing an
413
+ // unhandled 500 ("Could not start playback. Try again." in the player).
414
+ const blob = await readAppState(`recording-blob-${recordingId}`).catch(
415
+ () => null,
416
+ );
409
417
  const b64 = typeof blob?.data === "string" ? blob.data : null;
410
418
  const rangeHeader = getRequestHeader(event, "range");
411
419
 
@@ -11,8 +11,11 @@
11
11
  * `desktop/src-tauri/src/native_screen.rs` (same env var, same default).
12
12
  */
13
13
 
14
- /** Default maximum upload size: 256 MB. */
15
- export const DEFAULT_MAX_UPLOAD_BYTES = 256 * 1024 * 1024;
14
+ /** Default maximum upload size: 2 GB. The upload provider streams large files
15
+ * directly, so this is a generous safety ceiling rather than a hard product
16
+ * limit. It mainly bounds how long a high-bitrate (crisp 1080p) recording can
17
+ * run before it must be split — ~34 min at the 8 Mbps capture default. */
18
+ export const DEFAULT_MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
16
19
 
17
20
  /** Env var that overrides the upload ceiling, in bytes. */
18
21
  export const MAX_UPLOAD_BYTES_ENV = "CLIPS_MAX_UPLOAD_BYTES";