diff --git a/.agents/hooks/core/serena-primer.ts b/.agents/hooks/core/serena-primer.ts index df2aabc..07eaca2 100644 --- a/.agents/hooks/core/serena-primer.ts +++ b/.agents/hooks/core/serena-primer.ts @@ -117,6 +117,7 @@ export function primerContext(): string { "- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.", "- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.", "- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.", + '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.', "- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.", ].join("\n"); } diff --git a/.agents/skills/_version.json b/.agents/skills/_version.json index 0070ef0..e9d075d 100644 --- a/.agents/skills/_version.json +++ b/.agents/skills/_version.json @@ -1,6 +1,6 @@ { - "version": "11.1.1", + "version": "11.6.0", "schemaVersion": 2, "mode": "project", - "installedAt": "2026-07-27T12:13:46.129Z" + "installedAt": "2026-08-03T12:14:26.889Z" } diff --git a/.agents/skills/oma-frontend/SKILL.md b/.agents/skills/oma-frontend/SKILL.md index 22d5095..05d905e 100644 --- a/.agents/skills/oma-frontend/SKILL.md +++ b/.agents/skills/oma-frontend/SKILL.md @@ -129,7 +129,8 @@ Then run the project's frontend verification commands, typically lint, typecheck 5. Run the execution checklist before handoff and include relevant verification results. 6. **Self-describing file names**: every new file follows the File Naming convention in `../../rules/frontend.md` §Naming Conventions — domain + role readable from the basename alone (`order-summary-card.tsx`, `use-order-polling.ts`, `cart.atoms.ts`). Grab-bag names (`utils.ts`, `helpers.ts`, `misc.ts`) and version suffixes (`*-v2`, `*-final`) are banned. 7. **Next.js 16 `proxy.ts` is mandatory; `middleware.ts` is BANNED**: this project is Next.js 16+. `middleware.ts` is NOT "deprecated"; it is forbidden, touch it and you die. The canonical request-proxy / auth-gate file is `proxy.ts` (root or `src/`) exporting a `proxy` function. NEVER create, recommend, suggest, or "restore" `middleware.ts`. NEVER flag `proxy.ts` as dead code, unused, or not-wired. Any such finding is a fatal self-error: retract it immediately and write `proxy.ts`. -8. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects. +8. **`next/link` defaults to `prefetch={false}`**: every `` MUST pass `prefetch={false}` unless there is a stated reason not to. Next.js's default prefetching fires a request per link entering the viewport, which hammers container CPU/memory and origin bandwidth on list-heavy or nav-heavy pages. Opt back in (`prefetch` omitted, or `prefetch` / `prefetch="unstable_forceStale"`) ONLY for a small, deliberate set of high-intent targets (primary CTA, next step in a funnel), and note the reason inline. A `` without an explicit prefetch decision fails review. +9. **Angular projects follow `resources/angular-rules.md`**: standalone components + `OnPush` + signals-first, `inject()` DI, lazy routes, new control flow. **Any non-trivial RxJS pipeline MUST ship with a marble test (`TestScheduler` from `rxjs/testing`)** — a stream without a marble test fails review. React/Next.js-specific rules (shadcn workflow, `proxy.ts`, Libraries table below) do not apply in Angular projects. ### Libraries diff --git a/.agents/skills/oma-frontend/resources/checklist.md b/.agents/skills/oma-frontend/resources/checklist.md index abfb82c..dbe518e 100644 --- a/.agents/skills/oma-frontend/resources/checklist.md +++ b/.agents/skills/oma-frontend/resources/checklist.md @@ -8,6 +8,7 @@ Run through every item before submitting your work. - [ ] Do NOT flag `src/proxy.ts` as dead code or recommend renaming to `middleware.ts`; `proxy.ts` is the canonical Next.js 16+ convention - [ ] Config flags use the `Proxy` form (e.g. `skipProxyUrlNormalize`), not the legacy `Middleware` form +- [ ] Every `` passes `prefetch={false}`; any prefetching link is a deliberate high-intent target with an inline reason ## TypeScript - [ ] Strict mode, no `any` types diff --git a/.agents/skills/oma-frontend/resources/snippets.md b/.agents/skills/oma-frontend/resources/snippets.md index 4930d41..7e0402d 100644 --- a/.agents/skills/oma-frontend/resources/snippets.md +++ b/.agents/skills/oma-frontend/resources/snippets.md @@ -8,8 +8,13 @@ Copy-paste ready patterns. Use these as starting points, adapt to the specific t ```tsx // Internal nav: , never +// prefetch={false} is the DEFAULT — viewport prefetching eats container CPU/RAM. import Link from "next/link"; -View gallery +View gallery + +// Opt back in only for a few high-intent targets, and say why: +// primary funnel CTA — prefetch is intentional +Checkout // Custom font: next/font, never import { Inter } from "next/font/google"; diff --git a/.agents/skills/oma-video/SKILL.md b/.agents/skills/oma-video/SKILL.md index 7f016c4..7bd9b6f 100644 --- a/.agents/skills/oma-video/SKILL.md +++ b/.agents/skills/oma-video/SKILL.md @@ -203,7 +203,7 @@ Before invoking `oma video generate`, the calling agent runs this checklist. **I - [ ] **Locale**: narration + caption language (default from config; translated via oma-translator when non-source). - [ ] **Captions**: `tiktok` (centered, static windowed cues), `lower-third`, or `none`. - [ ] **Duration**: target seconds (<= 180) or `auto` (derived from the script). -- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music mixing is currently deferred (recorded + warned, not mixed). +- [ ] **Voice / music**: voice profile or `none`; music `upbeat` / `calm` / `none`. **The default voice is `none` → a silent video with estimated caption timing.** Pass `--voice ` (a Voicebox profile) whenever narration is expected. Music is rendered offline by Strudel and mixed at −18 dB; it needs a one-time `oma video doctor --install-strudel` and degrades to no music without it. **Amplification shortcut.** For a one-line brief (e.g. "shorts about Jeju coffee"), do not pop a questionnaire if the request is genuinely simple. Instead **amplify inline and show the user** the inferred plan before invoking: @@ -247,7 +247,7 @@ oma video generate "" [--mode shorts|explainer|demo] \ [--aspect 9:16|16:9|1:1|auto] [--locale ] \ [--captions tiktok|lower-third|none] \ [--visual auto|generate|stock|aigc|slide] \ - [--voice |none] [--music upbeat|calm|none] \ + [--voice |none] [--music upbeat|calm|cinematic|lofi|piano|none] \ [--duration |auto] [--compositor remotion|mpt] \ [--capture ] \ [--source file|web] [--url ] [--device ] \ @@ -298,7 +298,13 @@ Other skills call `oma video generate --format json` and parse the JSON envelope - **Narration is one wav**: oma-voice joins every scene line into a single `audio/narration-01.wav`, referenced by `render-spec.audio.narration`. There are no per-scene `narration-NN.wav` files. - **Timing**: per-line offsets live in `timing.json` (voicebox-stt -> estimated; the `tts-native` and `whisper-cpp` source values are reserved but not yet wired — `TODO(oma-deferred): whisper-cpp`); scene boundaries and caption cues are derived from it. - **Captions**: key-free `.srt` (+ `.vtt`) built from `timing.json`; `render-spec.captions.file` points at the `.srt`. The compositor renders **static windowed cues** — the cue active at the current frame, CSS-wrapped (no per-word animation). -- **Music**: deferred (`TODO(oma-deferred): music`) — no music asset source is wired yet, so a requested `--music` mode is recorded in `script.json` and surfaced as a warning, but no music is mixed. When implemented, it mixes under narration at `render-spec.audio.musicGainDb` (default −18 dB). +- **Music**: `--music ` renders a BGM bed with **Strudel** and mixes it under narration at `render-spec.audio.musicGainDb` (default −18 dB). The bed is generated offline (headless Chrome + `OfflineAudioContext`), so it needs no key, no network, and no audio device — a 30s bed renders in well under a second. + - **Presets**: `calm` (sustained pad + arpeggio), `upbeat` (bright plucks), `cinematic` (drone build to a lead), `lofi` (warm chords, swung ticks), `piano` (neoclassical arpeggio). Each preset picks its key and mode from the run `seed`, so the same preset sounds different run to run without a second pattern. + - **Artifacts** in the run dir: `music/bgm.wav` (mixed by the compositor), `music/bgm.mp3` (preview), `music/bgm-raw.wav` (pre-master), and `music/pattern.strudel` — the source that produced them, editable and re-renderable by hand. + - **Level**: every bed is normalised to −14 LUFS with a static gain before a peak limiter, so `musicGainDb` means the same thing for every preset. Normalisation is deliberately *not* `loudnorm`'s one-pass mode, which flattens the arrangement arc. + - **Opt-in install**: `@strudel/*` is AGPL-3.0-or-later while the oma CLI is MIT, so the deps are never bundled and never installed implicitly. Run `oma video doctor --install-strudel` once. The CLI never imports Strudel — it spawns `resources/strudel/render.mjs` as a subprocess, the same boundary the Remotion / Playwright projects use. + - **Fallback**: a missing install, a missing Chrome, or a failed render degrades to *no music* with a warning. The run still succeeds and `audio.music` stays unset (never a dangling `staticFile()` ref). + - **Determinism**: the built-in beds are oscillator-only (sine / triangle / square / sawtooth), which render byte-identically on replay. Noise sounds (`white` / `pink` / `brown`) draw from `Math.random()` and would break that, so the templates avoid them. ## References diff --git a/.agents/skills/oma-video/resources/execution-protocol.md b/.agents/skills/oma-video/resources/execution-protocol.md index bafb9c1..e739574 100644 --- a/.agents/skills/oma-video/resources/execution-protocol.md +++ b/.agents/skills/oma-video/resources/execution-protocol.md @@ -15,7 +15,7 @@ plan when the brief is a one-liner. - `mode` ∈ {`shorts`, `explainer`, `demo`}. - `aspect` ∈ {`9:16`, `16:9`, `1:1`, `auto`} (`auto` snaps to the mode default: shorts -> 9:16, explainer/demo -> 16:9). - `captions` ∈ {`tiktok`, `lower-third`, `none`}; `visual` ∈ {`auto`, `generate`, `stock`, `aigc`, `slide`}. - - `music` ∈ {`upbeat`, `calm`, `none`}; `compositor` ∈ {`remotion`, `mpt`}. + - `music` ∈ {`upbeat`, `calm`, `cinematic`, `lofi`, `piano`, `none`}; `compositor` ∈ {`remotion`, `mpt`}. - `duration` ≤ `limits.max_duration_sec` (180); resulting `scenes` ≤ `limits.max_scenes` (40). - `out` is inside `$PWD` unless `--allow-external-out`. - For `demo`: `--capture` (if given) exists, is absolute + `$PWD`-guarded, and is a valid video format. diff --git a/.agents/skills/oma-video/resources/prompt-tips.md b/.agents/skills/oma-video/resources/prompt-tips.md index 69b53b1..6bc6c33 100644 --- a/.agents/skills/oma-video/resources/prompt-tips.md +++ b/.agents/skills/oma-video/resources/prompt-tips.md @@ -53,7 +53,7 @@ Scene/backdrop → Subject → Details → Constraints - Anchor the **arc**: hook → body → payoff. A short without a hook gets swiped past. - Match **aspect to mode** (9:16 shorts, 16:9 explainer/demo) or use `auto`. - Keep narration **per-scene and short** so caption pages and scene boundaries align. -- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — note music mixing is currently deferred: the choice is recorded in `script.json` and warned, but not yet audible. +- Pick **music** that matches pacing (`upbeat` for shorts, `calm` for explainer) — Strudel renders the bed offline and mixes it at −18 dB under narration. Needs a one-time `oma video doctor --install-strudel`; without it the run still succeeds, just silent. ## Don'ts diff --git a/.agents/skills/oma-video/resources/script-schema.md b/.agents/skills/oma-video/resources/script-schema.md index 86c515d..b927f03 100644 --- a/.agents/skills/oma-video/resources/script-schema.md +++ b/.agents/skills/oma-video/resources/script-schema.md @@ -15,7 +15,7 @@ required** — omitting it is the most common authoring failure. | `locale` | string (min 1) | ✅ | narration/caption language tag, e.g. `en`, `ko` | | `title` | string (min 1) | ✅ | also drives the output filename slug: `-.mp4` | | `scenes` | array (min 1, ≤ 40) | ✅ | see per-scene fields below | -| `music` | `upbeat` \| `calm` \| `none` | ✅ | recorded only — music mixing is deferred (`TODO(oma-deferred): music`) | +| `music` | `upbeat` \| `calm` \| `cinematic` \| `lofi` \| `piano` \| `none` | ✅ | drives the Strudel BGM bed; mixed at −18 dB under narration | | `brand` | object | — | free-form; defaults to `{}` | ### Per-scene fields (`scenes[]`) @@ -77,5 +77,5 @@ required** — omitting it is the most common authoring failure. - [ ] `aspect` is concrete (`9:16` / `16:9` / `1:1`) — never `auto`. - [ ] Scene count ≤ 40, total `durationSec` ≤ 180. - [ ] Every scene has a `visual.kind` from the enum; `still` scenes carry an English `visual.prompt`. -- [ ] `music` is set (use `"none"` unless the user asked — mixing is deferred either way). +- [ ] `music` is set (use `"none"` unless the user asked for a bed). - [ ] Validate cheaply before rendering: pass the file via `--script` with `--dry-run` first. diff --git a/.agents/skills/oma-video/resources/strudel/package.json b/.agents/skills/oma-video/resources/strudel/package.json new file mode 100644 index 0000000..000fce4 --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/package.json @@ -0,0 +1,11 @@ +{ + "name": "oma-video-strudel", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Vendored Strudel BGM renderer for oma-video. Deps are installed on demand by `oma video doctor --install-strudel`. @strudel/* is AGPL-3.0-or-later and is NEVER imported by the MIT-licensed oma CLI — render.mjs runs as a subprocess (same boundary as the Remotion / Playwright projects).", + "license": "MIT", + "dependencies": { + "@strudel/web": "^1.3.0" + } +} diff --git a/.agents/skills/oma-video/resources/strudel/page.html b/.agents/skills/oma-video/resources/strudel/page.html new file mode 100644 index 0000000..afe58bf --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/page.html @@ -0,0 +1,87 @@ + + + + + oma-video strudel offline render + + + + + + + diff --git a/.agents/skills/oma-video/resources/strudel/render.mjs b/.agents/skills/oma-video/resources/strudel/render.mjs new file mode 100644 index 0000000..e9a67c6 --- /dev/null +++ b/.agents/skills/oma-video/resources/strudel/render.mjs @@ -0,0 +1,295 @@ +#!/usr/bin/env node +// Strudel BGM renderer — boundary-safe subprocess entrypoint. +// +// oma-video NEVER imports Strudel. @strudel/* is AGPL-3.0-or-later while the +// oma CLI is MIT, so the TypeScript provider (`providers/music-strudel.ts` + +// `internal/strudel-project.ts`) only ever LOCATES this project on disk and +// spawns *this* script. Same boundary the Remotion / Playwright projects use. +// +// MECHANISM: `@strudel/webaudio.renderPatternAudio()` renders a pattern through +// an OfflineAudioContext — faster than realtime, no audio device, no autoplay +// gesture. A 30s bed renders in well under a second. The bundle only runs in a +// browser, so we drive a headless Chrome over raw CDP (Node's global WebSocket; +// zero npm deps here beyond @strudel/web itself) and serve the bundle from a +// loopback-only HTTP server. +// +// DETERMINISM: oscillator sounds (sine/triangle/square/sawtooth) render +// byte-identically across runs. Noise-based sounds (white/pink/brown) do NOT — +// superdough fills those buffers from Math.random(). Patterns that must be +// reproducible should stay oscillator-only. +// +// Contract — flags in, ONE JSON result line out (stdout's LAST line): +// IN --pattern-file file holding the strudel pattern code (required) +// --out output wav, inside a run dir (required) +// --chrome Chrome/Chromium executable (required) +// --seconds bed length in seconds (required) +// --cps cycles per second (default 0.5) +// --sample-rate default 44100 +// --timeout hard ceiling for the whole render (default 120000) +// OUT {"ok":true,"output":"","seconds":,"bytes":,"sha256":""} +// | {"ok":false,"error":"","code":""} +// +// Exit code is 0 on success, 1 on failure; the JSON result line is authoritative. +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const DIST = path.join(HERE, "node_modules", "@strudel", "web", "dist"); + +const MIME = { + ".js": "text/javascript", + ".mjs": "text/javascript", + ".html": "text/html", + ".json": "application/json", + ".wasm": "application/wasm", +}; + +let server; +let chrome; +let userDataDir; +let hardTimer; + +function fail(message, code = "render_failed") { + process.stdout.write(`${JSON.stringify({ ok: false, error: message, code })}\n`); + cleanup().finally(() => process.exit(1)); +} + +async function cleanup() { + if (hardTimer) clearTimeout(hardTimer); + try { + chrome?.kill("SIGTERM"); + } catch {} + try { + server?.close(); + } catch {} + if (userDataDir) { + await rm(userDataDir, { recursive: true, force: true }).catch(() => {}); + } +} + +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith("--")) continue; + const eq = token.indexOf("="); + if (eq !== -1) { + out[token.slice(2, eq)] = token.slice(eq + 1); + } else { + out[token.slice(2)] = argv[i + 1]?.startsWith("--") ? "" : argv[++i]; + } + } + return out; +} + +const args = parseArgs(process.argv.slice(2)); +const patternFile = args["pattern-file"]; +const outPath = args.out; +const chromePath = args.chrome; +const seconds = Number(args.seconds); +const cps = Number(args.cps ?? 0.5); +const sampleRate = Number(args["sample-rate"] ?? 44100); +const timeoutMs = Number(args.timeout ?? 120000); + +if (!patternFile || !outPath || !chromePath) { + fail("missing required flag: --pattern-file / --out / --chrome", "bad_args"); +} else if (!Number.isFinite(seconds) || seconds <= 0) { + fail(`--seconds must be a positive number (got ${args.seconds})`, "bad_args"); +} else if (!existsSync(DIST)) { + fail( + `@strudel/web is not installed in ${HERE} — run \`oma video doctor --install-strudel\``, + "not_installed", + ); +} else if (!existsSync(chromePath)) { + fail(`chrome executable not found: ${chromePath}`, "no_chrome"); +} else { + main().catch((err) => fail(err?.message ?? String(err))); +} + +async function main() { + hardTimer = setTimeout( + () => fail(`render exceeded ${timeoutMs}ms`, "timeout"), + timeoutMs, + ); + hardTimer.unref?.(); + + const code = await readFile(patternFile, "utf8"); + + // 1. Loopback-only static server for the page + the strudel bundle. Paths are + // confined to this dir and the resolved dist (no traversal). + server = createServer(async (req, res) => { + const pathname = new URL(req.url, "http://127.0.0.1").pathname; + let file; + if (pathname === "/" || pathname === "/page.html") { + file = path.join(HERE, "page.html"); + } else if (pathname === "/strudel.js") { + file = path.join(DIST, "index.js"); + } else { + const resolved = path.resolve(DIST, `.${pathname}`); + file = resolved.startsWith(DIST) ? resolved : null; + } + if (!file) { + res.writeHead(403).end("forbidden"); + return; + } + try { + const body = await readFile(file); + res.writeHead(200, { + "content-type": MIME[path.extname(file)] ?? "application/octet-stream", + "cross-origin-opener-policy": "same-origin", + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }); + res.end(body); + } catch { + res.writeHead(404).end("not found"); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const httpPort = server.address().port; + + // 2. Headless Chrome. No audio device is needed — OfflineAudioContext renders + // into a buffer, so this works on a bare CI box. + userDataDir = await mkdtemp(path.join(tmpdir(), "oma-strudel-")); + chrome = spawn( + chromePath, + [ + "--headless=new", + "--disable-gpu", + "--no-first-run", + "--no-default-browser-check", + "--disable-dev-shm-usage", + `--user-data-dir=${userDataDir}`, + "--remote-debugging-port=0", + "about:blank", + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + chrome.on("error", (err) => fail(`chrome failed to start: ${err.message}`, "no_chrome")); + + const devtoolsPort = await waitForDevtoolsPort( + path.join(userDataDir, "DevToolsActivePort"), + ); + + // 3. Drive it over raw CDP. + const targets = await ( + await fetch(`http://127.0.0.1:${devtoolsPort}/json/list`) + ).json(); + const page = targets.find((t) => t.type === "page"); + if (!page) throw new Error("chrome exposed no page target"); + + const cdp = await connect(page.webSocketDebuggerUrl); + await cdp.send("Runtime.enable"); + await cdp.send("Page.enable"); + await cdp.send("Page.navigate", { + url: `http://127.0.0.1:${httpPort}/page.html`, + }); + await cdp.waitForLoad(); + + const base64Length = await cdp.evaluate( + `window.__omaRender(${JSON.stringify(code)}, ${seconds}, ${cps}, ${sampleRate})`, + ); + if (!base64Length || base64Length <= 0) { + throw new Error("strudel produced an empty buffer"); + } + + // 4. Pull the wav back in slices so no single CDP message gets huge. + const CHUNK = 4 * 1024 * 1024; + let b64 = ""; + for (let offset = 0; offset < base64Length; offset += CHUNK) { + b64 += await cdp.evaluate( + `window.__omaWav.slice(${offset}, ${offset + CHUNK})`, + ); + } + const wav = Buffer.from(b64, "base64"); + await writeFile(outPath, wav); + + process.stdout.write( + `${JSON.stringify({ + ok: true, + output: outPath, + seconds, + bytes: wav.length, + sha256: createHash("sha256").update(wav).digest("hex"), + })}\n`, + ); + await cleanup(); + process.exit(0); +} + +/** Chrome writes its chosen debugger port here once it is listening. */ +async function waitForDevtoolsPort(portFile) { + for (let i = 0; i < 200; i++) { + if (existsSync(portFile)) { + const [line] = readFileSync(portFile, "utf8").split("\n"); + const port = Number(line); + if (Number.isFinite(port) && port > 0) return port; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("chrome never reported a devtools port"); +} + +/** Minimal CDP client over Node's global WebSocket. */ +async function connect(wsUrl) { + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = () => reject(new Error("cdp websocket failed to open")); + }); + + let nextId = 0; + const pending = new Map(); + let loaded = false; + const loadWaiters = []; + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.id && pending.has(msg.id)) { + const { resolve, reject } = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) reject(new Error(JSON.stringify(msg.error))); + else resolve(msg.result); + return; + } + if (msg.method === "Page.loadEventFired") { + loaded = true; + while (loadWaiters.length) loadWaiters.shift()(); + } + }; + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++nextId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + return { + send, + waitForLoad: () => + loaded ? Promise.resolve() : new Promise((resolve) => loadWaiters.push(resolve)), + async evaluate(expression) { + const res = await send("Runtime.evaluate", { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (res.exceptionDetails) { + const detail = + res.exceptionDetails.exception?.description ?? + res.exceptionDetails.text ?? + "unknown page error"; + throw new Error(detail.split("\n")[0]); + } + return res.result.value; + }, + }; +} diff --git a/.agents/workflows/orchestrate.md b/.agents/workflows/orchestrate.md index 313ca4a..29076e8 100644 --- a/.agents/workflows/orchestrate.md +++ b/.agents/workflows/orchestrate.md @@ -37,12 +37,26 @@ The detected runtime vendor and each agent's target vendor determine how agents ## Step 1: Load or Create Plan +### 1a. Load + Look for a plan file: 1. Check `.agents/results/plan-{sessionId}.json` (current session's plan). 2. If not found: find the most recent `.agents/results/plan-*.json` file. -3. If none exist: ask the user to run `/plan` first, or ask them to describe the tasks to execute. -- **Do NOT proceed without a plan.** +3. A plan is **usable** only when every task carries an agent assignment, a priority tier, its dependencies, and acceptance criteria. A plan missing any of these is not execution-ready — fall through to 1b rather than fanning out against it. + +### 1b. Create (no usable plan) + +A missing plan is not a stop condition. `/orchestrate` creates the plan itself instead of handing the request back to the user: + +1. Generate the session ID now (format: `session-YYYYMMDD-HHMMSS`). Step 2 reuses this id verbatim — do not generate a second one. +2. Read and follow `.agents/workflows/plan.md` step by step, passing this session ID as its `{sessionId}` so the artifact lands at `.agents/results/plan-{sessionId}.json`. +3. **Do NOT skip `plan.md` Step 6 (Review Plan with User).** It is this run's approval gate — the Step 3 fan-out is authorized by it. Delegation never removes a user gate. +4. Once the plan is saved and approved, load it and continue to Step 2 with the same session ID. + +Stop and report only when the plan cannot be produced: the user declines to plan, or `plan.md` blocks because the request is too underspecified to decompose. + +- **Do NOT spawn agents without a usable plan.** --- @@ -66,7 +80,7 @@ Look for a plan file: └──────────┴───────────────────┘ ``` -3. Generate session ID (format: `session-YYYYMMDD-HHMMSS`). +3. Session ID: reuse the id generated in Step 1b when the plan was created in this run; otherwise generate one now (format: `session-YYYYMMDD-HHMMSS`). 4. **Domain gate**: for each planned task, classify it into `domain_tags` by matching against the `Intent signature` block of each installed `.agents/skills/oma-*/SKILL.md`, and derive `exposed_skill_set` (skills whose name is in `domain_tags`). If fewer than 2 skills match confidently, fall back to the full installed set and mark `exposure_fallback: true`. See `.agents/skills/oma-orchestrator/SKILL.md` (PHASE 1.5) for the full rules. 5. Use memory write tool to create `orchestrator-session.md` and `task-board.md` in the memory base path. Record `Exposed Skills` and `Exposure Fallback` per task in `task-board.md`. 6. Set session status to RUNNING. diff --git a/.agents/workflows/video.md b/.agents/workflows/video.md index b1754a9..fbc78e7 100644 --- a/.agents/workflows/video.md +++ b/.agents/workflows/video.md @@ -57,7 +57,7 @@ For `demo`, also resolve the **source**: a recorded file or Cap → `--source fi | stock video | Pexels (`PEXELS_API_KEY`) | oma-image stills + Ken Burns | `TODO(oma-deferred): pexels` | | AIGC video | Pixelle-MCP + RunningHub (`RUNNINGHUB_API_KEY`) | oma-image stills | `TODO(oma-deferred): pixelle` | | caption timing | voicebox-stt (MCP `voicebox_transcribe` → REST) | estimate | `TODO(oma-deferred): whisper-cpp` | - | music mixing | (not wired — recorded + warned only) | render without music | `TODO(oma-deferred): music` | + | music mixing | Strudel offline render (`oma video doctor --install-strudel`) | render without music | — | | premium TTS | (not needed — oma-voice is local) | — | — | - **Pixelle AIGC is a community MCP**: off by default, requires one-time explicit user consent plus a source review before connecting, and is always cost-gated on RunningHub credits. @@ -113,7 +113,7 @@ The agent writes the script — this is the start of the determinism boundary. D ```bash oma video generate "" --mode --aspect --locale \ --captions --visual \ - --voice --music --duration \ + --voice --music --duration \ --compositor --seed \ --script --dry-run --format json ``` @@ -193,7 +193,7 @@ Review the finished video against the brief and the quality bars. Iterate by re- - Narration audio is present (or intentionally silent) and aligns to scenes. - Captions are synced to `timing.json`, within the safe area, and legible (static windowed cues, CSS-wrapped, Pretendard, design rule 2). - Visuals match each scene's intent; no placeholder leakage unless the run intentionally used the fallback. - - Aspect / dimensions are correct for the mode; branding applied as requested. (Music mixing is deferred — a requested music mode only produces a warning, never audio.) + - Aspect / dimensions are correct for the mode; branding applied as requested. (A requested music mode yields `music/bgm.wav` mixed at −18 dB, or a fallback warning and a silent render when Strudel is not installed.) 2. **Route each defect to its stage:** - script/narration/scene-count → **Step 3** (re-author script). - audio/timing → **Step 4** voice track (check oma-voice, re-synthesize). diff --git a/.gitignore b/.gitignore index 3b645f6..f4db8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ docs/generated/ .agents/backup/ docs/plans/ + +.qwen/tmp/ diff --git a/.opencode/plugins/oma/serena-primer.ts b/.opencode/plugins/oma/serena-primer.ts index df2aabc..07eaca2 100644 --- a/.opencode/plugins/oma/serena-primer.ts +++ b/.opencode/plugins/oma/serena-primer.ts @@ -117,6 +117,7 @@ export function primerContext(): string { "- Code discovery / reading: `get_symbols_overview`, `find_symbol`, `find_referencing_symbols`, `search_for_pattern`.", "- Code edits: `replace_symbol_body`, `insert_after_symbol`, `insert_before_symbol`, `replace_content`.", "- Native grep/glob: only for initial filename/path discovery. Do not fall back to grep + Read for code navigation just because Serena's tools aren't loaded yet — load them.", + '- Result size: omit `max_answer_chars` on Serena tools (uses the configured default, typically 150000). Never pass small caps like `3000` on broad searches. If a call returns "The answer is too long (N characters)", retry with `max_answer_chars` > N or narrow path/glob — do not keep the low cap.', "- Exception — MCP timeout: if a Serena MCP call times out or hangs (seen mainly in OpenCode Desktop's long-lived sidecar), stop retrying MCP for this session: use native search/read for code, and access `.serena/memories/` files directly (or `serena memories read|write` when Serena CLI ≥ 1.5 is installed) for memory work. A full app relaunch restores Serena MCP.", ].join("\n"); }