@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
@@ -34,9 +34,17 @@ import {
34
34
  retimeSequence,
35
35
  retimeSequenceByClip,
36
36
  editText as runEditText,
37
+ setArtboardHug,
38
+ setArtboardStyle,
37
39
  toggleClipHidden,
38
40
  } from './canvas-edit.ts';
39
41
  import type { Context } from './context.ts';
42
+ import {
43
+ type AudioMatch,
44
+ type Candidate,
45
+ rankMatches,
46
+ sanitizeReuseText,
47
+ } from './generation/audio-library.ts';
40
48
  import { createHistory } from './history.ts';
41
49
  import { STICKERS_DIR } from './paths.ts';
42
50
 
@@ -257,6 +265,29 @@ export interface Api {
257
265
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
258
266
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
259
267
  listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
268
+ /** feature-ai-media-generation (Task 1.2) — read a content-addressed
269
+ * `assets/<sha8>.<ext>` source's bytes + sniffed mime for the image-edit /
270
+ * image-to-video generation flows. Contained to <designRoot>/assets/;
271
+ * null for an unknown/contained-out path. */
272
+ readAssetBytes(rel: unknown): Promise<{ bytes: Uint8Array; mime: string } | null>;
273
+ /** feature-ai-media-generation (Task 2.6) — write a caption sidecar
274
+ * (`assets/<sha8>.srt|.vtt`) next to a content-addressed source, so a cloud
275
+ * STT result lands where the local whisper path also writes it. Contained to
276
+ * <designRoot>/assets/; text byte-capped; format allowlisted. */
277
+ writeCaptionSidecar(
278
+ sourceRel: unknown,
279
+ format: unknown,
280
+ text: unknown
281
+ ): Promise<{ ok: boolean; path?: string; error?: string }>;
282
+ /** feature-ai-media-generation (Task 2.5) — write the audio-intent sidecar
283
+ * (`assets/<sha8>.audio.json`) for reuse-before-you-pay search. */
284
+ writeAudioIntent(
285
+ assetRel: unknown,
286
+ meta: { kind?: string; prompt?: string; provider?: string; model?: string; at?: string }
287
+ ): Promise<{ ok: boolean; path?: string; error?: string }>;
288
+ /** feature-ai-media-generation (Task 2.5) — keyword-search the project's own
289
+ * generated audio by recorded intent; ranked reuse candidates. */
290
+ searchAudioLibrary(query: unknown, limit?: number): Promise<AudioMatch[]>;
260
291
  /** Phase 4 (feature-whiteboard-annotation-improvements) — the bundled sticker
261
292
  * catalogue (MAUDE's own, not the served project's) for the StickerPicker. */
262
293
  listStickers(): Promise<{ ok: true; packs: StickerPack[] }>;
@@ -441,6 +472,22 @@ export interface Api {
441
472
  width?: unknown;
442
473
  height?: unknown;
443
474
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
475
+ /** Toggle an artboard's Hug/Fixed height sizing mode (CSS-panel control). */
476
+ setArtboardHugOp(input: {
477
+ canvas?: unknown;
478
+ artboardId?: unknown;
479
+ fixed?: unknown;
480
+ freezeHeight?: unknown;
481
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
482
+ /** Set artboard "more settings" — background / padding / layout / gap. */
483
+ setArtboardStyleOp(input: {
484
+ canvas?: unknown;
485
+ artboardId?: unknown;
486
+ background?: unknown;
487
+ padding?: unknown;
488
+ layout?: unknown;
489
+ gap?: unknown;
490
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
444
491
  /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
445
492
  duplicateElementOp(input: {
446
493
  canvas?: unknown;
@@ -588,7 +635,7 @@ export interface AssetListing {
588
635
  path: string;
589
636
  name: string;
590
637
  ext: string;
591
- kind: 'image' | 'video';
638
+ kind: 'image' | 'video' | 'audio';
592
639
  size: number;
593
640
  mtimeMs: number;
594
641
  }
@@ -1531,6 +1578,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1531
1578
  const assetsDir = path.join(paths.designRoot, 'assets');
1532
1579
  const IMG = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg']);
1533
1580
  const VID = new Set(['.mp4', '.webm', '.mov', '.m4v', '.ogg']);
1581
+ // feature-ai-media-generation Phase 2 — generated audio (ElevenLabs music /
1582
+ // SFX / voiceover) is content-addressed under assets/ like any other media;
1583
+ // surface it in the picker so a generated track can be dropped into an EDL.
1584
+ const AUD = new Set(['.mp3', '.wav', '.m4a', '.aac', '.flac', '.oga', '.opus']);
1534
1585
  let entries: string[] = [];
1535
1586
  try {
1536
1587
  entries = await readdir(assetsDir);
@@ -1540,7 +1591,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1540
1591
  const out: AssetListing[] = [];
1541
1592
  for (const name of entries) {
1542
1593
  const ext = path.extname(name).toLowerCase();
1543
- const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : null;
1594
+ const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : AUD.has(ext) ? 'audio' : null;
1544
1595
  if (!kind) continue;
1545
1596
  let size = 0;
1546
1597
  let mtimeMs = 0;
@@ -1561,6 +1612,200 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1561
1612
  return { ok: true, assets: out.slice(0, 500) };
1562
1613
  }
1563
1614
 
1615
+ // feature-ai-media-generation (Task 1.2) — read a content-addressed source
1616
+ // asset's bytes + authoritative (magic-byte-SNIFFED, not extension-trusted)
1617
+ // mime, for the image-edit / image-to-video generation flows (Nano Banana
1618
+ // maskless edit reads the source into an inlineData part). The provider adapter
1619
+ // never touches the filesystem — the generation route wires this onto
1620
+ // AdapterContext.readSourceAsset. Contained to <designRoot>/assets/ (an lstat
1621
+ // isFile guard defeats a planted symlink, mirroring listAssets); returns null
1622
+ // for an unknown / traversing / oversized path. Capped at ASSET_MAX_BYTES so a
1623
+ // huge source can't be buffered into RAM before an outbound provider POST.
1624
+ async function readAssetBytes(rel: unknown): Promise<{ bytes: Uint8Array; mime: string } | null> {
1625
+ if (typeof rel !== 'string' || !rel.startsWith('assets/')) return null;
1626
+ const name = rel.slice('assets/'.length);
1627
+ if (
1628
+ !name ||
1629
+ name.includes('/') ||
1630
+ name.includes('\\') ||
1631
+ name.includes('..') ||
1632
+ name.startsWith('_')
1633
+ )
1634
+ return null;
1635
+ const assetsDir = path.join(paths.designRoot, 'assets');
1636
+ const fileAbs = path.join(assetsDir, name);
1637
+ // Containment backstop — the name is validated above; assert the join anyway.
1638
+ if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), name)) return null;
1639
+ try {
1640
+ const st = await lstat(fileAbs);
1641
+ if (!st.isFile() || st.size > ASSET_MAX_BYTES) return null;
1642
+ } catch {
1643
+ return null;
1644
+ }
1645
+ const bytes = new Uint8Array(await readFile(fileAbs));
1646
+ const info = sniffAssetType(bytes);
1647
+ if (!info) return null; // not a recognised raster/video/audio — reject
1648
+ const mime =
1649
+ info.ext === 'jpg'
1650
+ ? 'image/jpeg'
1651
+ : info.category === 'image'
1652
+ ? `image/${info.ext}`
1653
+ : info.category === 'video'
1654
+ ? `video/${info.ext}`
1655
+ : `audio/${info.ext}`;
1656
+ return { bytes, mime };
1657
+ }
1658
+
1659
+ // feature-ai-media-generation (Task 2.6, DDR-164) — write a caption SIDECAR
1660
+ // (SRT/VTT text) next to a content-addressed source asset, so a CLOUD STT
1661
+ // result (ElevenLabs Scribe / Groq) lands in the SAME place the local whisper
1662
+ // path writes it: `assets/<sha8>.srt` beside `assets/<sha8>.<ext>`. Local +
1663
+ // cloud subtitles therefore live at one predictable path (the EDL caption
1664
+ // track / a video-comp `<track>` reads `assets/<sha8>.srt`). The caption text
1665
+ // is NOT run through the media magic-byte store (it isn't media) — it is a
1666
+ // versioned text sidecar, mirroring `.meta.json` / `.annotations.svg`.
1667
+ //
1668
+ // Contained to <designRoot>/assets/ exactly like readAssetBytes: the source
1669
+ // must be a validated `assets/<sha8>.<ext>` name, the format is allowlisted,
1670
+ // and the text is byte-capped. Returns the sidecar rel path or null on a bad
1671
+ // source / format.
1672
+ async function writeCaptionSidecar(
1673
+ sourceRel: unknown,
1674
+ format: unknown,
1675
+ text: unknown
1676
+ ): Promise<{ ok: boolean; path?: string; error?: string }> {
1677
+ if (typeof sourceRel !== 'string' || !sourceRel.startsWith('assets/'))
1678
+ return { ok: false, error: 'source must be an assets/<sha8>.<ext> path' };
1679
+ const srcName = sourceRel.slice('assets/'.length);
1680
+ if (
1681
+ !srcName ||
1682
+ srcName.includes('/') ||
1683
+ srcName.includes('\\') ||
1684
+ srcName.includes('..') ||
1685
+ srcName.startsWith('_')
1686
+ )
1687
+ return { ok: false, error: 'invalid source asset name' };
1688
+ const fmt = format === 'vtt' ? 'vtt' : format === 'srt' ? 'srt' : null;
1689
+ if (!fmt) return { ok: false, error: 'format must be srt or vtt' };
1690
+ if (typeof text !== 'string' || !text.trim()) return { ok: false, error: 'empty caption text' };
1691
+ if (text.length > 4 * 1024 * 1024) return { ok: false, error: 'caption text too large' };
1692
+
1693
+ // Strip the source extension → `<sha8>` (or the whole name if none), append
1694
+ // the caption format. A dotless source name keeps its whole name as the base.
1695
+ const dot = srcName.lastIndexOf('.');
1696
+ const base = dot > 0 ? srcName.slice(0, dot) : srcName;
1697
+ const outName = `${base}.${fmt}`;
1698
+ const assetsDir = path.join(paths.designRoot, 'assets');
1699
+ const fileAbs = path.join(assetsDir, outName);
1700
+ if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), outName))
1701
+ return { ok: false, error: 'resolved sidecar path escapes assets dir' };
1702
+ try {
1703
+ await mkdir(assetsDir, { recursive: true });
1704
+ await Bun.write(fileAbs, text);
1705
+ return { ok: true, path: `assets/${outName}` };
1706
+ } catch (err) {
1707
+ return { ok: false, error: err instanceof Error ? err.message : 'sidecar write failed' };
1708
+ }
1709
+ }
1710
+
1711
+ // feature-ai-media-generation (Task 2.5, DDR-164) — write the AUDIO INTENT
1712
+ // sidecar (`assets/<sha8>.audio.json`) next to a generated audio asset: the
1713
+ // durable, semantic "what was this audio FOR" index that reuse-before-you-pay
1714
+ // searches. Byte-content-addressing already dedups identical outputs; this adds
1715
+ // the "do we already have a warm lo-fi loop?" lookup. Contained to assets/ like
1716
+ // the caption sidecar; the intent is inert non-secret metadata.
1717
+ async function writeAudioIntent(
1718
+ assetRel: unknown,
1719
+ meta: { kind?: string; prompt?: string; provider?: string; model?: string; at?: string }
1720
+ ): Promise<{ ok: boolean; path?: string; error?: string }> {
1721
+ if (typeof assetRel !== 'string' || !assetRel.startsWith('assets/'))
1722
+ return { ok: false, error: 'asset must be an assets/<sha8>.<ext> path' };
1723
+ const name = assetRel.slice('assets/'.length);
1724
+ if (
1725
+ !name ||
1726
+ name.includes('/') ||
1727
+ name.includes('\\') ||
1728
+ name.includes('..') ||
1729
+ name.startsWith('_')
1730
+ )
1731
+ return { ok: false, error: 'invalid asset name' };
1732
+ const dot = name.lastIndexOf('.');
1733
+ const base = dot > 0 ? name.slice(0, dot) : name;
1734
+ const outName = `${base}.audio.json`;
1735
+ const assetsDir = path.join(paths.designRoot, 'assets');
1736
+ const fileAbs = path.join(assetsDir, outName);
1737
+ if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), outName))
1738
+ return { ok: false, error: 'resolved sidecar path escapes assets dir' };
1739
+ const record = {
1740
+ asset: assetRel,
1741
+ kind: meta.kind,
1742
+ prompt: typeof meta.prompt === 'string' ? meta.prompt.slice(0, 8000) : undefined,
1743
+ provider: meta.provider,
1744
+ model: meta.model,
1745
+ at: meta.at ?? new Date().toISOString(),
1746
+ };
1747
+ try {
1748
+ await mkdir(assetsDir, { recursive: true });
1749
+ await Bun.write(fileAbs, `${JSON.stringify(record, null, 2)}\n`);
1750
+ return { ok: true, path: `assets/${outName}` };
1751
+ } catch (err) {
1752
+ return { ok: false, error: err instanceof Error ? err.message : 'intent write failed' };
1753
+ }
1754
+ }
1755
+
1756
+ // feature-ai-media-generation (Task 2.5) — search the project's OWN generated
1757
+ // audio by intent (keyword overlap against the recorded prompt/kind), so the
1758
+ // reuse-first path can offer an existing track before paying for a new
1759
+ // generation. Reads the `assets/<sha8>.audio.json` sidecars; ranks with the
1760
+ // shared pure scorer (generation/audio-library.ts) so local + provider-history
1761
+ // results are comparable. Best-effort — a malformed sidecar is skipped.
1762
+ async function searchAudioLibrary(query: unknown, limit = 10): Promise<AudioMatch[]> {
1763
+ if (typeof query !== 'string' || !query.trim()) return [];
1764
+ const assetsDir = path.join(paths.designRoot, 'assets');
1765
+ let entries: string[];
1766
+ try {
1767
+ entries = await readdir(assetsDir);
1768
+ } catch {
1769
+ return [];
1770
+ }
1771
+ // F2 (ethical-hacker) — an intent sidecar is a peer-synced, hence UNTRUSTED
1772
+ // (DDR-054), file: a hostile branch peer could commit a giant `.audio.json`
1773
+ // or thousands of them to OOM the reader (the F1 RAM-DoS class, on disk). A
1774
+ // real intent record is tiny → cap the per-file read AND the number scanned.
1775
+ const MAX_SIDECAR_BYTES = 256 * 1024;
1776
+ const MAX_SIDECARS = 4000;
1777
+ const candidates: Candidate[] = [];
1778
+ let scanned = 0;
1779
+ for (const name of entries) {
1780
+ if (!name.endsWith('.audio.json')) continue;
1781
+ if (++scanned > MAX_SIDECARS) break;
1782
+ try {
1783
+ const fileAbs = path.join(assetsDir, name);
1784
+ const st = await lstat(fileAbs);
1785
+ if (!st.isFile() || st.size > MAX_SIDECAR_BYTES) continue;
1786
+ const raw = await readFile(fileAbs, 'utf8');
1787
+ const intent = JSON.parse(raw) as Record<string, unknown>;
1788
+ const asset =
1789
+ typeof intent.asset === 'string'
1790
+ ? intent.asset
1791
+ : `assets/${name.replace(/\.audio\.json$/, '')}`;
1792
+ candidates.push({
1793
+ source: 'local',
1794
+ ref: asset,
1795
+ // Sanitize the peer-synced (untrusted) prompt before it can be echoed
1796
+ // into an agent context (F3).
1797
+ text: sanitizeReuseText(intent.prompt),
1798
+ kind: typeof intent.kind === 'string' ? intent.kind : undefined,
1799
+ provider: typeof intent.provider === 'string' ? intent.provider : undefined,
1800
+ at: typeof intent.at === 'string' ? intent.at : undefined,
1801
+ });
1802
+ } catch {
1803
+ /* skip a malformed sidecar */
1804
+ }
1805
+ }
1806
+ return rankMatches(query, candidates, { limit });
1807
+ }
1808
+
1564
1809
  // Phase 4 (feature-whiteboard-annotation-improvements) — the bundled sticker
1565
1810
  // catalogue for the StickerPicker. Reads from MAUDE's OWN `STICKERS_DIR`
1566
1811
  // (paths.ts, DDR-045) — never the served project's designRoot, unlike
@@ -2660,6 +2905,137 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2660
2905
  }
2661
2906
  }
2662
2907
 
2908
+ /** Toggle an artboard's Hug/Fixed height sizing mode (CSS-panel control). */
2909
+ async function setArtboardHugOp(input: {
2910
+ canvas?: unknown;
2911
+ artboardId?: unknown;
2912
+ fixed?: unknown;
2913
+ freezeHeight?: unknown;
2914
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2915
+ const r = resolveCanvasAbs(input.canvas);
2916
+ if (!r.ok) return r;
2917
+ if (!takeStructuralToken()) return RATE_LIMITED;
2918
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2919
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2920
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2921
+ }
2922
+ if (typeof input.fixed !== 'boolean') {
2923
+ return { ok: false, status: 400, error: 'fixed (boolean) required' };
2924
+ }
2925
+ const fixed = input.fixed;
2926
+ const freezeHeight = Number.isFinite(Number(input.freezeHeight))
2927
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.freezeHeight))))
2928
+ : undefined;
2929
+ const rel = path.relative(paths.designRoot, r.abs);
2930
+ ctx.bus.emit('activity:suppress', rel);
2931
+ try {
2932
+ const before = await Bun.file(r.abs).text();
2933
+ await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
2934
+ const after = await Bun.file(r.abs).text();
2935
+ if (after === before) {
2936
+ ctx.bus.emit('activity:unsuppress', rel);
2937
+ return { ok: true };
2938
+ }
2939
+ try {
2940
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-hug');
2941
+ } catch {
2942
+ /* snapshot best-effort */
2943
+ }
2944
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2945
+ } catch (err) {
2946
+ ctx.bus.emit('activity:unsuppress', rel);
2947
+ return {
2948
+ ok: false,
2949
+ status: err instanceof CanvasEditError ? 422 : 500,
2950
+ error: err instanceof Error ? err.message : 'set-artboard-hug failed',
2951
+ };
2952
+ }
2953
+ }
2954
+
2955
+ const ARTBOARD_LAYOUT_VALUES = new Set(['block', 'flex-col', 'flex-row', 'grid']);
2956
+
2957
+ /** Set artboard "more settings" — background / padding / layout / gap. */
2958
+ async function setArtboardStyleOp(input: {
2959
+ canvas?: unknown;
2960
+ artboardId?: unknown;
2961
+ background?: unknown;
2962
+ padding?: unknown;
2963
+ layout?: unknown;
2964
+ gap?: unknown;
2965
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2966
+ const r = resolveCanvasAbs(input.canvas);
2967
+ if (!r.ok) return r;
2968
+ if (!takeStructuralToken()) return RATE_LIMITED;
2969
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2970
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2971
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2972
+ }
2973
+ const patch: {
2974
+ background?: string | null;
2975
+ padding?: number | null;
2976
+ layout?: string | null;
2977
+ gap?: number | null;
2978
+ } = {};
2979
+ if ('background' in input) {
2980
+ if (input.background === null) patch.background = null;
2981
+ else if (typeof input.background === 'string' && input.background.length <= 256) {
2982
+ patch.background = input.background;
2983
+ } else {
2984
+ return { ok: false, status: 400, error: 'invalid background' };
2985
+ }
2986
+ }
2987
+ if ('layout' in input) {
2988
+ if (input.layout === null) patch.layout = null;
2989
+ else if (typeof input.layout === 'string' && ARTBOARD_LAYOUT_VALUES.has(input.layout)) {
2990
+ patch.layout = input.layout;
2991
+ } else {
2992
+ return { ok: false, status: 400, error: 'invalid layout' };
2993
+ }
2994
+ }
2995
+ const clampBoxDim = (v: unknown): number | null | undefined => {
2996
+ if (v === null) return null;
2997
+ if (v === undefined) return undefined;
2998
+ return Number.isFinite(Number(v)) ? Math.max(0, Math.min(512, Math.round(Number(v)))) : NaN;
2999
+ };
3000
+ if ('padding' in input) {
3001
+ const p = clampBoxDim(input.padding);
3002
+ if (Number.isNaN(p)) return { ok: false, status: 400, error: 'invalid padding' };
3003
+ patch.padding = p;
3004
+ }
3005
+ if ('gap' in input) {
3006
+ const g = clampBoxDim(input.gap);
3007
+ if (Number.isNaN(g)) return { ok: false, status: 400, error: 'invalid gap' };
3008
+ patch.gap = g;
3009
+ }
3010
+ if (Object.keys(patch).length === 0) {
3011
+ return { ok: false, status: 400, error: 'no style props given' };
3012
+ }
3013
+ const rel = path.relative(paths.designRoot, r.abs);
3014
+ ctx.bus.emit('activity:suppress', rel);
3015
+ try {
3016
+ const before = await Bun.file(r.abs).text();
3017
+ await setArtboardStyle(r.abs, artboardId, patch);
3018
+ const after = await Bun.file(r.abs).text();
3019
+ if (after === before) {
3020
+ ctx.bus.emit('activity:unsuppress', rel);
3021
+ return { ok: true };
3022
+ }
3023
+ try {
3024
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-style');
3025
+ } catch {
3026
+ /* snapshot best-effort */
3027
+ }
3028
+ return { ok: true, seq: logUndo(r.abs, before, after) };
3029
+ } catch (err) {
3030
+ ctx.bus.emit('activity:unsuppress', rel);
3031
+ return {
3032
+ ok: false,
3033
+ status: err instanceof CanvasEditError ? 422 : 500,
3034
+ error: err instanceof Error ? err.message : 'set-artboard-style failed',
3035
+ };
3036
+ }
3037
+ }
3038
+
2663
3039
  /** Duplicate an element (Cmd+D) — insert a copy as the next sibling. */
2664
3040
  async function duplicateElementOp(input: {
2665
3041
  canvas?: unknown;
@@ -3003,6 +3379,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3003
3379
  // instead of unconditionally designSystems[0]. Populated from the canvas
3004
3380
  // groups below; returned on the payload and folded into the client cfg.
3005
3381
  const canvasDesignSystems: Record<string, string> = {};
3382
+ // DDR-174 (T15) — surface a canvas's `.meta.json` `kind` in the file tree
3383
+ // when it's a value the user should notice at a glance (today: only
3384
+ // `reconstructed-experimental`, so this map stays empty on every project
3385
+ // that hasn't run `/design:import --reconstruct`). Piggybacks on the same
3386
+ // `loadCanvasMeta` call the DDR-093 DS-map loop below already makes for
3387
+ // non-path-owned (`ui/`) canvases — no extra I/O for the common case.
3388
+ const canvasKinds: Record<string, string> = {};
3389
+ const NOTABLE_KINDS = new Set(['reconstructed-experimental']);
3006
3390
  const defaultDs = cfg.defaultDesignSystem || cfg.designSystems?.[0]?.name || 'project';
3007
3391
  // A file under `system/<folder>/` belongs to the DS that owns that folder —
3008
3392
  // path-authoritative, because specimens/ui_kits rarely carry a sidecar
@@ -3076,6 +3460,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3076
3460
  const meta = await loadCanvasMeta(fp);
3077
3461
  const declared = meta?.designSystem;
3078
3462
  dsName = typeof declared === 'string' && declared.trim() ? declared : defaultDs;
3463
+ const kind = meta?.kind;
3464
+ if (typeof kind === 'string' && NOTABLE_KINDS.has(kind)) canvasKinds[fp] = kind;
3079
3465
  }
3080
3466
  canvasDesignSystems[fp] = dsName;
3081
3467
  }
@@ -3140,6 +3526,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3140
3526
  designRoot: paths.designRel,
3141
3527
  groups,
3142
3528
  canvasDesignSystems,
3529
+ canvasKinds,
3143
3530
  };
3144
3531
  }
3145
3532
 
@@ -3358,6 +3745,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3358
3745
  saveAnnotations,
3359
3746
  saveAsset,
3360
3747
  listAssets,
3748
+ readAssetBytes,
3749
+ writeCaptionSidecar,
3750
+ writeAudioIntent,
3751
+ searchAudioLibrary,
3361
3752
  listStickers,
3362
3753
  saveAssetFromStream,
3363
3754
  saveChatAttachment,
@@ -3379,6 +3770,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3379
3770
  insertElementOp,
3380
3771
  insertArtboardOp,
3381
3772
  resizeArtboardOp,
3773
+ setArtboardHugOp,
3774
+ setArtboardStyleOp,
3382
3775
  deleteArtboardOp,
3383
3776
  duplicateElementOp,
3384
3777
  editScopeOp,
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env bun
2
+ // _audio-search.mjs — internal shim behind `maude design audio-search` (DDR-062).
3
+ // Reuse-before-you-pay for AUDIO (Task 2.5): before generating a new music/SFX/VO
4
+ // track, search the project's OWN generated audio (intent sidecars) AND — when
5
+ // ElevenLabs is configured — the user's re-downloadable History (free, already
6
+ // paid), and prefer an existing suitable track over spending credits.
7
+ //
8
+ // The search + reuse run SERVER-SIDE (the key is resolved by the sidecar, never
9
+ // handed to this CLI), so this verb is a pure fetch to the running dev server's
10
+ // privileged /_api/generate/audio-search + /_api/generate/audio-reuse routes —
11
+ // exactly like generate.sh. No client-side inference.
12
+ //
13
+ // Usage:
14
+ // audio-search.mjs --query "<text>" [--root <repo>] [--reuse <historyId>] [--json]
15
+ //
16
+ // Default: print ranked candidates (local assets + ElevenLabs history).
17
+ // --reuse <historyId>: re-download that history item into assets/<sha8>.mp3 (NO
18
+ // credit) and print the localized path (for $(...) capture).
19
+ //
20
+ // Stdout (last line, --reuse): the localized `/assets/<sha8>.mp3` path.
21
+ // Exit: 0 ok / 1 server problem / 2 bad args / 3 search/reuse failed.
22
+
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+
26
+ function parseArgs(argv) {
27
+ const out = {};
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ const next = () => argv[++i];
31
+ if (a === '--query' || a === '-q') out.query = next();
32
+ else if (a === '--root') out.root = next();
33
+ else if (a === '--reuse') out.reuse = next();
34
+ else if (a === '--json') out.json = true;
35
+ else if (a === '--help' || a === '-h') out.help = true;
36
+ else if (!out.query && !a.startsWith('-')) out.query = a; // bare positional query
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function resolvePort(repo) {
42
+ const serverPath = join(repo, '.design', '_server.json');
43
+ if (!existsSync(serverPath)) {
44
+ process.stderr.write(
45
+ 'audio-search: no _server.json (start the dev server first: maude design server-up)\n'
46
+ );
47
+ process.exit(1);
48
+ }
49
+ try {
50
+ const port = JSON.parse(readFileSync(serverPath, 'utf8'))?.port;
51
+ if (port) return port;
52
+ } catch {
53
+ /* fall through */
54
+ }
55
+ process.stderr.write(`audio-search: could not read a port from ${serverPath}\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ async function main() {
60
+ const args = parseArgs(process.argv.slice(2));
61
+ if (args.help || (!args.query && !args.reuse)) {
62
+ process.stderr.write(
63
+ 'usage: maude design audio-search --query "<text>" [--root <repo>] [--reuse <historyId>] [--json]\n' +
64
+ ' Searches your own generated audio + ElevenLabs history; prefers reuse over paying again.\n'
65
+ );
66
+ process.exit(args.help ? 0 : 2);
67
+ }
68
+ const repo = args.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
69
+ const port = resolvePort(repo);
70
+ const base = `http://127.0.0.1:${port}/_api/generate`;
71
+
72
+ if (args.reuse) {
73
+ try {
74
+ const res = await fetch(`${base}/audio-reuse`, {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ id: args.reuse }),
78
+ });
79
+ const json = await res.json().catch(() => ({}));
80
+ if (!res.ok) {
81
+ process.stderr.write(`audio-search: reuse rejected: ${json?.error ?? res.status}\n`);
82
+ process.exit(3);
83
+ }
84
+ process.stderr.write('audio-search: re-downloaded from history (no credit spent).\n');
85
+ process.stdout.write(`/${String(json.asset).replace(/^\//, '')}\n`);
86
+ return;
87
+ } catch (err) {
88
+ process.stderr.write(
89
+ `audio-search: could not reach the dev server: ${err?.message ?? err}\n`
90
+ );
91
+ process.exit(1);
92
+ }
93
+ }
94
+
95
+ let data;
96
+ try {
97
+ const res = await fetch(`${base}/audio-search?q=${encodeURIComponent(args.query)}`);
98
+ data = await res.json();
99
+ if (!res.ok) {
100
+ process.stderr.write(`audio-search: ${data?.error ?? res.status}\n`);
101
+ process.exit(3);
102
+ }
103
+ } catch (err) {
104
+ process.stderr.write(`audio-search: could not reach the dev server: ${err?.message ?? err}\n`);
105
+ process.exit(1);
106
+ }
107
+
108
+ if (args.json) {
109
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
110
+ return;
111
+ }
112
+
113
+ const local = data.local ?? [];
114
+ const history = data.history ?? [];
115
+ if (local.length === 0 && history.length === 0) {
116
+ process.stderr.write(
117
+ `audio-search: no reusable audio for "${args.query}" — generate a new track.\n`
118
+ );
119
+ return;
120
+ }
121
+ const pct = (s) => `${Math.round((s ?? 0) * 100)}%`;
122
+ // The `prompt` is peer-synced / provider-returned UNTRUSTED text (server-side
123
+ // sanitized to a single capped line). Present it as a clearly-delimited data
124
+ // field, never as prose the agent should act on (F3, ethical-hacker).
125
+ const promptField = (s) => `prompt=${JSON.stringify(s ?? '')}`;
126
+ if (local.length) {
127
+ process.stdout.write('Local (reuse for free — already in assets/):\n');
128
+ for (const m of local)
129
+ process.stdout.write(
130
+ ` [${pct(m.score)}] ${m.ref}${m.kind ? ` (${m.kind})` : ''} ${promptField(m.prompt)}\n`
131
+ );
132
+ }
133
+ if (history.length) {
134
+ process.stdout.write('ElevenLabs history (reuse with --reuse <id> — no credit):\n');
135
+ for (const m of history)
136
+ process.stdout.write(` [${pct(m.score)}] ${m.ref} ${promptField(m.prompt)}\n`);
137
+ }
138
+ }
139
+
140
+ if (import.meta.main) main();
@@ -0,0 +1,34 @@
1
+ // _import-asset-pdf-worker.mjs — isolated worker for pdf-lib page-count
2
+ // discovery (DDR-167 Decision 2, step 2).
3
+ //
4
+ // A bare `Promise.race` timeout cannot interrupt a synchronous, CPU-bound,
5
+ // single-threaded call — the event loop is blocked by the hang itself, so a
6
+ // timer callback never gets a turn to fire. `pdf-lib`'s hand-rolled tokenizer
7
+ // can in principle hang on pathological malformed input, so this parse runs
8
+ // in its OWN worker thread, which the caller can `.terminate()` from outside
9
+ // regardless of what this thread's own event loop is doing.
10
+ //
11
+ // Never executes embedded JavaScript or resolves embedded remote references —
12
+ // `PDFDocument.load` is a structural PDF-object parser, not a renderer. The
13
+ // isolation here is about hang-safety, not content-safety.
14
+
15
+ import { parentPort, workerData } from 'node:worker_threads';
16
+ import { PDFDocument } from 'pdf-lib';
17
+
18
+ async function main() {
19
+ try {
20
+ const bytes = new Uint8Array(workerData.buffer);
21
+ const doc = await PDFDocument.load(bytes, {
22
+ // Never attempt to resolve/repair encrypted or malformed structure —
23
+ // fail loud rather than silently coping with adversarial input.
24
+ ignoreEncryption: false,
25
+ throwOnInvalidObject: false,
26
+ updateMetadata: false,
27
+ });
28
+ parentPort.postMessage({ ok: true, pageCount: doc.getPageCount() });
29
+ } catch (err) {
30
+ parentPort.postMessage({ ok: false, error: String(err?.message ?? err) });
31
+ }
32
+ }
33
+
34
+ main();