mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2029 from heygen-com/feat/media-use-agentic-reuse
feat(media-use): agent-driven asset reuse (candidates + reuse)
This commit is contained in:
@@ -46,8 +46,8 @@
|
||||
"files": 10
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "caf64c363a39ad9b",
|
||||
"files": 98
|
||||
"hash": "d28c7979e727a6a9",
|
||||
"files": 101
|
||||
},
|
||||
"motion-graphics": {
|
||||
"hash": "f5a432862116b39a",
|
||||
|
||||
+45
-17
@@ -68,17 +68,41 @@ node <SKILL_DIR>/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 <sha>` | 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 <SKILL_DIR>/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 <sha>` 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/<type>/`, 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/<type>/`, 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 <sha>` 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <sha>`. 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;
|
||||
|
||||
@@ -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 <sha>`. 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");
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <sha> 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(", ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user