@1agh/maude 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -0,0 +1,257 @@
1
+ // generation/whisper-models.ts — managed local whisper.cpp GGML models (Task 2.7,
2
+ // approach A, DDR-164). The "one-click local subtitles" story WITHOUT a heavy
3
+ // WASM dependency: keep the fast native whisper.cpp engine, but remove the
4
+ // hand-fetch-a-ggml-from-Hugging-Face friction the owner hit in testing — a
5
+ // Settings button downloads a model into a Maude-managed, gitignored cache and
6
+ // `maude design transcribe --provider whisper` auto-resolves it (no --model).
7
+ //
8
+ // The whisper.cpp BINARY is still a soft dep (brew / build); this closes the
9
+ // MODEL half of the friction. Models live OUTSIDE any served project tree (a
10
+ // per-machine cache, re-downloadable), never in `.design/`, never committed.
11
+ //
12
+ // This module owns the model REGISTRY + dir + list/resolve (fs, server-side).
13
+ // The actual download (streamed, SSRF-hardened, progress-tracked) lives in the
14
+ // http route so the egress discipline sits next to the other provider egress.
15
+
16
+ import { existsSync, readdirSync, statSync } from 'node:fs';
17
+ import { mkdir, rename, rm } from 'node:fs/promises';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ export interface WhisperModelDescriptor {
22
+ /** Stable id used by the config + route (`base`, `base.en`, …). */
23
+ id: string;
24
+ /** The on-disk ggml filename. */
25
+ file: string;
26
+ label: string;
27
+ /** Approximate download size, for the consent copy + the download size cap. */
28
+ sizeMB: number;
29
+ /** Multilingual vs English-only (the owner's Czech-footage gotcha: `.en`
30
+ * garbles non-English — surface this in the picker). */
31
+ multilingual: boolean;
32
+ note: string;
33
+ }
34
+
35
+ // The subset of ggerganov/whisper.cpp ggml models Maude offers. Kept small +
36
+ // curated (a fixed allowlist — the download URL is derived from `file`, never
37
+ // user input, so this is also the SSRF allowlist for the model host).
38
+ export const WHISPER_MODELS: readonly WhisperModelDescriptor[] = [
39
+ {
40
+ id: 'tiny',
41
+ file: 'ggml-tiny.bin',
42
+ label: 'Tiny (multilingual)',
43
+ sizeMB: 75,
44
+ multilingual: true,
45
+ note: 'Fastest, lowest accuracy. Good for a quick draft in any language.',
46
+ },
47
+ {
48
+ id: 'base',
49
+ file: 'ggml-base.bin',
50
+ label: 'Base (multilingual)',
51
+ sizeMB: 142,
52
+ multilingual: true,
53
+ note: 'The recommended default — works in any language (incl. Czech). Modest accuracy.',
54
+ },
55
+ {
56
+ id: 'base.en',
57
+ file: 'ggml-base.en.bin',
58
+ label: 'Base (English-only)',
59
+ sizeMB: 142,
60
+ multilingual: false,
61
+ note: 'English audio only — do NOT use for other languages (it garbles them).',
62
+ },
63
+ {
64
+ id: 'small',
65
+ file: 'ggml-small.bin',
66
+ label: 'Small (multilingual)',
67
+ sizeMB: 466,
68
+ multilingual: true,
69
+ note: 'Noticeably more accurate than base, ~3× larger.',
70
+ },
71
+ {
72
+ id: 'large-v3-turbo',
73
+ file: 'ggml-large-v3-turbo.bin',
74
+ label: 'Large v3 Turbo (multilingual)',
75
+ sizeMB: 1560,
76
+ multilingual: true,
77
+ note: 'Best accuracy, ~1.5 GB. Slower and disk-heavy, but close to cloud quality.',
78
+ },
79
+ ];
80
+
81
+ const MODEL_HOST = 'https://huggingface.co';
82
+ const MODEL_REPO_PATH = '/ggerganov/whisper.cpp/resolve/main';
83
+
84
+ /** The fixed, non-interpolated download URL for a model (SSRF-safe — the file
85
+ * comes from the frozen registry above, never from a request). */
86
+ export function whisperModelUrl(m: WhisperModelDescriptor): string {
87
+ return `${MODEL_HOST}${MODEL_REPO_PATH}/${m.file}`;
88
+ }
89
+
90
+ export function getWhisperModel(id: unknown): WhisperModelDescriptor | null {
91
+ return WHISPER_MODELS.find((m) => m.id === id) ?? null;
92
+ }
93
+
94
+ /** Maude-managed model cache dir — per-machine, gitignored, never in a project.
95
+ * XDG_CACHE_HOME-aware (falls back to ~/.cache), mirroring keys.ts's XDG logic
96
+ * but a CACHE location (large, re-downloadable) not a config one. */
97
+ export function whisperModelsDir(): string {
98
+ const xdg = process.env.XDG_CACHE_HOME;
99
+ const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
100
+ return join(base, 'maude', 'whisper-models');
101
+ }
102
+
103
+ export interface WhisperModelStatus extends WhisperModelDescriptor {
104
+ downloaded: boolean;
105
+ /** Absolute path when downloaded. */
106
+ path?: string;
107
+ /** Actual on-disk bytes when downloaded (for the "Remove" affordance). */
108
+ bytes?: number;
109
+ }
110
+
111
+ /** The absolute on-disk path a model WOULD live at (whether present or not). */
112
+ export function whisperModelPath(m: WhisperModelDescriptor): string {
113
+ return join(whisperModelsDir(), m.file);
114
+ }
115
+
116
+ /** Every registered model + whether it's already downloaded. */
117
+ export function listWhisperModels(): WhisperModelStatus[] {
118
+ return WHISPER_MODELS.map((m) => {
119
+ const p = whisperModelPath(m);
120
+ let downloaded = false;
121
+ let bytes: number | undefined;
122
+ try {
123
+ const st = statSync(p);
124
+ if (st.isFile() && st.size > 0) {
125
+ downloaded = true;
126
+ bytes = st.size;
127
+ }
128
+ } catch {
129
+ /* not downloaded */
130
+ }
131
+ return { ...m, downloaded, ...(downloaded ? { path: p, bytes } : {}) };
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Resolve a downloaded model's path for `maude design transcribe` (no --model
137
+ * needed once one is downloaded). Preference order: the caller-preferred id
138
+ * (from `generation.transcription.whisperModel`) → any downloaded MULTILINGUAL
139
+ * model (safe for non-English) → any downloaded model → null.
140
+ */
141
+ export function resolveWhisperModel(preferId?: string): string | null {
142
+ const dir = whisperModelsDir();
143
+ if (!existsSync(dir)) return null;
144
+ let present: Set<string>;
145
+ try {
146
+ present = new Set(readdirSync(dir));
147
+ } catch {
148
+ return null;
149
+ }
150
+ const has = (m: WhisperModelDescriptor) => present.has(m.file);
151
+ if (preferId) {
152
+ const pref = getWhisperModel(preferId);
153
+ if (pref && has(pref)) return join(dir, pref.file);
154
+ }
155
+ const multi = WHISPER_MODELS.find((m) => m.multilingual && has(m));
156
+ if (multi) return join(dir, multi.file);
157
+ const any = WHISPER_MODELS.find(has);
158
+ return any ? join(dir, any.file) : null;
159
+ }
160
+
161
+ /**
162
+ * Download one model into the managed cache, streamed with a progress callback
163
+ * (Task 2.7). Egress discipline: the URL is derived from the FROZEN registry
164
+ * (initial host is always huggingface.co, asserted https + fixed host); the
165
+ * redirect the HF blob store issues is followed but the FINAL hop is re-asserted
166
+ * https + a `*.huggingface.co` host (ethical-hacker Finding 2); the body is
167
+ * size-capped per-chunk to the model's expected size (+20% margin) before it can
168
+ * fill the disk; written to a `.part` temp and atomically renamed so a
169
+ * partial/aborted download never looks complete. Pass an AbortSignal (a timeout)
170
+ * so a stalled connection can't wedge the single download slot. Not a full
171
+ * `_fetch-asset.mjs` (no resolved-IP private-range guard) — proportionate because
172
+ * the host is pinned to HF and the route is loopback + same-origin only. Throws
173
+ * on any failure (the temp file is cleaned up); the caller owns progress state.
174
+ */
175
+ export async function downloadWhisperModel(
176
+ id: string,
177
+ onProgress: (received: number, total: number) => void,
178
+ signal?: AbortSignal
179
+ ): Promise<string> {
180
+ const m = getWhisperModel(id);
181
+ if (!m) throw new Error(`unknown whisper model: ${id}`);
182
+ const url = whisperModelUrl(m);
183
+ const u = new URL(url);
184
+ if (u.protocol !== 'https:') throw new Error('model URL must be https');
185
+ if (u.hostname !== 'huggingface.co') throw new Error('model host not allowlisted');
186
+
187
+ const dir = whisperModelsDir();
188
+ await mkdir(dir, { recursive: true });
189
+ const finalPath = join(dir, m.file);
190
+ const tmpPath = `${finalPath}.part`;
191
+
192
+ const cap = Math.ceil(m.sizeMB * 1.2) * 1024 * 1024; // expected size + 20% margin
193
+ // huggingface.co 302-redirects model blobs to its own LFS CDN, so we must
194
+ // follow — but re-assert the FINAL hop is still https + a huggingface.co host
195
+ // (ethical-hacker Finding 2): an open-redirect/on-path must not land the fetch
196
+ // on an arbitrary host. A wall-clock timeout is layered by the caller's signal.
197
+ const res = await fetch(url, { signal, redirect: 'follow' });
198
+ try {
199
+ const finalUrl = new URL(res.url || url);
200
+ if (finalUrl.protocol !== 'https:' || !/(^|\.)huggingface\.co$/.test(finalUrl.hostname))
201
+ throw new Error(`model download redirected off huggingface.co (${finalUrl.hostname})`);
202
+ } catch (err) {
203
+ if (err instanceof Error && err.message.startsWith('model download redirected')) throw err;
204
+ throw new Error('model download resolved to an invalid URL');
205
+ }
206
+ if (!res.ok) throw new Error(`model download failed: HTTP ${res.status}`);
207
+ const declared = Number(res.headers.get('content-length'));
208
+ if (Number.isFinite(declared) && declared > cap)
209
+ throw new Error(`model larger than expected (${declared} > ${cap} bytes)`);
210
+ const total = Number.isFinite(declared) && declared > 0 ? declared : m.sizeMB * 1024 * 1024;
211
+
212
+ const body = res.body;
213
+ if (!body) throw new Error('empty model response');
214
+ const reader = body.getReader();
215
+ const writer = Bun.file(tmpPath).writer();
216
+ let received = 0;
217
+ try {
218
+ for (;;) {
219
+ const { done, value } = await reader.read();
220
+ if (done) break;
221
+ if (value) {
222
+ received += value.byteLength;
223
+ if (received > cap) throw new Error(`model exceeded ${cap} bytes`);
224
+ writer.write(value);
225
+ onProgress(received, total);
226
+ }
227
+ }
228
+ await writer.end();
229
+ if (received === 0) throw new Error('model download was empty');
230
+ await rename(tmpPath, finalPath);
231
+ return finalPath;
232
+ } catch (err) {
233
+ try {
234
+ await writer.end();
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ await rm(tmpPath, { force: true }).catch(() => {});
239
+ throw err;
240
+ } finally {
241
+ try {
242
+ reader.releaseLock();
243
+ } catch {
244
+ /* already released */
245
+ }
246
+ }
247
+ }
248
+
249
+ /** Remove a downloaded model (reclaim disk). Returns true if a file was removed. */
250
+ export async function removeWhisperModel(id: string): Promise<boolean> {
251
+ const m = getWhisperModel(id);
252
+ if (!m) return false;
253
+ const p = whisperModelPath(m);
254
+ if (!existsSync(p)) return false;
255
+ await rm(p, { force: true });
256
+ return true;
257
+ }
@@ -0,0 +1,61 @@
1
+ # Task 2.7 feasibility spike — transformers.js whisper (word timestamps in the sidecar)
2
+
3
+ **Date:** 2026-07-11 · **Spike verdict: B is functionally feasible.** · **Build outcome: approach A shipped** (B blocked on a native-dep / DDR-070 conflict — see the resolution note at the bottom).
4
+
5
+ The plan gated the one-click local-subtitle build on a spike (DDR-164 Open-Question 7):
6
+ in-process transformers.js whisper must produce word-level timestamps, at tolerable
7
+ speed + memory, running MAIN-origin / in the sidecar (not the untrusted canvas realm).
8
+
9
+ ## Result (Node sidecar, `@huggingface/transformers` v3, `onnx-community/whisper-base_timestamped`, q8)
10
+
11
+ | Criterion | Measured | Gate |
12
+ | --- | --- | --- |
13
+ | Word-level timestamps | `hasWordTimestamps: true` — `"And" [0.22–0.52] "so" [0.52–0.84] "my" [0.84–1.2]…` | ✅ |
14
+ | Transcript accuracy | "And so my fellow Americans ask not what your country can do for you…" (JFK 11s clip, verbatim) | ✅ |
15
+ | Speed | 2.66× realtime (4.14 s for 11.0 s audio) | ✅ tolerable; show an ETA for long clips |
16
+ | Memory | ~676 MB peak RSS | ✅ fits |
17
+ | In-process, main-origin | Node/Bun via onnxruntime-node — no browser CSP surface | ✅ |
18
+
19
+ ## Build notes (feed the DDR + the implementation)
20
+
21
+ 1. **Must use a `_timestamped` model variant.** Plain `onnx-community/whisper-base`
22
+ throws `Model outputs must contain cross attentions to extract timestamps` —
23
+ word timestamps need the cross-attention export. Use `onnx-community/whisper-*_timestamped`.
24
+ 2. **Model size default.** `base` (q8) is the sweet spot for the WASM/sidecar default
25
+ (accuracy vs download size vs the 2.66× speed). Offer `tiny`/`small` in the size picker.
26
+ 3. **onnxruntime-node teardown quirk.** A cosmetic `mutex lock failed` SIGABRT fires on
27
+ process exit AFTER all output is produced — the shim must `process.exit(0)` cleanly
28
+ once the SRT is written (the inference itself is unaffected).
29
+ 4. **Packaging fork (the genuinely consequential decision — DDR-worthy).** transformers.js
30
+ in Node defaults to **onnxruntime-node** (native `.node` binaries) — fast, but a heavy
31
+ per-platform native dep that fights the `bun --compile` standalone-binary distribution
32
+ (DDR-045) and bloats the npm tarball. Alternatives that avoid that: (a) transformers.js
33
+ with **onnxruntime-web (WASM)** in the browser proof-canvas harness — mirrors the shipped
34
+ `@imgly/background-removal` precedent (DDR-161) exactly, no native dep, but slower and
35
+ in a browser context; (b) native **whisper.cpp + a managed model-download button** (the
36
+ plan's fallback A) — fastest, but needs the binary. This fork is surfaced to the owner
37
+ before the heavy dep ships to every user.
38
+
39
+ ## Repro
40
+
41
+ `scratchpad/whisper-spike/spike3.mjs` (stereo→mono + 44.1k→16k resample; the earlier
42
+ garbled "[BIRDS CHIRPING]" run was a WAV-parse bug in the harness, not the model).
43
+
44
+ ## Resolution (2026-07-11) — shipped approach A, not B
45
+
46
+ Building B revealed the packaging fork wasn't a preference but a **blocker**:
47
+ `@huggingface/transformers@3.8.1` hard-depends on **`sharp` + `onnxruntime-node`**,
48
+ the exact native "bun-compile-hostile" class **DDR-070 excludes**. A clean `npm add`
49
+ is impossible; B would require vendoring the prebuilt browser bundle + allowlisting
50
+ `cdn.jsdelivr.net` (WASM) and `huggingface.co` (models) in the canvas CSP — a
51
+ DDR-worthy vendoring + CDN commitment. `device:'wasm'` is also unsupported in the
52
+ transformers.js **Node** build (only `cpu` = onnxruntime-node), so sidecar-WASM is
53
+ out — B would have to run in a browser proof-canvas harness (the @imgly pattern).
54
+
55
+ **Owner chose approach A:** keep the fast native whisper.cpp engine (already validated
56
+ on real footage) and remove the *friction* instead — a one-click managed model
57
+ download (`generation/whisper-models.ts` + `/_api/generate/whisper-model` + a Settings
58
+ card), `--provider whisper` model auto-resolution, and auto-ffmpeg container decode.
59
+ No new runtime dependency; native binary still required (brew / build). True
60
+ binary-free zero-install (B) stays a documented future option behind the vendoring
61
+ decision.
@@ -209,7 +209,9 @@ function underPrefix(filepath: string, prefix: string): boolean {
209
209
  * double-transports through git. */
210
210
  function isMaudeRuntimeState(p: string): boolean {
211
211
  return (
212
- /(^|\/)_(?:server|active|sync|preflight|locator|export-history)\.json$/.test(p) ||
212
+ /(^|\/)_(?:server|active|sync|preflight|locator|export-history|generate-history)\.json$/.test(
213
+ p
214
+ ) ||
213
215
  /(^|\/)_server\.(?:lock|log)$/.test(p) ||
214
216
  /(^|\/)_(?:history|trash|draw|photo|smoke|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
215
217
  p