dsh-hyperframes 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.en.md +4 -3
  3. package/README.md +4 -3
  4. package/docs/WORKBENCH.md +6 -0
  5. package/lib/client.js +40 -2
  6. package/lib/media-templates.d.ts +1 -1
  7. package/lib/media-templates.js +1 -1
  8. package/package.json +2 -2
  9. package/plugin.json +1 -1
  10. package/skills/NOTICE.md +1 -1
  11. package/skills/UPSTREAM.json +93 -8
  12. package/skills/embedded-captions/SKILL.md +1 -1
  13. package/skills/faceless-explainer/SKILL.md +1 -1
  14. package/skills/general-video/SKILL.md +1 -1
  15. package/skills/hyperframes-animation/SKILL.md +1 -1
  16. package/skills/hyperframes-audio/SKILL.md +1 -1
  17. package/skills/hyperframes-core/SKILL.md +1 -1
  18. package/skills/hyperframes-creative/SKILL.md +1 -1
  19. package/skills/hyperframes-keyframes/SKILL.md +1 -1
  20. package/skills/hyperframes-studio/SKILL.md +1 -1
  21. package/skills/media-use/scripts/lib/adopt.mjs +128 -0
  22. package/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  23. package/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  24. package/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  25. package/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  26. package/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  27. package/skills/media-use/scripts/lib/cache.mjs +162 -0
  28. package/skills/media-use/scripts/lib/cache.test.mjs +110 -0
  29. package/skills/media-use/scripts/lib/candidates.mjs +91 -0
  30. package/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  31. package/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  32. package/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  33. package/skills/media-use/scripts/lib/config-lock.d.mts +3 -0
  34. package/skills/media-use/scripts/lib/config-lock.test.mjs +79 -0
  35. package/skills/media-use/scripts/lib/coverage.test.mjs +136 -0
  36. package/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  37. package/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  38. package/skills/media-use/scripts/lib/cube-validate.mjs +197 -0
  39. package/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  40. package/skills/media-use/scripts/lib/cutlist.test.mjs +174 -0
  41. package/skills/media-use/scripts/lib/duck.test.mjs +319 -0
  42. package/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  43. package/skills/media-use/scripts/lib/ff-binaries.mjs +37 -0
  44. package/skills/media-use/scripts/lib/freeze.mjs +72 -0
  45. package/skills/media-use/scripts/lib/freeze.test.mjs +138 -0
  46. package/skills/media-use/scripts/lib/grade-analyzer.mjs +277 -0
  47. package/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  48. package/skills/media-use/scripts/lib/heygen-cli.mjs +208 -0
  49. package/skills/media-use/scripts/lib/heygen-cli.test.mjs +315 -0
  50. package/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  51. package/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  52. package/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  53. package/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  54. package/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  55. package/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  56. package/skills/media-use/scripts/lib/local-models.mjs +318 -0
  57. package/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  58. package/skills/media-use/scripts/lib/local-run.mjs +78 -0
  59. package/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  60. package/skills/media-use/scripts/lib/logo-provider.mjs +244 -0
  61. package/skills/media-use/scripts/lib/logo-provider.test.mjs +298 -0
  62. package/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  63. package/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  64. package/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  65. package/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  66. package/skills/media-use/scripts/lib/main-module.mjs +13 -0
  67. package/skills/media-use/scripts/lib/manifest.d.mts +20 -0
  68. package/skills/media-use/scripts/lib/manifest.test.mjs +397 -0
  69. package/skills/media-use/scripts/lib/match.mjs +47 -0
  70. package/skills/media-use/scripts/lib/media-fetch.test.mjs +197 -0
  71. package/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  72. package/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  73. package/skills/media-use/scripts/lib/media-search.mjs +107 -0
  74. package/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  75. package/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  76. package/skills/media-use/scripts/lib/misses.mjs +49 -0
  77. package/skills/media-use/scripts/lib/misses.test.mjs +89 -0
  78. package/skills/media-use/scripts/lib/npx-sync.mjs +2 -28
  79. package/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  80. package/skills/media-use/scripts/lib/openverse.mjs +134 -0
  81. package/skills/media-use/scripts/lib/openverse.test.mjs +219 -0
  82. package/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  83. package/skills/media-use/scripts/lib/prefs-store.concurrent.test.mjs +173 -0
  84. package/skills/media-use/scripts/lib/prefs-store.test.mjs +245 -0
  85. package/skills/media-use/scripts/lib/probe.mjs +46 -0
  86. package/skills/media-use/scripts/lib/probe.test.mjs +221 -0
  87. package/skills/media-use/scripts/lib/providers.mjs +5 -0
  88. package/skills/media-use/scripts/lib/recipe-store.test.mjs +391 -0
  89. package/skills/media-use/scripts/lib/registry.mjs +214 -0
  90. package/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  91. package/skills/media-use/scripts/lib/search.mjs +24 -0
  92. package/skills/media-use/scripts/lib/search.test.mjs +32 -0
  93. package/skills/media-use/scripts/lib/sfx-provider.mjs +33 -0
  94. package/skills/media-use/scripts/lib/specs.mjs +132 -0
  95. package/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  96. package/skills/media-use/scripts/lib/stats.mjs +117 -0
  97. package/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  98. package/skills/media-use/scripts/lib/svg-sanitize.mjs +21 -0
  99. package/skills/media-use/scripts/lib/telemetry.test.mjs +437 -53
  100. package/skills/media-use/scripts/lib/test-home.test.mjs +10 -0
  101. package/skills/media-use/scripts/lib/transcriptCutFade.test.mjs +33 -0
  102. package/skills/media-use/scripts/lib/tts-local-provider.mjs +94 -0
  103. package/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  104. package/skills/media-use/scripts/lib/usage.mjs +57 -0
  105. package/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  106. package/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  107. package/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  108. package/skills/media-use/scripts/resolve.mjs +1360 -27
  109. package/skills/motion-graphics/SKILL.md +1 -1
  110. package/skills/music-to-video/SKILL.md +1 -1
  111. package/skills/pr-to-video/SKILL.md +1 -1
  112. package/skills/product-launch-video/SKILL.md +1 -1
  113. package/skills/talking-head-recut/SKILL.md +1 -1
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { appendRecord } from "./manifest.mjs";
7
+ import {
8
+ cacheGet,
9
+ cacheGetByEntity,
10
+ cachePut,
11
+ findGlobalBySha,
12
+ importFromCache,
13
+ readGlobalManifest,
14
+ } from "./cache.mjs";
15
+
16
+ function sandbox(t) {
17
+ const root = mkdtempSync(join(tmpdir(), "mu-cache-recovery-"));
18
+ const previous = process.env.HYPERFRAMES_MEDIA_HOME;
19
+ process.env.HYPERFRAMES_MEDIA_HOME = join(root, "library");
20
+ t.after(() => {
21
+ if (previous === undefined) delete process.env.HYPERFRAMES_MEDIA_HOME;
22
+ else process.env.HYPERFRAMES_MEDIA_HOME = previous;
23
+ rmSync(root, { recursive: true, force: true });
24
+ });
25
+ const source = join(root, "original.wav");
26
+ const bytes = "cache recovery fixture";
27
+ writeFileSync(source, bytes);
28
+ const record = {
29
+ id: "bgm_001",
30
+ type: "bgm",
31
+ entity: "Recovery",
32
+ provenance: { prompt: "cache recovery" },
33
+ };
34
+ return { root, source, bytes, record };
35
+ }
36
+
37
+ for (const invalid of [
38
+ "missing file",
39
+ "missing marker",
40
+ "missing directory",
41
+ "empty file",
42
+ "directory",
43
+ ]) {
44
+ test(`cachePut repairs a ${invalid} before deduplicating`, (t) => {
45
+ const { root, source, bytes, record } = sandbox(t);
46
+ const first = cachePut(source, record);
47
+ const entry = dirname(first.cached_path);
48
+ if (invalid === "missing marker") rmSync(join(entry, ".hf-complete"));
49
+ else if (invalid === "missing directory") rmSync(entry, { recursive: true });
50
+ else if (invalid === "empty file") writeFileSync(first.cached_path, "");
51
+ else {
52
+ rmSync(first.cached_path);
53
+ if (invalid === "directory") mkdirSync(first.cached_path);
54
+ }
55
+
56
+ const replacement = join(root, "replacement.wav");
57
+ writeFileSync(replacement, bytes);
58
+ const repaired = cachePut(replacement, record);
59
+
60
+ assert.equal(repaired.deduped, undefined);
61
+ assert.equal(repaired.sha, first.sha);
62
+ assert.equal(readFileSync(repaired.cached_path, "utf8"), bytes);
63
+ const cached = cacheGet(" Cache Recovery ", "bgm");
64
+ assert.equal(cached?.sha, repaired.sha);
65
+ assert.equal(readFileSync(cached.cached_path, "utf8"), bytes);
66
+ assert.equal(cacheGetByEntity("recovery")?.sha, repaired.sha);
67
+ const imported = importFromCache(cached, join(root, "project"), "bgm_002", "restored.wav");
68
+ assert.equal(imported?.provenance.imported_from, first.sha);
69
+ assert.equal(readFileSync(join(root, "project", "restored.wav"), "utf8"), bytes);
70
+ });
71
+ }
72
+
73
+ test("an intact cache stays deduplicated without adding a manifest record", (t) => {
74
+ const { source, record } = sandbox(t);
75
+ const first = cachePut(source, record);
76
+ const second = cachePut(source, record);
77
+
78
+ assert.deepEqual(second, { ...first, deduped: true });
79
+ assert.equal(readGlobalManifest().length, 1);
80
+ });
81
+
82
+ test("prompt and entity lookups skip a stale entry for another complete match", (t) => {
83
+ const { root, source, record } = sandbox(t);
84
+ const first = cachePut(source, record);
85
+ rmSync(first.cached_path);
86
+ const secondSource = join(root, "second.wav");
87
+ writeFileSync(secondSource, "different reusable bytes");
88
+ const second = cachePut(secondSource, record);
89
+
90
+ assert.equal(cacheGet("cache recovery", "bgm")?.sha, second.sha);
91
+ assert.equal(cacheGetByEntity("RECOVERY")?.sha, second.sha);
92
+ assert.equal(cacheGet("cache recovery", "sfx"), null);
93
+ });
94
+
95
+ test("sha reuse counts distinct content and selects the repaired record", (t) => {
96
+ const { root, source, bytes, record } = sandbox(t);
97
+ const first = cachePut(source, record);
98
+ rmSync(first.cached_path);
99
+ const replacement = join(dirname(first.cached_path), "replacement.wav");
100
+ writeFileSync(replacement, bytes);
101
+ appendRecord(join(root, "library"), {
102
+ ...record,
103
+ sha: first.sha,
104
+ reusable: true,
105
+ cached_path: replacement,
106
+ });
107
+
108
+ assert.equal(findGlobalBySha(first.sha)?.cached_path, replacement);
109
+ assert.equal(findGlobalBySha(first.sha.slice(0, 16))?.cached_path, replacement);
110
+ });
@@ -0,0 +1,91 @@
1
+ // Reuse candidates: a side-effect-free view of assets already available to this
2
+ // project (its own manifest) and across every project (the global ~/.media
3
+ // cache), so the calling agent can judge semantic fit itself. No download, no
4
+ // provider, no mutation. The ranker only *surfaces* — it orders by lexical
5
+ // overlap but never filters a candidate out on zero overlap (that would
6
+ // pre-empt the agent's judgment); the agent does the semantic call.
7
+
8
+ import { currentRecords } from "./manifest.mjs";
9
+ import { readGlobalManifest } from "./cache.mjs";
10
+ import { tokenOverlap, typesMatch } from "./match.mjs";
11
+
12
+ export const CANDIDATE_CAP = 8;
13
+
14
+ function shape(record, scope, intent) {
15
+ const description = record.description || record.provenance?.prompt || "";
16
+ const prompt = record.provenance?.prompt || null;
17
+ return {
18
+ id: record.id,
19
+ type: record.type,
20
+ scope,
21
+ description,
22
+ prompt,
23
+ provider: record.provenance?.provider || null,
24
+ duration: record.duration ?? null,
25
+ width: record.width ?? null,
26
+ height: record.height ?? null,
27
+ // Only global records carry a content sha — it is the stable reuse handle
28
+ // for `resolve --reuse <sha>`. Project assets are reused by referencing
29
+ // their path directly, so they need no handle.
30
+ sha: scope === "global" ? record.sha || null : null,
31
+ path: scope === "project" ? record.path : null,
32
+ score: intent ? tokenOverlap(intent, `${description} ${prompt || ""}`) : 0,
33
+ };
34
+ }
35
+
36
+ // Rank one scope: type-matched (icon<->image aware), newest-first within equal
37
+ // overlap, ordered by overlap desc. Returns the full ranked list (uncapped).
38
+ function rankScope(records, scope, type, intent) {
39
+ return records
40
+ .filter((r) => typesMatch(r.type, type))
41
+ .reverse() // manifest is append-order (oldest first); newest-first at equal score
42
+ .map((r) => shape(r, scope, intent))
43
+ .sort((a, b) => b.score - a.score); // Array.sort is stable → recency preserved
44
+ }
45
+
46
+ // List reuse candidates for `type`, capped per scope. Returns:
47
+ // candidates: capped project candidates followed by capped global candidates
48
+ // truncated: true if either scope had more than `cap`
49
+ // total: { project, global } counts before the cap (machine-readable)
50
+ // similar: count of candidates with lexical overlap > 0 (drives the nudge)
51
+ export function listCandidates({ projectDir, type, intent = "", cap = CANDIDATE_CAP }) {
52
+ const project = rankScope(currentRecords(projectDir), "project", type, intent);
53
+ const global = rankScope(readGlobalManifest(), "global", type, intent);
54
+ const candidates = [...project.slice(0, cap), ...global.slice(0, cap)];
55
+ return {
56
+ candidates,
57
+ truncated: project.length > cap || global.length > cap,
58
+ total: { project: project.length, global: global.length },
59
+ similar: [...project, ...global].filter((c) => c.score > 0).length,
60
+ };
61
+ }
62
+
63
+ function meta(c) {
64
+ const parts = [];
65
+ if (c.duration != null) parts.push(`${c.duration}s`);
66
+ if (c.width && c.height) parts.push(`${c.width}x${c.height}`);
67
+ if (c.provider) parts.push(c.provider);
68
+ return parts.join(", ");
69
+ }
70
+
71
+ // Human-readable listing. The agent can read this directly; --json is for
72
+ // programmatic use. Reuse handle differs by scope: path for project, sha for
73
+ // global.
74
+ export function formatCandidates(candidates, { truncated, total } = {}) {
75
+ if (candidates.length === 0) return "no reuse candidates found (project or global cache)";
76
+ const lines = [`${candidates.length} reuse candidate${candidates.length === 1 ? "" : "s"}:`, ""];
77
+ for (const c of candidates) {
78
+ const handle =
79
+ c.scope === "global" ? `--reuse ${String(c.sha).slice(0, 16)}` : c.path || `manifest:${c.id}`;
80
+ const m = meta(c);
81
+ lines.push(` [${c.scope}] ${c.description}${m ? ` (${m})` : ""}`);
82
+ lines.push(` ${handle}`);
83
+ }
84
+ if (truncated && total) {
85
+ lines.push("");
86
+ lines.push(
87
+ ` (showing top ${CANDIDATE_CAP} per scope; ${total.project} project / ${total.global} global total — refine --intent to narrow)`,
88
+ );
89
+ }
90
+ return lines.join("\n");
91
+ }
@@ -0,0 +1,155 @@
1
+ import { test } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { listCandidates, formatCandidates, CANDIDATE_CAP } from "./candidates.mjs";
7
+ import { findGlobalBySha } from "./cache.mjs";
8
+
9
+ // candidates + findGlobalBySha are offline (no heygen), so we can point HYPERFRAMES_MEDIA_HOME
10
+ // to a temp dir and seed a fake global ~/.media manifest deterministically.
11
+ function sandbox() {
12
+ const root = mkdtempSync(join(tmpdir(), "mu-cand-"));
13
+ const project = join(root, "proj");
14
+ const home = join(root, "home");
15
+ process.env.HYPERFRAMES_MEDIA_HOME = home;
16
+ return { root, project, home };
17
+ }
18
+ function seedManifest(dir, records) {
19
+ const md = join(dir, ".media");
20
+ mkdirSync(md, { recursive: true });
21
+ writeFileSync(md + "/manifest.jsonl", records.map((r) => JSON.stringify(r)).join("\n") + "\n");
22
+ }
23
+ function proj(id, type, description, prompt) {
24
+ return { id, type, path: `.media/audio/bgm/${id}.wav`, description, provenance: { prompt } };
25
+ }
26
+ function glob(id, type, description, prompt, sha) {
27
+ return {
28
+ id,
29
+ type,
30
+ sha,
31
+ reusable: true,
32
+ cached_path: `/x/${sha}/${id}.wav`,
33
+ description,
34
+ provenance: { prompt, provider: "heygen.audio.sounds" },
35
+ };
36
+ }
37
+
38
+ test("ranks project + global by overlap, tags scope", () => {
39
+ const { root, project, home } = sandbox();
40
+ try {
41
+ seedManifest(project, [proj("bgm_001", "bgm", "calm ambient piano", "calm ambient piano")]);
42
+ seedManifest(home, [
43
+ glob("bgm_009", "bgm", "energetic tech launch", "energetic tech launch", "a".repeat(64)),
44
+ glob("bgm_010", "bgm", "sad corporate piano", "sad corporate piano", "b".repeat(64)),
45
+ ]);
46
+ const { candidates } = listCandidates({
47
+ projectDir: project,
48
+ type: "bgm",
49
+ intent: "tech launch",
50
+ });
51
+ assert.equal(candidates[0].scope, "project"); // project listed first
52
+ const g = candidates.filter((c) => c.scope === "global");
53
+ assert.equal(g[0].description, "energetic tech launch"); // higher overlap ranks first
54
+ assert.ok(g[0].score >= g[1].score);
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ test("zero-overlap intent still lists candidates (no hard filter)", () => {
61
+ const { root, project, home } = sandbox();
62
+ try {
63
+ seedManifest(project, []);
64
+ seedManifest(home, [glob("bgm_009", "bgm", "driving synth", "driving synth", "c".repeat(64))]);
65
+ const { candidates, similar } = listCandidates({
66
+ projectDir: project,
67
+ type: "bgm",
68
+ intent: "totally unrelated words xyz",
69
+ });
70
+ assert.equal(candidates.length, 1, "listed despite zero overlap");
71
+ assert.equal(candidates[0].score, 0);
72
+ assert.equal(similar, 0, "similar counts only overlap>0");
73
+ } finally {
74
+ rmSync(root, { recursive: true, force: true });
75
+ }
76
+ });
77
+
78
+ test("caps per scope and reports truncation + totals", () => {
79
+ const { root, project, home } = sandbox();
80
+ try {
81
+ const many = Array.from({ length: CANDIDATE_CAP + 3 }, (_, i) =>
82
+ glob(`bgm_${i}`, "bgm", `track ${i}`, `track ${i}`, String(i).padStart(64, "0")),
83
+ );
84
+ seedManifest(project, []);
85
+ seedManifest(home, many);
86
+ const { candidates, truncated, total } = listCandidates({ projectDir: project, type: "bgm" });
87
+ assert.equal(candidates.length, CANDIDATE_CAP);
88
+ assert.equal(truncated, true);
89
+ assert.equal(total.global, CANDIDATE_CAP + 3);
90
+ } finally {
91
+ rmSync(root, { recursive: true, force: true });
92
+ }
93
+ });
94
+
95
+ test("honors icon<->image adjacency", () => {
96
+ const { root, project, home } = sandbox();
97
+ try {
98
+ seedManifest(project, []);
99
+ seedManifest(home, [glob("image_1", "image", "rocket logo", "rocket logo", "d".repeat(64))]);
100
+ const { candidates } = listCandidates({ projectDir: project, type: "icon", intent: "rocket" });
101
+ assert.equal(candidates.length, 1, "image asset surfaces for icon request");
102
+ } finally {
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ });
106
+
107
+ test("sha only on global, path only on project", () => {
108
+ const { root, project, home } = sandbox();
109
+ try {
110
+ seedManifest(project, [proj("bgm_001", "bgm", "x", "x")]);
111
+ seedManifest(home, [glob("bgm_009", "bgm", "y", "y", "e".repeat(64))]);
112
+ const { candidates } = listCandidates({ projectDir: project, type: "bgm" });
113
+ const p = candidates.find((c) => c.scope === "project");
114
+ const g = candidates.find((c) => c.scope === "global");
115
+ assert.ok(p.path && !p.sha);
116
+ assert.ok(g.sha && !g.path);
117
+ } finally {
118
+ rmSync(root, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("findGlobalBySha resolves unique prefix, flags ambiguity, misses cleanly", () => {
123
+ const { root, project, home } = sandbox();
124
+ try {
125
+ seedManifest(project, []);
126
+ seedManifest(home, [
127
+ glob("bgm_1", "bgm", "a", "a", "abc" + "0".repeat(61)),
128
+ glob("bgm_2", "bgm", "b", "b", "abd" + "0".repeat(61)),
129
+ glob("bgm_3", "bgm", "c", "c", "fff" + "0".repeat(61)),
130
+ ]);
131
+ assert.equal(findGlobalBySha("fff").id, "bgm_3", "unique prefix resolves");
132
+ assert.deepEqual(
133
+ { ambiguous: findGlobalBySha("ab").ambiguous, count: findGlobalBySha("ab").count },
134
+ { ambiguous: true, count: 2 },
135
+ "ambiguous prefix flagged",
136
+ );
137
+ assert.equal(findGlobalBySha("zzz"), null, "miss returns null");
138
+ assert.equal(findGlobalBySha(""), null, "empty returns null");
139
+ } finally {
140
+ rmSync(root, { recursive: true, force: true });
141
+ }
142
+ });
143
+
144
+ test("formatCandidates shows reuse handles by scope; empty message", () => {
145
+ const { candidates } = {
146
+ candidates: [
147
+ { scope: "project", description: "p", path: ".media/audio/bgm/bgm_001.wav" },
148
+ { scope: "global", description: "g", sha: "f".repeat(64) },
149
+ ],
150
+ };
151
+ const out = formatCandidates(candidates, {});
152
+ assert.match(out, /\.media\/audio\/bgm\/bgm_001\.wav/);
153
+ assert.match(out, /--reuse ffffffffffffffff/);
154
+ assert.match(formatCandidates([], {}), /no reuse candidates/);
155
+ });
@@ -0,0 +1,161 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Image generation via the OpenAI Codex CLI's built-in image tool (gpt-image-2)
7
+ // on the user's ChatGPT subscription: the codex CLI owns auth, media-use holds
8
+ // no key (CLI-only). The image UPSELL behind local mflux; skipped by --local-only.
9
+ //
10
+ // Retrieval mirrors illo-skill rather than trusting the model to save a file:
11
+ // `--enable image_generation` makes the built-in tool drop the rendered artifact
12
+ // into $CODEX_HOME/generated_images/, and we fetch the freshest file that
13
+ // postdates this run. The save-to-path instruction is only a best-effort
14
+ // verify-first.
15
+ //
16
+ // The flag was called `imagegenext` until Codex CLI ~0.145, which renamed it to
17
+ // `image_generation` and dropped the old row from `codex features list`. Both
18
+ // the gate below and the --enable arg track the new name, with a fallback to
19
+ // the old one so older CLIs keep working.
20
+
21
+ const TIMEOUT_MS = 600000; // codex exec round-trips the sub; first-run tool spin-up is slow
22
+ const MTIME_SKEW_MS = 2000; // tolerate mtime granularity / clock skew (illo uses 2s)
23
+
24
+ function codexGeneratedDir() {
25
+ // Codex relocates CODEX_HOME on some hosts, so resolve it at run time.
26
+ return join(process.env.CODEX_HOME || join(homedir(), ".codex"), "generated_images");
27
+ }
28
+
29
+ // Newest artifact that postdates `sinceMs` (minus skew), so a stale prior render
30
+ // or a concurrent session's file can't be mistaken for this run's output.
31
+ function freshestGeneratedImage(sinceMs) {
32
+ const dir = codexGeneratedDir();
33
+ if (!existsSync(dir)) return null;
34
+ const floor = sinceMs - MTIME_SKEW_MS;
35
+ let best = null;
36
+ for (const name of readdirSync(dir)) {
37
+ let st;
38
+ try {
39
+ st = statSync(join(dir, name));
40
+ } catch {
41
+ continue;
42
+ }
43
+ if (!st.isFile() || st.mtimeMs < floor) continue;
44
+ if (!best || st.mtimeMs > best.mtimeMs) best = { path: join(dir, name), mtimeMs: st.mtimeMs };
45
+ }
46
+ return best?.path ?? null;
47
+ }
48
+
49
+ // Short `codex` subcommand → combined stdout+stderr, or null if it can't run.
50
+ function codexRun(args) {
51
+ try {
52
+ return execFileSync("codex", args, {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ timeout: 10000,
56
+ });
57
+ } catch (err) {
58
+ return `${err.stdout?.toString() ?? ""}${err.stderr?.toString() ?? ""}` || null;
59
+ }
60
+ }
61
+
62
+ // Name of the feature flag this CLI uses for the extended image tool, or null if
63
+ // it exposes neither. Newer CLIs list only `image_generation`; older ones list
64
+ // `imagegenext` alongside it and reject the new name on --enable.
65
+ export function imageGenFlag(feats) {
66
+ if (/\bimagegenext\b/.test(feats)) return "imagegenext";
67
+ if (/\bimage_generation\b/.test(feats)) return "image_generation";
68
+ return null;
69
+ }
70
+
71
+ // Fail-fast host check (mirrors illo): don't burn a minutes-long exec when Codex
72
+ // isn't usable. Returns { flag } when ready, else { reason }. The flag ships
73
+ // default-disabled, so we check the ROW is present (the capability signal) — the
74
+ // exec enables it per-render with --enable.
75
+ function codexUnavailableReason() {
76
+ try {
77
+ const which = process.platform === "win32" ? "where" : "which";
78
+ execFileSync(which, ["codex"], { stdio: ["ignore", "ignore", "ignore"], timeout: 5000 });
79
+ } catch {
80
+ // A shell alias (e.g. `codex → /Applications/Codex.app/...`) is NOT enough:
81
+ // aliases live only in the interactive shell, so a spawned subprocess's PATH
82
+ // lookup can't see them. Symlink the real binary onto PATH.
83
+ return {
84
+ reason:
85
+ 'codex CLI not reachable on PATH (a shell alias won\'t work — spawned processes can\'t see aliases; symlink the real binary onto PATH, e.g. ln -s "$(readlink -f "$(command -v codex)")" ~/.local/bin/codex)',
86
+ };
87
+ }
88
+ // Auth marker: presence of the credentials file, NOT `codex login status`.
89
+ // That command prints "Logged in using ChatGPT" only to a human stream
90
+ // (stderr / TTY) and exits 0, so its piped stdout — how media-use spawns it —
91
+ // is empty, and the gate falsely reported "not logged in", blocking codex
92
+ // image gen in every headless / CI / agent run even when fully authed.
93
+ // auth.json is the durable, TTY-independent signal; token validity is proven
94
+ // by the exec itself, which fails cleanly if the login is stale.
95
+ const authPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "auth.json");
96
+ if (!existsSync(authPath)) return { reason: "codex not logged in (run: codex login)" };
97
+ const feats = codexRun(["features", "list"]);
98
+ if (feats == null) return { reason: "could not read `codex features list`" };
99
+ const flag = imageGenFlag(feats);
100
+ if (!flag) return { reason: "codex image generation feature unavailable (upgrade Codex CLI)" };
101
+ return { flag };
102
+ }
103
+
104
+ export async function codexImageGenerate(intent) {
105
+ const { reason, flag } = codexUnavailableReason();
106
+ if (reason) {
107
+ console.error(`media-use: codex image upsell unavailable: ${reason}`);
108
+ return null;
109
+ }
110
+ const outPath = join(tmpdir(), `media-use-codex-${process.pid}-${Date.now()}.png`);
111
+ const prompt =
112
+ `${intent}\n\n` +
113
+ `Use your built-in image generation tool to render this, then save the image ` +
114
+ `to ${outPath} (overwrite if it exists). Do not ask for confirmation. ` +
115
+ `If you have no built-in image tool, do nothing (no PIL/matplotlib/SVG substitute).`;
116
+ try {
117
+ unlinkSync(outPath); // clear any prior file so verify-first can't accept a stale render
118
+ } catch {
119
+ /* no prior file */
120
+ }
121
+ const started = Date.now();
122
+ try {
123
+ execFileSync(
124
+ "codex",
125
+ [
126
+ "exec",
127
+ "--cd",
128
+ tmpdir(),
129
+ "-s",
130
+ "workspace-write",
131
+ "--skip-git-repo-check",
132
+ "--enable",
133
+ flag,
134
+ "-",
135
+ ],
136
+ { input: prompt, encoding: "utf8", timeout: TIMEOUT_MS, stdio: ["pipe", "pipe", "pipe"] },
137
+ );
138
+ } catch (err) {
139
+ console.error(
140
+ `media-use: \`codex exec\` image generation failed: ${err.stderr?.toString().trim().slice(-200) || err.message}`,
141
+ );
142
+ return null;
143
+ }
144
+ // Verify-first (save-to-path may have worked), else fetch the image-tool artifact.
145
+ const produced =
146
+ existsSync(outPath) && statSync(outPath).size > 0 ? outPath : freshestGeneratedImage(started);
147
+ if (!produced) return null;
148
+ if (produced !== outPath) {
149
+ try {
150
+ copyFileSync(produced, outPath);
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+ return {
156
+ localPath: outPath,
157
+ ext: ".png",
158
+ source: "generated",
159
+ metadata: { description: intent, provider: "codex.image_gen", provenance: { prompt: intent } },
160
+ };
161
+ }
@@ -0,0 +1,34 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert";
3
+ import { imageGenFlag } from "./codex-provider.mjs";
4
+
5
+ // Real `codex features list` output is three space-padded columns:
6
+ // <name> <stage> <enabled>
7
+ const OLD_CLI = [
8
+ "image_detail_original removed false",
9
+ "image_generation stable true",
10
+ "imagegenext under development false",
11
+ ].join("\n");
12
+
13
+ const NEW_CLI = [
14
+ "image_detail_original removed false",
15
+ "image_generation stable true",
16
+ ].join("\n");
17
+
18
+ test("older CLIs keep the old flag — they reject --enable image_generation", () => {
19
+ assert.equal(imageGenFlag(OLD_CLI), "imagegenext");
20
+ });
21
+
22
+ test("Codex CLI >=0.145 dropped the imagegenext row; fall back to its new name", () => {
23
+ // The regression this guards: gating on `imagegenext` alone made the provider
24
+ // report "unavailable" on every up-to-date CLI, without attempting a render.
25
+ assert.equal(imageGenFlag(NEW_CLI), "image_generation");
26
+ });
27
+
28
+ test("no image feature at all is unavailable, not a silent default", () => {
29
+ assert.equal(imageGenFlag("apps stable true\nhooks stable true"), null);
30
+ });
31
+
32
+ test("substring lookalikes do not count as the feature row", () => {
33
+ assert.equal(imageGenFlag("image_generation_v2 stable true"), null);
34
+ });
@@ -0,0 +1,3 @@
1
+ import type * as fs from "node:fs";
2
+
3
+ export function withFileLock<T>(lockPath: string, fsModule: typeof fs, task: () => T): T;
@@ -0,0 +1,79 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { spawn } from "node:child_process";
3
+ import { test } from "node:test";
4
+ import * as fs from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { withFileLock } from "./config-lock.mjs";
8
+
9
+ function lockIn() {
10
+ const dir = fs.mkdtempSync(join(tmpdir(), "hf-config-lock-"));
11
+ return {
12
+ lock: join(dir, "config.json.lock"),
13
+ cleanup: () => fs.rmSync(dir, { recursive: true }),
14
+ };
15
+ }
16
+
17
+ test("returns the task's result even when the lock cannot be released", () => {
18
+ const { lock, cleanup } = lockIn();
19
+ try {
20
+ const refusing = {
21
+ ...fs,
22
+ rmSync(path, options) {
23
+ if (path === lock) throw Object.assign(new Error("EPERM"), { code: "EPERM" });
24
+ return fs.rmSync(path, options);
25
+ },
26
+ };
27
+ assert.equal(
28
+ withFileLock(lock, refusing, () => "saved"),
29
+ "saved",
30
+ );
31
+ } finally {
32
+ cleanup();
33
+ }
34
+ });
35
+
36
+ test("refuses a lock left by a process that stopped, names it, and never removes it", () => {
37
+ const { lock, cleanup } = lockIn();
38
+ try {
39
+ fs.writeFileSync(lock, "dead");
40
+ const past = new Date(Date.now() - 60_000);
41
+ fs.utimesSync(lock, past, past);
42
+ const started = Date.now();
43
+ let ran = false;
44
+
45
+ assert.throws(
46
+ () => withFileLock(lock, fs, () => (ran = true)),
47
+ (error) => error.code === "HF_SETTINGS_LOCKED" && error.message.includes(lock),
48
+ );
49
+ assert.ok(Date.now() - started < 1000);
50
+ assert.equal(ran, false);
51
+ assert.equal(fs.readFileSync(lock, "utf8"), "dead");
52
+ } finally {
53
+ cleanup();
54
+ }
55
+ });
56
+
57
+ test("waits for a process that holds the lock, then takes it", async () => {
58
+ const { lock, cleanup } = lockIn();
59
+ try {
60
+ const script = `
61
+ const fs = require("fs");
62
+ const lock = process.argv[1];
63
+ fs.writeFileSync(lock, "other", { flag: "wx" });
64
+ process.stdout.write("locked\\n");
65
+ setTimeout(() => {
66
+ fs.writeFileSync(lock + ".released", String(Date.now()));
67
+ fs.rmSync(lock);
68
+ }, 300);`;
69
+ const other = spawn(process.execPath, ["-e", script, lock]);
70
+ await new Promise((ready) => other.stdout.once("data", ready));
71
+
72
+ const ranAt = withFileLock(lock, fs, () => Date.now());
73
+
74
+ assert.ok(ranAt >= Number(fs.readFileSync(`${lock}.released`, "utf8")));
75
+ await new Promise((exited) => other.once("exit", exited));
76
+ } finally {
77
+ cleanup();
78
+ }
79
+ });