fix(media-use): forgiving prompt matching + precise assets/ scan

Two defects in the resolve cascade that made cache and asset-reuse
misbehave in practice:

- Prompt matching was byte-exact and case-sensitive. findByPrompt and
  cacheGet compared provenance.prompt with ===, so "Calm piano" and
  "calm  piano" re-searched and re-downloaded instead of reusing the
  cached asset (same project and cross-project). Add normalizePrompt
  (trim + lowercase + collapse whitespace) and key both lookups on it;
  the raw prompt is still stored for audit.

- findExistingAsset matched with name.includes(intent) ||
  intent.includes(name), which silently returned the WRONG local file:
  intent "whoosh" grabbed a stray who.mp3, and a one-letter filename
  matched every intent. Require a shared word token (>= 3 chars, minus
  stopwords) so a false negative just falls through to a catalog search
  rather than shipping the wrong asset.

Adds lib/adopt.test.mjs and extends manifest.test.mjs. Full media-use
suite green; verified e2e against the live catalog (case-variant
cross-project resolve now reuses; whoosh no longer grabs who.mp3).
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-07 15:21:41 -04:00
parent 2bd9bb6f69
commit b5383ded42
7 changed files with 179 additions and 13 deletions
+2 -2
View File
@@ -46,8 +46,8 @@
"files": 10 "files": 10
}, },
"media-use": { "media-use": {
"hash": "db5787c5ff2bd852", "hash": "caf64c363a39ad9b",
"files": 97 "files": 98
}, },
"motion-graphics": { "motion-graphics": {
"hash": "f5a432862116b39a", "hash": "f5a432862116b39a",
+3 -3
View File
@@ -111,8 +111,8 @@ leaving the project + global cache and any local provider.
## How it works ## How it works
1. Check project `.media/manifest.jsonl` for exact-prompt match 1. Check project `.media/manifest.jsonl` for a prompt match (case- and whitespace-insensitive)
2. Scan existing `assets/` directory for unregistered files matching the need 2. Scan existing `assets/` directory for unregistered files that share a word with the need
3. Check global cache `~/.media/` for reusable asset 3. Check global cache `~/.media/` for reusable asset
4. Search via provider (HeyGen audio catalog, HeyGen asset search) 4. Search via provider (HeyGen audio catalog, HeyGen asset search)
5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md` 5. Freeze file to `.media/<type>/`, register in manifest, regenerate `index.md`
@@ -148,7 +148,7 @@ icon_001 icon - 200×200 .media/images/icon_001.png rocket
## Cross-project reuse ## Cross-project reuse
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 prompt, in any project, hit the cache with no re-download and no provider call. 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.
## Files ## Files
+37 -4
View File
@@ -96,16 +96,49 @@ export function adoptExistingAssets(projectDir) {
return adopted; 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
// one-letter filename matched every intent. A false negative just falls through
// to a catalog search (safe); a false positive ships the wrong asset. So bias to
// precision: require a shared token, don't guess from substrings.
export function findExistingAsset(projectDir, intent, type) { export function findExistingAsset(projectDir, intent, type) {
const assetsDir = join(projectDir, "assets"); const assetsDir = join(projectDir, "assets");
if (!existsSync(assetsDir)) return null; if (!existsSync(assetsDir)) return null;
const lower = intent.toLowerCase(); const intentTokens = matchTokens(intent);
if (intentTokens.size === 0) return null;
for (const rel of walkDir(assetsDir)) { for (const rel of walkDir(assetsDir)) {
const t = inferType(rel); const t = inferType(rel);
if (!t || (type && t !== type)) continue; if (!t || (type && t !== type)) continue;
const name = basename(rel, extname(rel)).toLowerCase().replace(/[-_]/g, " "); const stem = basename(rel, extname(rel));
if (name.includes(lower) || lower.includes(name)) { for (const tok of matchTokens(stem)) {
return { relativePath: `assets/${rel}`, type: t, name: basename(rel, extname(rel)) }; if (intentTokens.has(tok)) {
return { relativePath: `assets/${rel}`, type: t, name: stem };
}
} }
} }
return null; return null;
@@ -0,0 +1,95 @@
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 { findExistingAsset, adoptExistingAssets } from "./adopt.mjs";
let tmp;
function setup() {
tmp = mkdtempSync(join(tmpdir(), "mu-adopt-test-"));
}
function cleanup() {
if (tmp) rmSync(tmp, { recursive: true, force: true });
}
function drop(rel) {
const full = join(tmp, "assets", rel);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, "x");
}
function runTests() {
const tests = [];
const test = (name, fn) => tests.push({ name, fn });
test("does NOT false-match a short filename stem (who.mp3 vs 'whoosh')", () => {
setup();
drop("sfx/who.mp3");
assert.equal(findExistingAsset(tmp, "whoosh", "sfx"), null);
cleanup();
});
test("does NOT match a one-letter filename against any intent", () => {
setup();
drop("images/a.jpg");
assert.equal(findExistingAsset(tmp, "gradient tech background", "image"), null);
cleanup();
});
test("matches on a shared meaningful word", () => {
setup();
drop("images/hero-shot.jpg");
const hit = findExistingAsset(tmp, "hero image", "image");
assert.ok(hit);
assert.equal(hit.relativePath, "assets/images/hero-shot.jpg");
cleanup();
});
test("matches multi-word overlap", () => {
setup();
drop("images/gradient-tech-bg.jpg");
assert.ok(findExistingAsset(tmp, "gradient tech background", "image"));
cleanup();
});
test("a shared stopword alone does not match", () => {
setup();
drop("video/the-video.mp4");
assert.equal(findExistingAsset(tmp, "the rocket", null), null);
cleanup();
});
test("respects the type filter", () => {
setup();
drop("sfx/rocket.mp3");
assert.equal(findExistingAsset(tmp, "rocket", "image"), null, "wrong type is skipped");
assert.ok(findExistingAsset(tmp, "rocket", "sfx"), "right type matches");
cleanup();
});
test("adoptExistingAssets still imports every typed file (unaffected by match rule)", () => {
setup();
drop("sfx/who.mp3");
drop("images/a.jpg");
assert.equal(adoptExistingAssets(tmp).length, 2);
cleanup();
});
let passed = 0;
let failed = 0;
for (const { name, fn } of tests) {
try {
fn();
passed++;
console.log(` \x1b[32m✓\x1b[0m ${name}`);
} catch (err) {
failed++;
console.log(` \x1b[31m✗\x1b[0m ${name}`);
console.log(` ${err.message}`);
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
}
console.log("media-use · adopt / findExistingAsset tests\n");
runTests();
+7 -2
View File
@@ -2,7 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync } from
import { join, basename } from "node:path"; import { join, basename } from "node:path";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { readManifest, appendRecord } from "./manifest.mjs"; import { readManifest, appendRecord, normalizePrompt } from "./manifest.mjs";
const SCHEMA_PREFIX = "mu-v1-"; const SCHEMA_PREFIX = "mu-v1-";
const KEY_HEX_CHARS = 16; const KEY_HEX_CHARS = 16;
@@ -43,9 +43,14 @@ function validateCacheHit(match) {
} }
export function cacheGet(prompt, type) { export function cacheGet(prompt, type) {
const key = normalizePrompt(prompt);
if (!key) return null;
return validateCacheHit( return validateCacheHit(
readGlobalManifest().find( readGlobalManifest().find(
(r) => r.reusable && r.provenance?.prompt === prompt && (type == null || r.type === type), (r) =>
r.reusable &&
normalizePrompt(r.provenance?.prompt) === key &&
(type == null || r.type === type),
), ),
); );
} }
+16 -2
View File
@@ -64,11 +64,25 @@ export function appendRecord(projectDir, record) {
appendFileSync(p, line); appendFileSync(p, line);
} }
// Match prompts forgivingly. Agents rarely re-emit a byte-identical intent, so
// keying cache lookups on exact equality meant "Calm piano" and "calm piano"
// re-searched and re-downloaded. Normalize (trim, lowercase, collapse internal
// whitespace) on both sides; the raw prompt is still stored for audit.
export function normalizePrompt(prompt) {
return String(prompt ?? "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
export function findByPrompt(projectDir, prompt, type) { export function findByPrompt(projectDir, prompt, type) {
const key = normalizePrompt(prompt);
if (!key) return null;
const records = readManifest(projectDir); const records = readManifest(projectDir);
return ( return (
records.find((r) => r.provenance?.prompt === prompt && (type == null || r.type === type)) || records.find(
null (r) => normalizePrompt(r.provenance?.prompt) === key && (type == null || r.type === type),
) || null
); );
} }
@@ -8,6 +8,7 @@ import {
findByPrompt, findByPrompt,
findByEntity, findByEntity,
nextId, nextId,
normalizePrompt,
manifestPath, manifestPath,
mediaDir, mediaDir,
typeDirPath, typeDirPath,
@@ -114,6 +115,20 @@ function runTests() {
cleanup(); cleanup();
}); });
test("findByPrompt matches across case and whitespace variants", () => {
setup();
appendRecord(tmp, makeRecord({ provenance: { provider: "x", prompt: "calm ambient piano" } }));
assert.ok(findByPrompt(tmp, "Calm Ambient Piano", "bgm"), "case-insensitive");
assert.ok(findByPrompt(tmp, " calm ambient piano ", "bgm"), "whitespace-insensitive");
assert.equal(findByPrompt(tmp, "calm ambient guitar", "bgm"), null, "still a real miss");
cleanup();
});
test("normalizePrompt trims, lowercases, collapses whitespace", () => {
assert.equal(normalizePrompt(" Upbeat Tech Launch "), "upbeat tech launch");
assert.equal(normalizePrompt(null), "");
});
test("findByEntity matches case-insensitively", () => { test("findByEntity matches case-insensitively", () => {
setup(); setup();
appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" })); appendRecord(tmp, makeRecord({ entity: "GitHub", type: "icon" }));
@@ -203,6 +218,10 @@ function runTests() {
assert.ok(found); assert.ok(found);
assert.equal(found.reusable, true); assert.equal(found.reusable, true);
assert.equal(found.sha, sha); assert.equal(found.sha, sha);
// cross-project reuse must survive trivial prompt variation, not just
// byte-identical intents (the whole point of normalizePrompt).
assert.ok(cacheGet(" Cache Test ", "bgm"), "cacheGet is case/whitespace-insensitive");
cleanup(); cleanup();
}); });