@1agh/maude 0.43.0 → 0.45.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.
- package/apps/studio/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/commands/init.mjs +7 -1
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
package/apps/studio/api.ts
CHANGED
|
@@ -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();
|