From 35e54cae1978e04d09ab4cc7c8627cdecdcf6421 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 7 Jul 2026 16:23:06 -0400 Subject: [PATCH] feat(media-use): agent-driven asset reuse (candidates + reuse) Reuse now hands the semantic judgment to the coding agent instead of a string heuristic, while keeping the deterministic normalize-exact match as an automatic dedup floor. No LLM/embedding call enters resolve; it stays offline-capable. - lib/match.mjs: shared matchTokens + typesMatch (extracted from adopt.mjs and resolve.mjs so the icon<->image equivalence and token rules can't drift between the do-path and the look-path); adds tokenOverlap ranker. - resolve --candidates: side-effect-free listing of reusable assets across the project manifest AND the global ~/.media cache, ranked by lexical overlap, capped per scope, --json or human table. Never hard-filters on zero overlap (that would pre-empt the agent's judgment); the agent decides. - resolve --reuse : import a specific global-cache asset by content sha/prefix (from --candidates) into the project via importFromCache, marked source=reused-explicit / provenance.reused_by=agent. - Adherence nudge: on a resolve that misses the exact floor and is about to fetch, print a one-line stderr hint when similar cached assets exist, pointing at --candidates. Offline, stderr (safe under --json), never auto-reuses a fuzzy match. - cache.mjs: export readGlobalManifest; add findGlobalBySha (prefix resolve with ambiguity/miss handling). - Telemetry: media_use_candidates event + reused-explicit source on media_use_resolve (type/scope/counts only, no intent text or paths). - SKILL.md: 'Reuse before you resolve' guidance + trust guardrail (prefer-fresh-when-unsure, entity-exact for brand, cross-project bleed). - Tests: lib/candidates.test.mjs (ranking, no-hard-filter, cap/truncation, icon<->image, sha resolution, formatter); adopt.mjs refactor covered by existing lib/adopt.test.mjs. Full media-use suite green; verified e2e against the live catalog (cross-project resolve->candidates->reuse; hint fires on miss). --- skills-manifest.json | 4 +- skills/media-use/SKILL.md | 62 +++++-- skills/media-use/scripts/lib/adopt.mjs | 25 +-- skills/media-use/scripts/lib/cache.mjs | 24 ++- skills/media-use/scripts/lib/candidates.mjs | 90 ++++++++++ .../media-use/scripts/lib/candidates.test.mjs | 155 ++++++++++++++++++ skills/media-use/scripts/lib/match.mjs | 46 ++++++ skills/media-use/scripts/resolve.mjs | 106 +++++++++++- 8 files changed, 463 insertions(+), 49 deletions(-) create mode 100644 skills/media-use/scripts/lib/candidates.mjs create mode 100644 skills/media-use/scripts/lib/candidates.test.mjs create mode 100644 skills/media-use/scripts/lib/match.mjs diff --git a/skills-manifest.json b/skills-manifest.json index ba204e63f..bb23beeb0 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -46,8 +46,8 @@ "files": 10 }, "media-use": { - "hash": "caf64c363a39ad9b", - "files": 98 + "hash": "d28c7979e727a6a9", + "files": 101 }, "motion-graphics": { "hash": "f5a432862116b39a", diff --git a/skills/media-use/SKILL.md b/skills/media-use/SKILL.md index ebdc32d7e..c2bcc174f 100644 --- a/skills/media-use/SKILL.md +++ b/skills/media-use/SKILL.md @@ -68,17 +68,41 @@ node /scripts/resolve.mjs --type icon --intent "rocket" --project . ### Flags -| Flag | Description | -| --------------- | --------------------------------------------------------------- | -| `--type, -t` | Media type: bgm, sfx, image, icon, voice | -| `--intent, -i` | What you need (natural language) | -| `--entity, -e` | Entity name for cache matching (optional) | -| `--project, -p` | Project directory (default: .) | -| `--from` | Freeze a local file or direct public URL (ingest) | -| `--local-only` | Offline: skip every network provider (cache + local only) | -| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) | -| `--adopt` | Bulk-import existing assets/ into manifest | -| `--json` | Output JSON instead of one-line result | +| Flag | Description | +| --------------- | ------------------------------------------------------------------------------------ | +| `--type, -t` | Media type: bgm, sfx, image, icon, voice | +| `--intent, -i` | What you need (natural language) | +| `--entity, -e` | Entity name for cache matching (optional) | +| `--project, -p` | Project directory (default: .) | +| `--candidates` | List reusable assets (project + global cache) for `--type`; no download, no mutation | +| `--reuse ` | Import a specific global-cache asset (by content sha/prefix, from `--candidates`) | +| `--from` | Freeze a local file or direct public URL (ingest) | +| `--local-only` | Offline: skip every network provider (cache + local only) | +| `--provider` | Force one generator (e.g. `codex`, `mflux`, `kokoro`, `heygen`) | +| `--adopt` | Bulk-import existing assets/ into manifest | +| `--json` | Output JSON instead of one-line result | + +## Reuse before you resolve + +Before resolving bgm/sfx/image/icon, **check what already exists and reuse it when it fits.** media-use does not semantically match for you — you are the judge. It surfaces candidates; you decide. + +```bash +node /scripts/resolve.mjs --type bgm --intent "upbeat tech launch" --candidates --project . +# [project] upbeat tech launch (25s, heygen.audio.sounds) +# .media/audio/bgm/bgm_001.wav +# [global] energetic tech intro (22s, heygen.audio.sounds) +# --reuse 06e052c075fd2b80 +``` + +Read the list and judge semantic fit yourself — "upbeat tech launch" ≈ "energetic tech intro" is a call only you can make from the descriptions. Then: + +- **A project candidate fits** → just reference its path in your composition. Nothing else to run. +- **A global candidate fits** → `resolve --type bgm --reuse ` copies it into this project (self-contained render) and records it. +- **Nothing fits** → resolve fresh (`--type ... --intent ...`). + +**Trust guardrail — when unsure, resolve fresh.** A redundant download is cheap; shipping the wrong asset is not. Judge fit from description + prompt + type + duration/dims. For **brand/entity** assets, reuse a _global_ candidate only when the entity matches exactly — the global cache aggregates every project you have worked on, so a `--candidates` list can surface another client's brand mark and its prompt text. Never reuse a cross-project brand asset on a loose match. + +The deterministic floor still runs automatically: an identical (case/whitespace-insensitive) repeat auto-reuses with no `--candidates` step. `--candidates` is only for the semantic layer above that floor — and a fuzzy match is **never** auto-applied; reuse is always your explicit call. On a resolve that misses the floor and is about to fetch, media-use prints a one-line stderr hint when similar cached assets exist, pointing you back here. ## Providers @@ -111,13 +135,15 @@ leaving the project + global cache and any local provider. ## How it works -1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive) -2. Scan existing `assets/` directory for unregistered files that share a word with the need -3. Check global cache `~/.media/` for reusable asset -4. Search via provider (HeyGen audio catalog, HeyGen asset search) -5. Freeze file to `.media//`, register in manifest, regenerate `index.md` +`resolve` runs an automatic floor, then falls through to fetching: -The agent gets back **one line**. Candidates, scores, provenance stay on disk. +1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive) — auto-reuse +2. Scan existing `assets/` directory for unregistered files that share a word with the need +3. Check global cache `~/.media/` for a reusable asset matched on the same normalized prompt — auto-reuse +4. Search via provider (HeyGen audio catalog, HeyGen asset search), then generate +5. Freeze file to `.media//`, register in manifest, regenerate `index.md`, auto-promote to `~/.media/` + +Steps 1 and 3 are the **deterministic floor**: they only auto-reuse an exact-normalized match, never a fuzzy one. Semantic reuse ("close enough") is the agent's explicit call via [Reuse before you resolve](#reuse-before-you-resolve) — it never happens automatically. The agent gets back **one line**; candidates, scores, provenance stay on disk. ## Adopt existing projects @@ -150,6 +176,8 @@ icon_001 icon - 200×200 .media/images/icon_001.png rocket Assets are cached automatically on resolve. Every resolved/ingested asset is auto-promoted to the global cache at `~/.media/`, so subsequent resolves for the same (or near-identical) prompt, in any project, hit the cache with no re-download and no provider call. +For a _semantically_ similar (not identical) need in another project, the exact-match floor won't fire — use [Reuse before you resolve](#reuse-before-you-resolve): `--candidates` lists the global assets, and `--reuse ` imports the one you pick. This is how a track resolved in one project gets reused in the next when the wording differs. + ## Files - `.media/manifest.jsonl`: machine SSOT, one JSON record per line diff --git a/skills/media-use/scripts/lib/adopt.mjs b/skills/media-use/scripts/lib/adopt.mjs index 7f201cedd..8ccb407b7 100644 --- a/skills/media-use/scripts/lib/adopt.mjs +++ b/skills/media-use/scripts/lib/adopt.mjs @@ -3,6 +3,7 @@ import { join, extname, basename } from "node:path"; import { readManifest, appendRecord, nextId } from "./manifest.mjs"; import { regenerateIndex } from "./index-gen.mjs"; import { probe } from "./probe.mjs"; +import { matchTokens } from "./match.mjs"; const AUDIO_EXT = new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]); const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico"]); @@ -96,30 +97,6 @@ export function adoptExistingAssets(projectDir) { return adopted; } -// Common filler words that should never, on their own, make a filename match an -// intent (e.g. intent "the rocket" must not adopt "the-video.mp4"). -const MATCH_STOPWORDS = new Set([ - "the", - "and", - "for", - "with", - "from", - "this", - "that", - "your", - "our", -]); - -// Split into lowercased word tokens of length >= 3, minus stopwords. -function matchTokens(text) { - return new Set( - String(text) - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((t) => t.length >= 3 && !MATCH_STOPWORDS.has(t)), - ); -} - // Adopt a pre-existing assets/ file only when it shares a meaningful word with // the intent. The old test — `name.includes(intent) || intent.includes(name)` — // silently returned the WRONG file: "whoosh" grabbed a stray who.mp3, and a diff --git a/skills/media-use/scripts/lib/cache.mjs b/skills/media-use/scripts/lib/cache.mjs index d245fb899..c7c7d6f71 100644 --- a/skills/media-use/scripts/lib/cache.mjs +++ b/skills/media-use/scripts/lib/cache.mjs @@ -33,10 +33,32 @@ function markComplete(entryDir) { // global manifest must be addressed by HOME, not by globalMediaDir() — passing // the latter nested it at ~/.media/.media/manifest.jsonl, invisible to the // Studio /api/assets/global route (which reads the documented flat path). -function readGlobalManifest() { +export function readGlobalManifest() { return readManifest(homedir()); } +// Resolve a content-sha (full or unambiguous prefix) to a reusable global-cache +// record, for `resolve --reuse `. Returns null on no match, or +// { ambiguous: true, count } when a prefix matches multiple distinct entries. +// Completeness (the .hf-complete sentinel) is left to importFromCache so the +// caller can surface an "incomplete cache entry" error distinctly from a miss. +export function findGlobalBySha(shaPrefix) { + const p = String(shaPrefix || "") + .toLowerCase() + .trim(); + if (!p) return null; + const matches = readGlobalManifest().filter( + (r) => r.reusable && typeof r.sha === "string" && r.sha.startsWith(p), + ); + if (matches.length === 0) return null; + if (matches.length > 1) { + const exact = matches.find((r) => r.sha === p); + if (exact) return exact; + return { ambiguous: true, count: matches.length }; + } + return matches[0]; +} + function validateCacheHit(match) { if (!match?.sha) return null; return isComplete(cacheEntryDir(globalMediaDir(), match.sha)) ? match : null; diff --git a/skills/media-use/scripts/lib/candidates.mjs b/skills/media-use/scripts/lib/candidates.mjs new file mode 100644 index 000000000..4532c49fa --- /dev/null +++ b/skills/media-use/scripts/lib/candidates.mjs @@ -0,0 +1,90 @@ +// Reuse candidates: a side-effect-free view of assets already available to this +// project (its own manifest) and across every project (the global ~/.media +// cache), so the calling agent can judge semantic fit itself. No download, no +// provider, no mutation. The ranker only *surfaces* — it orders by lexical +// overlap but never filters a candidate out on zero overlap (that would +// pre-empt the agent's judgment); the agent does the semantic call. + +import { readManifest } from "./manifest.mjs"; +import { readGlobalManifest } from "./cache.mjs"; +import { tokenOverlap, typesMatch } from "./match.mjs"; + +export const CANDIDATE_CAP = 8; + +function shape(record, scope, intent) { + const description = record.description || record.provenance?.prompt || ""; + const prompt = record.provenance?.prompt || null; + return { + id: record.id, + type: record.type, + scope, + description, + prompt, + provider: record.provenance?.provider || null, + duration: record.duration ?? null, + width: record.width ?? null, + height: record.height ?? null, + // Only global records carry a content sha — it is the stable reuse handle + // for `resolve --reuse `. Project assets are reused by referencing + // their path directly, so they need no handle. + sha: scope === "global" ? record.sha || null : null, + path: scope === "project" ? record.path : null, + score: intent ? tokenOverlap(intent, `${description} ${prompt || ""}`) : 0, + }; +} + +// Rank one scope: type-matched (icon<->image aware), newest-first within equal +// overlap, ordered by overlap desc. Returns the full ranked list (uncapped). +function rankScope(records, scope, type, intent) { + return records + .filter((r) => typesMatch(r.type, type)) + .reverse() // manifest is append-order (oldest first); newest-first at equal score + .map((r) => shape(r, scope, intent)) + .sort((a, b) => b.score - a.score); // Array.sort is stable → recency preserved +} + +// List reuse candidates for `type`, capped per scope. Returns: +// candidates: capped project candidates followed by capped global candidates +// truncated: true if either scope had more than `cap` +// total: { project, global } counts before the cap (machine-readable) +// similar: count of candidates with lexical overlap > 0 (drives the nudge) +export function listCandidates({ projectDir, type, intent = "", cap = CANDIDATE_CAP }) { + const project = rankScope(readManifest(projectDir), "project", type, intent); + const global = rankScope(readGlobalManifest(), "global", type, intent); + const candidates = [...project.slice(0, cap), ...global.slice(0, cap)]; + return { + candidates, + truncated: project.length > cap || global.length > cap, + total: { project: project.length, global: global.length }, + similar: [...project, ...global].filter((c) => c.score > 0).length, + }; +} + +function meta(c) { + const parts = []; + if (c.duration != null) parts.push(`${c.duration}s`); + if (c.width && c.height) parts.push(`${c.width}x${c.height}`); + if (c.provider) parts.push(c.provider); + return parts.join(", "); +} + +// Human-readable listing. The agent can read this directly; --json is for +// programmatic use. Reuse handle differs by scope: path for project, sha for +// global. +export function formatCandidates(candidates, { truncated, total } = {}) { + if (candidates.length === 0) return "no reuse candidates found (project or global cache)"; + const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""]; + for (const c of candidates) { + const handle = c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path; + const m = meta(c); + lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`); + lines.push(` ${handle}`); + } + if (truncated && total) { + lines.push(""); + lines.push( + ` (showing top ${CANDIDATE_CAP} per scope; ${total.project} project / ${total.global} global total — refine --intent to narrow)`, + ); + } + return lines.join("\n"); +} diff --git a/skills/media-use/scripts/lib/candidates.test.mjs b/skills/media-use/scripts/lib/candidates.test.mjs new file mode 100644 index 000000000..972577b3c --- /dev/null +++ b/skills/media-use/scripts/lib/candidates.test.mjs @@ -0,0 +1,155 @@ +import { test } from "node:test"; +import { strict as assert } from "node:assert"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./candidates.mjs"; +import { findGlobalBySha } from "./cache.mjs"; + +// candidates + findGlobalBySha are offline (no heygen), so we can override HOME +// to a temp dir and seed a fake global ~/.media manifest deterministically. +function sandbox() { + const root = mkdtempSync(join(tmpdir(), "mu-cand-")); + const project = join(root, "proj"); + const home = join(root, "home"); + process.env.HOME = home; + return { root, project, home }; +} +function seedManifest(dir, records) { + const md = join(dir, ".media"); + mkdirSync(md, { recursive: true }); + writeFileSync(md + "/manifest.jsonl", records.map((r) => JSON.stringify(r)).join("\n") + "\n"); +} +function proj(id, type, description, prompt) { + return { id, type, path: `.media/audio/bgm/${id}.wav`, description, provenance: { prompt } }; +} +function glob(id, type, description, prompt, sha) { + return { + id, + type, + sha, + reusable: true, + cached_path: `/x/${sha}/${id}.wav`, + description, + provenance: { prompt, provider: "heygen.audio.sounds" }, + }; +} + +test("ranks project + global by overlap, tags scope", () => { + const { root, project, home } = sandbox(); + try { + seedManifest(project, [proj("bgm_001", "bgm", "calm ambient piano", "calm ambient piano")]); + seedManifest(home, [ + glob("bgm_009", "bgm", "energetic tech launch", "energetic tech launch", "a".repeat(64)), + glob("bgm_010", "bgm", "sad corporate piano", "sad corporate piano", "b".repeat(64)), + ]); + const { candidates } = listCandidates({ + projectDir: project, + type: "bgm", + intent: "tech launch", + }); + assert.equal(candidates[0].scope, "project"); // project listed first + const g = candidates.filter((c) => c.scope === "global"); + assert.equal(g[0].description, "energetic tech launch"); // higher overlap ranks first + assert.ok(g[0].score >= g[1].score); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("zero-overlap intent still lists candidates (no hard filter)", () => { + const { root, project, home } = sandbox(); + try { + seedManifest(project, []); + seedManifest(home, [glob("bgm_009", "bgm", "driving synth", "driving synth", "c".repeat(64))]); + const { candidates, similar } = listCandidates({ + projectDir: project, + type: "bgm", + intent: "totally unrelated words xyz", + }); + assert.equal(candidates.length, 1, "listed despite zero overlap"); + assert.equal(candidates[0].score, 0); + assert.equal(similar, 0, "similar counts only overlap>0"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("caps per scope and reports truncation + totals", () => { + const { root, project, home } = sandbox(); + try { + const many = Array.from({ length: CANDIDATE_CAP + 3 }, (_, i) => + glob(`bgm_${i}`, "bgm", `track ${i}`, `track ${i}`, String(i).padStart(64, "0")), + ); + seedManifest(project, []); + seedManifest(home, many); + const { candidates, truncated, total } = listCandidates({ projectDir: project, type: "bgm" }); + assert.equal(candidates.length, CANDIDATE_CAP); + assert.equal(truncated, true); + assert.equal(total.global, CANDIDATE_CAP + 3); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("honors icon<->image adjacency", () => { + const { root, project, home } = sandbox(); + try { + seedManifest(project, []); + seedManifest(home, [glob("image_1", "image", "rocket logo", "rocket logo", "d".repeat(64))]); + const { candidates } = listCandidates({ projectDir: project, type: "icon", intent: "rocket" }); + assert.equal(candidates.length, 1, "image asset surfaces for icon request"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("sha only on global, path only on project", () => { + const { root, project, home } = sandbox(); + try { + seedManifest(project, [proj("bgm_001", "bgm", "x", "x")]); + seedManifest(home, [glob("bgm_009", "bgm", "y", "y", "e".repeat(64))]); + const { candidates } = listCandidates({ projectDir: project, type: "bgm" }); + const p = candidates.find((c) => c.scope === "project"); + const g = candidates.find((c) => c.scope === "global"); + assert.ok(p.path && !p.sha); + assert.ok(g.sha && !g.path); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("findGlobalBySha resolves unique prefix, flags ambiguity, misses cleanly", () => { + const { root, project, home } = sandbox(); + try { + seedManifest(project, []); + seedManifest(home, [ + glob("bgm_1", "bgm", "a", "a", "abc" + "0".repeat(61)), + glob("bgm_2", "bgm", "b", "b", "abd" + "0".repeat(61)), + glob("bgm_3", "bgm", "c", "c", "fff" + "0".repeat(61)), + ]); + assert.equal(findGlobalBySha("fff").id, "bgm_3", "unique prefix resolves"); + assert.deepEqual( + { ambiguous: findGlobalBySha("ab").ambiguous, count: findGlobalBySha("ab").count }, + { ambiguous: true, count: 2 }, + "ambiguous prefix flagged", + ); + assert.equal(findGlobalBySha("zzz"), null, "miss returns null"); + assert.equal(findGlobalBySha(""), null, "empty returns null"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("formatCandidates shows reuse handles by scope; empty message", () => { + const { candidates } = { + candidates: [ + { scope: "project", description: "p", path: ".media/audio/bgm/bgm_001.wav" }, + { scope: "global", description: "g", sha: "f".repeat(64) }, + ], + }; + const out = formatCandidates(candidates, {}); + assert.match(out, /\.media\/audio\/bgm\/bgm_001\.wav/); + assert.match(out, /--reuse ffffffffffffffff/); + assert.match(formatCandidates([], {}), /no reuse candidates/); +}); diff --git a/skills/media-use/scripts/lib/match.mjs b/skills/media-use/scripts/lib/match.mjs new file mode 100644 index 000000000..b56e2c484 --- /dev/null +++ b/skills/media-use/scripts/lib/match.mjs @@ -0,0 +1,46 @@ +// Shared lexical-matching helpers used by both the assets/ scan (adopt.mjs) and +// the reuse-candidate ranker (candidates.mjs), and the type-equivalence check +// used by resolve.mjs and candidates.mjs. Kept in one place so the icon<->image +// equivalence and the token rules can't drift between the "do" path (resolve) +// and the "look" path (candidates). + +// Common filler words that should never, on their own, make two strings match. +const MATCH_STOPWORDS = new Set([ + "the", + "and", + "for", + "with", + "from", + "this", + "that", + "your", + "our", +]); + +// Split into lowercased word tokens of length >= 3, minus stopwords. +export function matchTokens(text) { + return new Set( + String(text) + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 3 && !MATCH_STOPWORDS.has(t)), + ); +} + +// Count of shared meaningful word tokens between two strings. 0 = no lexical +// overlap (the candidate ranker still surfaces these, ordered after overlaps). +export function tokenOverlap(a, b) { + const ta = matchTokens(a); + const tb = matchTokens(b); + let n = 0; + for (const t of ta) if (tb.has(t)) n++; + return n; +} + +// icon and image are interchangeable: both live in images/, and figma-imported +// brand marks are recorded as type image while agents ask for logos as icon. +export function typesMatch(a, b) { + if (a === b) return true; + const visual = new Set(["icon", "image"]); + return visual.has(a) && visual.has(b); +} diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs index d06d5dc7d..41513a96c 100644 --- a/skills/media-use/scripts/resolve.mjs +++ b/skills/media-use/scripts/resolve.mjs @@ -10,6 +10,9 @@ import { runCapability, listTypes } from "./lib/registry.mjs"; import { freezeUrl, freezeLocalFile, isDirectMediaUrl } from "./lib/freeze.mjs"; import { findExistingAsset } from "./lib/adopt.mjs"; import { track } from "./lib/telemetry.mjs"; +import { typesMatch } from "./lib/match.mjs"; +import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./lib/candidates.mjs"; +import { findGlobalBySha } from "./lib/cache.mjs"; const { values: args } = parseArgs({ options: { @@ -18,6 +21,9 @@ const { values: args } = parseArgs({ entity: { type: "string", short: "e" }, project: { type: "string", short: "p", default: "." }, adopt: { type: "boolean", default: false }, + candidates: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + reuse: { type: "string" }, from: { type: "string" }, "local-only": { type: "boolean", default: false }, provider: { type: "string" }, @@ -41,6 +47,10 @@ Options: --entity, -e Entity name for cache matching (optional) --project, -p Project directory (default: .) --adopt Adopt all existing assets/ files into the manifest + --candidates List reusable assets (project + global cache) for --type; no + download, no mutation. Read them and decide reuse yourself. + --reuse Import a specific global-cache asset (by content sha/prefix, + from --candidates) into this project --provider Force one generator (e.g. codex, mflux, kokoro, heygen) --json Output JSON instead of one-line result --help, -h Show this help`); @@ -62,6 +72,21 @@ if (args.adopt) { process.exit(0); } +// Candidates: side-effect-free listing of reusable assets (project + global +// cache) for --type. No download, no provider, no mutation. The agent reads +// these and decides semantic fit itself. +if (args.candidates || args["dry-run"]) { + await showCandidates(); + process.exit(0); +} + +// Reuse: import a specific global-cache asset (by content sha/prefix, taken +// from --candidates) into this project. +if (args.reuse) { + await reuseGlobal(args.reuse); + process.exit(0); +} + // Ingest: freeze a user-supplied local file or direct public URL (no search). if (args.from) { await ingest(args.from); @@ -155,6 +180,22 @@ async function run() { const localOnly = args["local-only"]; const ctx = { entity, projectDir, localOnly, provider: args.provider }; + // Adherence nudge (offline, no auto-reuse): the exact-cache floor missed and + // we're about to fetch/generate. If lexically-similar assets already exist, + // point the agent at --candidates so it can reuse instead of fetching. Only a + // fuzzy match ever reaches the agent this way — never auto-applied. Goes to + // stderr so it reaches --json callers without corrupting stdout. Best-effort. + try { + const { similar } = listCandidates({ projectDir, type, intent, cap: CANDIDATE_CAP }); + if (similar > 0) { + console.error( + `media-use: ${similar} similar cached asset${similar === 1 ? "" : "s"} already exist — run \`resolve --candidates --type ${type} --intent "${intent}"\` to review and reuse instead of fetching.`, + ); + } + } catch { + // hint is best-effort; never block a resolve + } + // 3. provider search — registry tries providers in order (heygen-CLI first) let searchResult = null; try { @@ -284,10 +325,65 @@ async function ingest(src) { await result(record, "ingested"); } -function typesMatch(a, b) { - if (a === b) return true; - const visual = new Set(["icon", "image"]); - return visual.has(a) && visual.has(b); +async function showCandidates() { + const projectDir = resolve(args.project); + const type = args.type; + if (!type || !listTypes().includes(type)) { + console.error(`error: --candidates requires --type (one of: ${listTypes().join(", ")})`); + process.exit(2); + } + const intent = args.intent || ""; + const { candidates, truncated, total, similar } = listCandidates({ + projectDir, + type, + intent, + cap: CANDIDATE_CAP, + }); + await track("media_use_candidates", { + type, + project_n: total.project, + global_n: total.global, + local_only: !!args["local-only"], + }); + if (args.json) { + console.log(JSON.stringify({ ok: true, candidates, truncated, total, similar })); + } else { + console.log(formatCandidates(candidates, { truncated, total })); + } +} + +async function reuseGlobal(shaArg) { + const projectDir = resolve(args.project); + const type = args.type; + if (!type || !listTypes().includes(type)) { + console.error(`error: --reuse requires --type (one of: ${listTypes().join(", ")})`); + process.exit(2); + } + const rec = findGlobalBySha(shaArg); + if (rec && rec.ambiguous) { + console.error( + `error: sha prefix "${shaArg}" is ambiguous (${rec.count} matches) — use more characters`, + ); + process.exit(2); + } + if (!rec) { + console.error(`error: no reusable global asset matches sha "${shaArg}"`); + process.exit(1); + } + const id = nextId(projectDir, type); + const ext = extname(rec.cached_path || "") || defaultExt(type); + const localPath = `.media/${typeSubdir(type)}/${id}${ext}`; + const imported = importFromCache(rec, projectDir, id, localPath); + if (!imported) { + console.error(`error: cache entry for "${shaArg}" is incomplete or missing on disk`); + process.exit(1); + } + // Distinguish an explicit agent reuse from an automatic normalize-exact hit. + imported.source = "reused-explicit"; + imported.provenance = { ...imported.provenance, reused_by: "agent" }; + appendRecord(projectDir, imported); + regenerateIndex(projectDir); + await result(imported, "reused-explicit"); } async function result(record, source) { @@ -313,7 +409,7 @@ function formatMeta(record, source) { if (record.duration != null) parts.push(`${record.duration}s`); if (record.width && record.height) parts.push(`${record.width}×${record.height}`); if (record.transparent) parts.push("transparent"); - if (source === "reused") parts.push("reused"); + if (source === "reused" || source === "reused-explicit") parts.push("reused"); if (source === "generated") parts.push("generated"); return parts.join(", "); }