overlay-factory-worker 0.2.0 → 0.2.2
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.
- package/README.md +7 -2
- package/package.json +5 -3
- package/scripts/agent.ts +66 -0
- package/scripts/overlay-worker.ts +17 -2
- package/scripts/series.ts +119 -16
- package/scripts/stock.ts +2 -0
- package/skills/reference-analysis.md +149 -0
- package/worker/agent-cli.mjs +277 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# overlay-factory-worker
|
|
2
2
|
|
|
3
3
|
The Overlay Factory's worker: goosetools.com queues overlay jobs, this machine
|
|
4
|
-
writes the overlays with Claude, renders them with Remotion, and burns them onto
|
|
4
|
+
writes the overlays with the agent the user picked in Setup (Claude by default; episode research always uses Claude, for web search), renders them with Remotion, and burns them onto
|
|
5
5
|
the uploaded clip with ffmpeg. Published to npm on its own; lives in the
|
|
6
6
|
goosetools repo at `workers/overlay` (it was the standalone `reel-factory` repo
|
|
7
7
|
until 2026-09-26 — the What Prints series that lived there is at the
|
|
@@ -33,7 +33,12 @@ is: **add references, it tries, you adjust in words, it learns.**
|
|
|
33
33
|
the rewrite against the note and retries once with whatever it missed; then
|
|
34
34
|
writes the result as the series. The note is appended to `notes.md`, so it
|
|
35
35
|
keeps applying — to later revisions and to a full rebuild.
|
|
36
|
-
4.
|
|
36
|
+
4. **It checks its own render.** After a build or a revision, the series is
|
|
37
|
+
rendered and put next to the references with the shared method's compare
|
|
38
|
+
phase (`skills/reference-analysis.md`, the same method the Carousel Maker
|
|
39
|
+
uses); anything that doesn't match is fixed before you see it. Field keys
|
|
40
|
+
can't change in that step.
|
|
41
|
+
5. The previous 5 versions of each series are kept in `series/.history/<slug>/`,
|
|
37
42
|
so a bad revision can be put back by hand.
|
|
38
43
|
|
|
39
44
|
`series/` is gitignored and per-machine (in an npm install it lives in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "overlay-factory-worker",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "The Goose Tools Overlay Factory worker — your computer renders reel overlays for goosetools.com with Remotion and ffmpeg.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"overlay:install": "node worker/cli.mjs install",
|
|
41
41
|
"overlay:uninstall": "node worker/cli.mjs uninstall",
|
|
42
42
|
"overlay:status": "node worker/cli.mjs status",
|
|
43
|
-
"overlay:doctor": "node worker/cli.mjs doctor"
|
|
43
|
+
"overlay:doctor": "node worker/cli.mjs doctor",
|
|
44
|
+
"prepublishOnly": "node ../sync-shared.mjs --check"
|
|
44
45
|
},
|
|
45
46
|
"sideEffects": [
|
|
46
47
|
"*.css"
|
|
@@ -58,6 +59,7 @@
|
|
|
58
59
|
"src",
|
|
59
60
|
"remotion.config.ts",
|
|
60
61
|
"tsconfig.json",
|
|
61
|
-
"README.md"
|
|
62
|
+
"README.md",
|
|
63
|
+
"skills"
|
|
62
64
|
]
|
|
63
65
|
}
|
package/scripts/agent.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overlay worker's two shared dependencies with the other workers:
|
|
3
|
+
*
|
|
4
|
+
* - Which coding-agent CLI to run. The user picks one in Setup on
|
|
5
|
+
* goosetools.com and every claim carries it (`agentCli`); prompts go
|
|
6
|
+
* through worker/agent-cli.mjs, the same adapter the other workers use,
|
|
7
|
+
* synced from workers/shared/agent-cli.js.
|
|
8
|
+
* - How to read references. skills/reference-analysis.md is the Carousel
|
|
9
|
+
* Maker's method, shared so both tools study references, and check their
|
|
10
|
+
* own renders against them, the same way.
|
|
11
|
+
*
|
|
12
|
+
* The worker runs one job at a time, so the agent is set per job with
|
|
13
|
+
* useAgent() rather than threaded through every generation helper.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
const ROOT = join(__dirname, "..");
|
|
20
|
+
|
|
21
|
+
type RunAgent = (opts: {
|
|
22
|
+
agent?: string;
|
|
23
|
+
prompt: string;
|
|
24
|
+
cwd?: string;
|
|
25
|
+
mode?: "text" | "oneshot" | "read" | "session";
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}) => Promise<{ text: string }>;
|
|
28
|
+
|
|
29
|
+
let current = "claude-code";
|
|
30
|
+
|
|
31
|
+
/** Called at the start of each job with the agent that job's owner picked. */
|
|
32
|
+
export const useAgent = (agentCli?: string | null) => {
|
|
33
|
+
current = agentCli || "claude-code";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let adapter: Promise<{ runAgent: RunAgent }> | undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run one prompt through the current agent and return its text.
|
|
40
|
+
*
|
|
41
|
+
* "read" lets it open the reference and render images named in the prompt;
|
|
42
|
+
* "oneshot" is for judgements where everything it needs is inlined.
|
|
43
|
+
*/
|
|
44
|
+
export const askAgent = async (
|
|
45
|
+
prompt: string,
|
|
46
|
+
{ mode = "read", timeoutMs = 15 * 60 * 1000 }: { mode?: "read" | "oneshot"; timeoutMs?: number } = {},
|
|
47
|
+
): Promise<string> => {
|
|
48
|
+
// A file URL rather than a bare specifier: the adapter is an ES module in a
|
|
49
|
+
// CommonJS package, and this keeps it a real dynamic import.
|
|
50
|
+
adapter ??= import(pathToFileURL(join(ROOT, "worker", "agent-cli.mjs")).href) as Promise<{
|
|
51
|
+
runAgent: RunAgent;
|
|
52
|
+
}>;
|
|
53
|
+
const { runAgent } = await adapter;
|
|
54
|
+
const { text } = await runAgent({ agent: current, prompt, mode, cwd: ROOT, timeoutMs });
|
|
55
|
+
return text;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let method: string | undefined;
|
|
59
|
+
|
|
60
|
+
/** One section of the shared reference method, by its SECTION marker. */
|
|
61
|
+
export const skillSection = (name: string): string => {
|
|
62
|
+
method ??= readFileSync(join(ROOT, "skills", "reference-analysis.md"), "utf8");
|
|
63
|
+
const m = method.match(new RegExp(`<!-- SECTION: ${name} -->\\n([\\s\\S]*?)(?=<!-- SECTION: |$)`));
|
|
64
|
+
if (!m) throw new Error(`reference method section "${name}" not found`);
|
|
65
|
+
return m[1].trim();
|
|
66
|
+
};
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { resolveCustomFonts } from "./custom-fonts";
|
|
47
47
|
import { resolveFieldImages } from "./field-images";
|
|
48
48
|
import { ensureStateLinks } from "./state-dir";
|
|
49
|
+
import { useAgent } from "./agent";
|
|
49
50
|
import { acquireWorkerLock } from "./worker-lock";
|
|
50
51
|
|
|
51
52
|
// Worker-side upload, same signature as the old `upload` from
|
|
@@ -149,6 +150,8 @@ type NewSeries = {
|
|
|
149
150
|
type Job = {
|
|
150
151
|
id: string;
|
|
151
152
|
kind: "overlay" | "series" | "restock" | "preview" | "single";
|
|
153
|
+
/** The coding-agent CLI this job's owner picked in Setup. */
|
|
154
|
+
agentCli?: string | null;
|
|
152
155
|
/** kind='single' and one-off previews: what it should say, and what to copy. */
|
|
153
156
|
single?: { say: string; refUrls: string[] } | null;
|
|
154
157
|
/** The preview this job confirms, so it reuses that overlay rather than writing a new one. */
|
|
@@ -177,8 +180,17 @@ type Job = {
|
|
|
177
180
|
const ROOT = join(__dirname, "..");
|
|
178
181
|
const PUBLIC = join(ROOT, "public");
|
|
179
182
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
183
|
+
/**
|
|
184
|
+
* A one-off's directory name — the same for its preview and its burn.
|
|
185
|
+
*
|
|
186
|
+
* The id comes from the server, and the result is a folder the worker later
|
|
187
|
+
* deletes, so anything that isn't a plain job id is refused rather than
|
|
188
|
+
* trusted to stay inside series/.
|
|
189
|
+
*/
|
|
190
|
+
const singleSlug = (id: string) => {
|
|
191
|
+
if (!/^[0-9a-f]{8}-[0-9a-f-]{4,}$/i.test(id)) throw new Error("Bad one-off id");
|
|
192
|
+
return `single-${id.slice(0, 8).toLowerCase()}`;
|
|
193
|
+
};
|
|
182
194
|
|
|
183
195
|
/**
|
|
184
196
|
* Run a script, and on failure raise the part a human wrote.
|
|
@@ -678,6 +690,7 @@ const watchOwnSource = () => {
|
|
|
678
690
|
const files = [
|
|
679
691
|
"scripts/overlay-worker.ts",
|
|
680
692
|
"scripts/series.ts",
|
|
693
|
+
"scripts/agent.ts",
|
|
681
694
|
"scripts/stock.ts",
|
|
682
695
|
"scripts/render-overlay.ts",
|
|
683
696
|
"scripts/custom-fonts.ts",
|
|
@@ -708,6 +721,8 @@ const main = async () => {
|
|
|
708
721
|
try {
|
|
709
722
|
const job = (await api("claim")) as Job | null;
|
|
710
723
|
if (job) {
|
|
724
|
+
// One job at a time, so the owner's agent choice is set per job.
|
|
725
|
+
useAgent(job.agentCli);
|
|
711
726
|
if (job.kind === "series") await runSeriesJob(job);
|
|
712
727
|
else if (job.kind === "restock") await runRestockJob(job);
|
|
713
728
|
else if (job.kind === "preview") await runPreviewJob(job);
|
package/scripts/series.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* freeze on whatever the first frame painted. Each layer instead declares an
|
|
21
21
|
* `enter`, which the composition drives with Remotion's own spring.
|
|
22
22
|
*/
|
|
23
|
-
import {
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
24
|
import {
|
|
25
25
|
cpSync,
|
|
26
26
|
existsSync,
|
|
@@ -34,9 +34,8 @@ import {
|
|
|
34
34
|
} from "node:fs";
|
|
35
35
|
import { tmpdir } from "node:os";
|
|
36
36
|
import { join } from "node:path";
|
|
37
|
-
import {
|
|
37
|
+
import { askAgent, skillSection } from "./agent";
|
|
38
38
|
|
|
39
|
-
const execFileAsync = promisify(execFile);
|
|
40
39
|
const ROOT = join(__dirname, "..");
|
|
41
40
|
const SERIES_DIR = join(ROOT, "series");
|
|
42
41
|
|
|
@@ -216,6 +215,10 @@ ${
|
|
|
216
215
|
Read each of these image files closely (in order — the first matters most):
|
|
217
216
|
${opts.refPaths.map((p) => `- ${p}`).join("\n")}
|
|
218
217
|
|
|
218
|
+
${skillSection("principles")}
|
|
219
|
+
|
|
220
|
+
${skillSection("overlay-analyze")}
|
|
221
|
+
|
|
219
222
|
Sample the ACTUAL colors from them. Match their typography treatment (weight,
|
|
220
223
|
case, letter-spacing, serif vs sans), their shapes (radius, borders, bars),
|
|
221
224
|
their spacing and their composition. A finished frame should look like it
|
|
@@ -432,11 +435,7 @@ const draftFiles = async (
|
|
|
432
435
|
let p = initialPrompt;
|
|
433
436
|
// Two attempts: the second is told exactly what was wrong with the first.
|
|
434
437
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
435
|
-
const
|
|
436
|
-
"claude",
|
|
437
|
-
["-p", p, "--output-format", "text", "--allowedTools", "Read"],
|
|
438
|
-
{ maxBuffer: 20 * 1024 * 1024, timeout: 15 * 60 * 1000 },
|
|
439
|
-
);
|
|
438
|
+
const stdout = await askAgent(p, { mode: "read" });
|
|
440
439
|
try {
|
|
441
440
|
const files = extractFiles(stdout);
|
|
442
441
|
return { files, spec: validate(files) };
|
|
@@ -451,6 +450,8 @@ const draftFiles = async (
|
|
|
451
450
|
};
|
|
452
451
|
|
|
453
452
|
const NOTES_FILE = "notes.md";
|
|
453
|
+
/** Marks a one-off's folder, so the sweep never touches a real series. */
|
|
454
|
+
const ONE_OFF_MARKER = ".one-off";
|
|
454
455
|
const HISTORY_DIR = join(SERIES_DIR, ".history");
|
|
455
456
|
const KEEP_VERSIONS = 5;
|
|
456
457
|
|
|
@@ -475,7 +476,7 @@ const writeSeries = (
|
|
|
475
476
|
const out = join(SERIES_DIR, slug);
|
|
476
477
|
const notes = readNotes(slug);
|
|
477
478
|
|
|
478
|
-
if (existsSync(join(out, "series.json")) && !
|
|
479
|
+
if (existsSync(join(out, "series.json")) && !existsSync(join(out, ONE_OFF_MARKER))) {
|
|
479
480
|
const hist = join(HISTORY_DIR, slug);
|
|
480
481
|
mkdirSync(hist, { recursive: true });
|
|
481
482
|
cpSync(out, join(hist, new Date().toISOString().replace(/[:.]/g, "-")), {
|
|
@@ -544,12 +545,103 @@ export const generateSeries = async (input: {
|
|
|
544
545
|
}),
|
|
545
546
|
"Series generation",
|
|
546
547
|
);
|
|
547
|
-
|
|
548
|
+
await selfCheck({
|
|
549
|
+
slug: input.slug,
|
|
550
|
+
refPaths,
|
|
551
|
+
brand: input.brand ?? null,
|
|
552
|
+
workDir: dir,
|
|
553
|
+
});
|
|
554
|
+
return readSeriesSpec(input.slug)?.fields ?? spec.fields;
|
|
548
555
|
} finally {
|
|
549
556
|
rmSync(dir, { recursive: true, force: true });
|
|
550
557
|
}
|
|
551
558
|
};
|
|
552
559
|
|
|
560
|
+
// ── Self-check ────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Look at the rendered series next to the references and fix what's off.
|
|
564
|
+
*
|
|
565
|
+
* The Carousel Maker's self-check, for overlays: judging a design by
|
|
566
|
+
* re-reading its markup misses exactly the things that make it look wrong —
|
|
567
|
+
* a label twice the size the references use, a box that reads as a slab. So
|
|
568
|
+
* this renders a frame and puts the picture beside the references, with the
|
|
569
|
+
* shared method's compare phase. One round, and it never fails the job: a
|
|
570
|
+
* check that can't run leaves the series as it was.
|
|
571
|
+
*
|
|
572
|
+
* Returns a sentence when it changed something, or null.
|
|
573
|
+
*/
|
|
574
|
+
const selfCheck = async (input: {
|
|
575
|
+
slug: string;
|
|
576
|
+
refPaths: string[];
|
|
577
|
+
brand: Brand | null;
|
|
578
|
+
/** When the check follows a revision, the rewrite must keep doing this. */
|
|
579
|
+
note?: string;
|
|
580
|
+
workDir: string;
|
|
581
|
+
}): Promise<string | null> => {
|
|
582
|
+
if (input.refPaths.length === 0) return null;
|
|
583
|
+
const spec = readSeriesSpec(input.slug);
|
|
584
|
+
if (!spec) return null;
|
|
585
|
+
try {
|
|
586
|
+
const frame = join(input.workDir, "self-check.png");
|
|
587
|
+
renderSeriesPreview(input.slug, spec.fields, input.brand, frame);
|
|
588
|
+
const files = currentFiles(input.slug);
|
|
589
|
+
|
|
590
|
+
const out = await askAgent(
|
|
591
|
+
`You designed this overlay series for vertical video; now inspect your own output.
|
|
592
|
+
|
|
593
|
+
Read the RENDERED frame (sample text in every field, over neutral grey — the real
|
|
594
|
+
thing sits over footage):
|
|
595
|
+
- ${frame}
|
|
596
|
+
|
|
597
|
+
Then read the REFERENCES this series must look like it belongs with (in order;
|
|
598
|
+
the first matters most):
|
|
599
|
+
${input.refPaths.map((p) => `- ${p}`).join("\n")}
|
|
600
|
+
|
|
601
|
+
${skillSection("principles")}
|
|
602
|
+
|
|
603
|
+
${skillSection("overlay-compare")}
|
|
604
|
+
${
|
|
605
|
+
input.note
|
|
606
|
+
? `
|
|
607
|
+
The user's latest note on this series must still be true after any fix:
|
|
608
|
+
"${input.note}"
|
|
609
|
+
`
|
|
610
|
+
: ""
|
|
611
|
+
}
|
|
612
|
+
The series files as they are now:
|
|
613
|
+
${asFileBlocks(files)}
|
|
614
|
+
|
|
615
|
+
If it passes every test, reply with exactly: PASS
|
|
616
|
+
Otherwise reply with the corrected files — EVERY file, in full — in exactly
|
|
617
|
+
this format, changing only what fails, and nothing before or after:
|
|
618
|
+
=== FILE: series.json ===
|
|
619
|
+
...
|
|
620
|
+
=== FILE: <template>.html ===
|
|
621
|
+
...`,
|
|
622
|
+
{ mode: "read" },
|
|
623
|
+
);
|
|
624
|
+
if (/^\s*PASS\b/.test(out) && !out.includes("=== FILE:")) {
|
|
625
|
+
console.log(" self-check: matches the references");
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const fixed = extractFiles(out);
|
|
629
|
+
const fixedSpec = validate(fixed);
|
|
630
|
+
// A fix must not change what the form asks for — episodes use those keys.
|
|
631
|
+
const keys = (f: SeriesField[]) => f.map((x) => x.key).sort().join(",");
|
|
632
|
+
if (keys(fixedSpec.fields) !== keys(spec.fields)) {
|
|
633
|
+
console.warn(" self-check: its fix changed the fields, so it was not applied");
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
writeSeries(input.slug, fixed, { keepEpisodes: true });
|
|
637
|
+
console.log(" self-check: adjusted to match the references");
|
|
638
|
+
return "Checked against your references and adjusted to match.";
|
|
639
|
+
} catch (e) {
|
|
640
|
+
console.error(` self-check skipped (${(e as Error).message.split("\n")[0]})`);
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
553
645
|
// ── Revisions ─────────────────────────────────────────────────────────────
|
|
554
646
|
|
|
555
647
|
/** The files that make up a series' look, as they are now. */
|
|
@@ -593,11 +685,7 @@ the AFTER files actually do it (compare against BEFORE — an unchanged value
|
|
|
593
685
|
means it was not done). Reply with ONLY this JSON, nothing else:
|
|
594
686
|
{"applied": "<one short sentence, in plain words, saying what changed>",
|
|
595
687
|
"missed": ["<each request that was NOT done, in the user's words>"]}`;
|
|
596
|
-
const
|
|
597
|
-
"claude",
|
|
598
|
-
["-p", p, "--output-format", "text"],
|
|
599
|
-
{ maxBuffer: 5 * 1024 * 1024, timeout: 5 * 60 * 1000 },
|
|
600
|
-
);
|
|
688
|
+
const stdout = await askAgent(p, { mode: "oneshot", timeoutMs: 5 * 60 * 1000 });
|
|
601
689
|
const json = stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1);
|
|
602
690
|
const parsed = JSON.parse(json) as { applied?: unknown; missed?: unknown };
|
|
603
691
|
return {
|
|
@@ -697,7 +785,17 @@ ${missed.map((m) => `- ${m}`).join("\n")}
|
|
|
697
785
|
join(SERIES_DIR, input.slug, NOTES_FILE),
|
|
698
786
|
`${readNotes(input.slug)}- ${date} · ${input.feedback.replace(/\s+/g, " ").trim()}\n`,
|
|
699
787
|
);
|
|
700
|
-
|
|
788
|
+
const checked = await selfCheck({
|
|
789
|
+
slug: input.slug,
|
|
790
|
+
refPaths,
|
|
791
|
+
brand,
|
|
792
|
+
note: input.feedback,
|
|
793
|
+
workDir: dir,
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
fields: readSeriesSpec(input.slug)?.fields ?? draft.spec.fields,
|
|
797
|
+
note: checked ? `${summary} ${checked}` : summary,
|
|
798
|
+
};
|
|
701
799
|
} finally {
|
|
702
800
|
rmSync(dir, { recursive: true, force: true });
|
|
703
801
|
}
|
|
@@ -829,6 +927,10 @@ export const sweepSingles = (maxAgeMs = 24 * 60 * 60 * 1000) => {
|
|
|
829
927
|
for (const d of readdirSync(SERIES_DIR, { withFileTypes: true })) {
|
|
830
928
|
if (!d.isDirectory() || !d.name.startsWith("single-")) continue;
|
|
831
929
|
const p = join(SERIES_DIR, d.name);
|
|
930
|
+
// Only folders generateSingle marked. A real series can have a single-
|
|
931
|
+
// slug (the site now refuses new ones, but older ones may exist), and
|
|
932
|
+
// this deletes without a backup.
|
|
933
|
+
if (!existsSync(join(p, ONE_OFF_MARKER))) continue;
|
|
832
934
|
try {
|
|
833
935
|
if (Date.now() - statSync(p).mtimeMs > maxAgeMs) {
|
|
834
936
|
rmSync(p, { recursive: true, force: true });
|
|
@@ -878,6 +980,7 @@ export const generateSingle = async (input: {
|
|
|
878
980
|
}),
|
|
879
981
|
"Overlay generation",
|
|
880
982
|
);
|
|
983
|
+
writeFileSync(join(SERIES_DIR, input.slug, ONE_OFF_MARKER), "");
|
|
881
984
|
return {
|
|
882
985
|
layers: seriesLayers(input.slug, {}, input.durationSec, input.brand ?? null),
|
|
883
986
|
cleanup,
|
package/scripts/stock.ts
CHANGED
|
@@ -169,6 +169,8 @@ and stops being readable. No markdown, no quotes around the whole value.
|
|
|
169
169
|
|
|
170
170
|
Return at most ${opts.count}.`;
|
|
171
171
|
|
|
172
|
+
// Always Claude, whatever agent the user picked: research needs WebSearch
|
|
173
|
+
// and WebFetch, which the shared agent adapter doesn't offer for the others.
|
|
172
174
|
const { stdout } = await execFileAsync(
|
|
173
175
|
"claude",
|
|
174
176
|
[
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# Reference Analysis Methodology
|
|
2
|
+
|
|
3
|
+
How to study a set of reference images, map them onto something new, and check
|
|
4
|
+
the finished render against them. Shared by the Carousel Maker (slides) and the
|
|
5
|
+
Overlay Factory (overlays on reels) — the reference images are the source of
|
|
6
|
+
truth for EVERY behavior described here; there are no hardcoded rules. Worker
|
|
7
|
+
prompts splice individual sections by the SECTION markers below.
|
|
8
|
+
|
|
9
|
+
SHARED FILE: the source of truth is workers/shared/reference-analysis.md in the
|
|
10
|
+
goosetools repo, copied into each worker's skills/ by `npm run workers:sync`.
|
|
11
|
+
|
|
12
|
+
<!-- SECTION: principles -->
|
|
13
|
+
## Principles — both tools
|
|
14
|
+
|
|
15
|
+
- **Purpose first, paint second.** Work out what each reference is FOR — the
|
|
16
|
+
recurring format a follower would recognize — before its colours and fonts.
|
|
17
|
+
A copy that nails the palette but misses the format is wrong.
|
|
18
|
+
- **Variance vs constants.** Across the set, note what varies and what never
|
|
19
|
+
changes. Freedom lives exactly where the references vary; fidelity is owed
|
|
20
|
+
everywhere they don't.
|
|
21
|
+
- **Same-account test.** The finished thing should read as the same account's
|
|
22
|
+
work if posted next to the references — same format, same typographic
|
|
23
|
+
voice, same palette logic. Pixel-matching any one reference is NOT the goal.
|
|
24
|
+
- **Look, don't assume.** Read every image before deciding anything, and judge
|
|
25
|
+
your own output by looking at the render, not by re-reading your markup.
|
|
26
|
+
|
|
27
|
+
<!-- SECTION: analyze -->
|
|
28
|
+
## Phase A — understand what each reference is DOING
|
|
29
|
+
|
|
30
|
+
Read every reference image first. For each one, work out:
|
|
31
|
+
|
|
32
|
+
- **Role**: is this the cover (the hook slide), a middle content slide, or
|
|
33
|
+
the closer/CTA? The first reference is usually the cover; use layout and
|
|
34
|
+
copy cues (page counters, "swipe", "follow") to confirm.
|
|
35
|
+
- **Content job**: what is the slide FOR? Presenting a color palette with
|
|
36
|
+
labeled swatches? Comparing a before vs an after? Listing tips? Showcasing
|
|
37
|
+
a product photo? The recurring format a follower would recognize is the
|
|
38
|
+
thing to reproduce — purpose first, paint second.
|
|
39
|
+
- **Photo and subject**: where does the photo's main subject sit in the
|
|
40
|
+
frame? How tight is the crop?
|
|
41
|
+
- **Overlays**: where do overlays (swatch stacks, badges, labels, text
|
|
42
|
+
blocks) sit relative to the subject? Record which behavior this set uses:
|
|
43
|
+
some styles keep overlays OFF the subject (overlays live in the empty
|
|
44
|
+
side of the frame); others deliberately lay elements OVER the photo as
|
|
45
|
+
part of the look. Both are legitimate — copy whichever these references do.
|
|
46
|
+
- **Variance vs constants**: across the whole set, what varies (number of
|
|
47
|
+
swatches, which side the overlay sits on, crop tightness) and what never
|
|
48
|
+
changes (the format, the typography, the palette treatment, the border
|
|
49
|
+
style)? Freedom lives exactly where the references vary; fidelity is owed
|
|
50
|
+
everywhere they don't.
|
|
51
|
+
|
|
52
|
+
<!-- SECTION: map -->
|
|
53
|
+
## Phase B — map references onto the new carousel, role to role
|
|
54
|
+
|
|
55
|
+
Match by ROLE and FORMAT, never image-to-image: your cover should do what
|
|
56
|
+
the reference cover does, your closer what their closer does, and every
|
|
57
|
+
middle slide should follow the references' middle-slide format. Your slide 3
|
|
58
|
+
does not need to mirror reference slide 3.
|
|
59
|
+
|
|
60
|
+
- **Colors**: if the user's photos share a coherent palette (e.g. they are
|
|
61
|
+
all sunsets, all one product line), sample the carousel's colors from THE
|
|
62
|
+
USER'S PHOTOS so the deck feels made from their images. If the photos are
|
|
63
|
+
visually unrelated, keep the references' original colors. Never mix in
|
|
64
|
+
colors from anywhere else.
|
|
65
|
+
- **Variance**: whatever varied across the references may vary across your
|
|
66
|
+
slides the same way — swatch counts, overlay side, crop tightness. Do not
|
|
67
|
+
make every slide identical when the references weren't.
|
|
68
|
+
- **Overlay placement**: follow the coverage behavior recorded in Phase A.
|
|
69
|
+
If the references keep overlays clear of the subject, look at each of the
|
|
70
|
+
user's photos (and what the user's prompt says the photo is about) and
|
|
71
|
+
place overlays on the empty side — use `variant: flip` on a slide to
|
|
72
|
+
mirror a template's overlay to the other side. If the references overlay
|
|
73
|
+
the photo deliberately, do the same.
|
|
74
|
+
- **Photo count adaptation** — the references decide the slide mix:
|
|
75
|
+
- More photos than slides: choose the photos that best serve each slide's
|
|
76
|
+
message, but if the user's prompt asks to show something specific, the
|
|
77
|
+
photo showing it MUST be used.
|
|
78
|
+
- Fewer photos than the format wants: if the references put a photo on
|
|
79
|
+
every slide, reuse a photo with a DIFFERENT focus/zoom crop so it reads
|
|
80
|
+
as a new frame; if the references include photo-free formats (statement
|
|
81
|
+
slides, text slides), use those instead. Never pad with invented filler
|
|
82
|
+
content.
|
|
83
|
+
|
|
84
|
+
<!-- SECTION: compare -->
|
|
85
|
+
## Phase C — compare each rendered slide against the references
|
|
86
|
+
|
|
87
|
+
For each rendered slide, find the reference(s) with the same ROLE (slide 1 =
|
|
88
|
+
cover, last slide = CTA/closer, everything else = middle) and ask:
|
|
89
|
+
|
|
90
|
+
- **Same-account test**: if this slide were posted next to the references,
|
|
91
|
+
would it read as the same account's work — same format, same typographic
|
|
92
|
+
voice, same palette logic? Exact alignment with any single reference is
|
|
93
|
+
NOT required; matching the format is.
|
|
94
|
+
- **Overlay-behavior test**: does the slide follow the references' coverage
|
|
95
|
+
behavior? If they keep overlays off the subject, is this slide's subject
|
|
96
|
+
(or a face) hidden under a swatch stack, badge, or caption? If so it
|
|
97
|
+
fails — fix with `variant: flip` (mirror the overlay side) or a focus/zoom
|
|
98
|
+
change that moves the subject clear.
|
|
99
|
+
- **Framing test**: is the thing the slide is about actually prominent and
|
|
100
|
+
centered by the crop, exactly as the reference format frames its subjects?
|
|
101
|
+
|
|
102
|
+
Fix vocabulary (per slide): `focus` ("X% Y%" on the original photo),
|
|
103
|
+
`zoom` (1–3), `variant: flip` (mirror the overlay side), `swatches`
|
|
104
|
+
(recount/recolor a swatch list — only on slides that already have one).
|
|
105
|
+
Never rewrite a slide's text or switch its template during comparison.
|
|
106
|
+
|
|
107
|
+
<!-- SECTION: overlay-analyze -->
|
|
108
|
+
## Overlay Phase A — understand what the references are DOING
|
|
109
|
+
|
|
110
|
+
These are frames from reels (or screenshots of them) with text or graphics laid
|
|
111
|
+
over footage. For the set, work out:
|
|
112
|
+
|
|
113
|
+
- **Format**: what one episode of this is — a question, a hot take, a stat, a
|
|
114
|
+
quote, a before/after label? That is what the fields should capture.
|
|
115
|
+
- **Hierarchy**: what the eye reads first, second, third, and how that is
|
|
116
|
+
achieved (size, weight, colour, a box, a bar, a label above the main line).
|
|
117
|
+
- **Type**: weight, case, letter-spacing, serif vs sans vs mono, line length,
|
|
118
|
+
how many lines the main text runs to.
|
|
119
|
+
- **Containers**: solid box, translucent scrim, outline, shadow only, or text
|
|
120
|
+
straight on footage — and corner radius, border, padding.
|
|
121
|
+
- **Placement**: where on the 9:16 frame each element sits, and how much of
|
|
122
|
+
the frame the overlay takes. Note anything placed where Instagram's UI would
|
|
123
|
+
cover it — keep the format but move it into the safe band.
|
|
124
|
+
- **Colour logic**: which colours are fixed accents and which come from the
|
|
125
|
+
footage; how contrast is kept over busy or bright video.
|
|
126
|
+
- **Variance vs constants** across the set (see Principles).
|
|
127
|
+
|
|
128
|
+
<!-- SECTION: overlay-compare -->
|
|
129
|
+
## Overlay Phase C — compare the rendered frame against the references
|
|
130
|
+
|
|
131
|
+
Look at the rendered frame (sample text in every field, over grey) next to the
|
|
132
|
+
references and ask:
|
|
133
|
+
|
|
134
|
+
- **Same-account test**: would this sit next to the references as the same
|
|
135
|
+
series? Same format, hierarchy, type treatment, container style, colour
|
|
136
|
+
logic.
|
|
137
|
+
- **Hierarchy test**: does the eye land where it lands in the references, in
|
|
138
|
+
the same order?
|
|
139
|
+
- **Proportion test**: is the overlay roughly the size the references use — not
|
|
140
|
+
a tiny label where they fill the band, not a slab where they are a caption?
|
|
141
|
+
- **Legibility test**: would it read over ANY footage, on a phone at arm's
|
|
142
|
+
length? Thin text straight on video fails.
|
|
143
|
+
- **Safe-area test**: is anything in Instagram's top bar, right action rail or
|
|
144
|
+
bottom caption zone?
|
|
145
|
+
|
|
146
|
+
Fix by editing the templates (sizes, weights, spacing, colours, containers,
|
|
147
|
+
widthPct, anchor). Never change field keys, the content brief, or wording that
|
|
148
|
+
the user typed or asked for.
|
|
149
|
+
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Agent CLI adapter — runs a job's prompt through whichever coding-agent CLI
|
|
2
|
+
// the user picked on goosetools.com (claim payloads carry `agentCli`).
|
|
3
|
+
//
|
|
4
|
+
// SHARED FILE: the source of truth is workers/shared/agent-cli.js in the
|
|
5
|
+
// goosetools repo. Each worker ships its own copy (worker/agent-cli.js) so the
|
|
6
|
+
// npm packages stay self-contained. Edit the shared one, then run
|
|
7
|
+
// `npm run workers:sync` from the repo root; `npm run workers:check` fails if
|
|
8
|
+
// a copy has drifted.
|
|
9
|
+
//
|
|
10
|
+
// Modes, chosen for speed-vs-capability (see brand-manager commit c0c1b33 —
|
|
11
|
+
// tool-looping turned 36-second drafts into 10-minute ones):
|
|
12
|
+
// "text" — plain prompt-in/text-out, no tool flags at all (captions).
|
|
13
|
+
// "oneshot" — tools explicitly OFF, all context inlined by the caller.
|
|
14
|
+
// "read" — file-reading allowed (carousel drafts look at photos).
|
|
15
|
+
// "session" — tools ON and resumable where the CLI supports it.
|
|
16
|
+
//
|
|
17
|
+
// Honest capability notes per CLI:
|
|
18
|
+
// claude — full support: fine tool control, JSON envelope, --resume.
|
|
19
|
+
// codex — sandbox-level tool control only; resumable threads; JSONL out.
|
|
20
|
+
// gemini — no headless resume: sessionId is always null, callers replay
|
|
21
|
+
// history (they already do this whenever sessionId is absent).
|
|
22
|
+
// opencode — plain text out; session continuation is version-dependent, so
|
|
23
|
+
// we don't claim it: sessionId is always null.
|
|
24
|
+
//
|
|
25
|
+
// Session ids are namespaced ("claude:<uuid>", "codex:<thread-id>") because
|
|
26
|
+
// the server stores them per conversation and the user can switch agents
|
|
27
|
+
// between turns — a Claude uuid means nothing to Codex. resolveSession()
|
|
28
|
+
// drops a mismatched id so the caller falls back to history replay. Bare
|
|
29
|
+
// un-prefixed ids predate namespacing and are treated as Claude's.
|
|
30
|
+
|
|
31
|
+
import { spawn } from "node:child_process";
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
|
|
34
|
+
export const AGENT_IDS = ["claude-code", "codex", "gemini", "opencode"];
|
|
35
|
+
|
|
36
|
+
const INSTALL_HINTS = {
|
|
37
|
+
"claude-code": "npm install -g @anthropic-ai/claude-code",
|
|
38
|
+
codex: "npm install -g @openai/codex, then run: codex login",
|
|
39
|
+
gemini: "npm install -g @google/gemini-cli, then run: gemini (to sign in)",
|
|
40
|
+
opencode: "npm install -g opencode-ai, then run: opencode auth login",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const LABELS = {
|
|
44
|
+
"claude-code": "Claude Code",
|
|
45
|
+
codex: "Codex CLI",
|
|
46
|
+
gemini: "Gemini CLI",
|
|
47
|
+
opencode: "OpenCode",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Session-id namespace per agent. claude-code writes "claude:" for continuity
|
|
51
|
+
// with the bare ids already stored server-side.
|
|
52
|
+
const SESSION_PREFIX = {
|
|
53
|
+
"claude-code": "claude",
|
|
54
|
+
codex: "codex",
|
|
55
|
+
gemini: "gemini",
|
|
56
|
+
opencode: "opencode",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function bin(agent) {
|
|
60
|
+
if (agent === "claude-code") return process.env.CLAUDE_BIN ?? "claude";
|
|
61
|
+
return agent; // codex / gemini / opencode binaries share their id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** "claude" (legacy claim payloads) → "claude-code"; unknown → null. */
|
|
65
|
+
export function normalizeAgent(v) {
|
|
66
|
+
if (v == null || v === "" ) return "claude-code";
|
|
67
|
+
if (v === "claude") return "claude-code";
|
|
68
|
+
return AGENT_IDS.includes(v) ? v : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn a stored (possibly namespaced, possibly another agent's) session id
|
|
73
|
+
* into one usable by `agent`, or null — null tells the caller to inline the
|
|
74
|
+
* conversation history instead of resuming.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveSession(agent, sessionId) {
|
|
77
|
+
if (!sessionId) return null;
|
|
78
|
+
const idx = sessionId.indexOf(":");
|
|
79
|
+
if (idx === -1) {
|
|
80
|
+
// Legacy bare id — those were always Claude Code session uuids.
|
|
81
|
+
return agent === "claude-code" ? sessionId : null;
|
|
82
|
+
}
|
|
83
|
+
const prefix = sessionId.slice(0, idx);
|
|
84
|
+
return prefix === SESSION_PREFIX[agent] ? sessionId.slice(idx + 1) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function namespaced(agent, rawId) {
|
|
88
|
+
return rawId ? `${SESSION_PREFIX[agent]}:${rawId}` : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Strip ANSI escapes and other terminal noise some CLIs print around answers.
|
|
92
|
+
export function cleanText(text) {
|
|
93
|
+
// eslint-disable-next-line no-control-regex
|
|
94
|
+
return String(text).replace(/\[[0-9;]*[A-Za-z]/g, "").trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// stdio[0] MUST be "ignore". Inherited stdin makes invocations hang forever
|
|
98
|
+
// under launchd — brand-manager hit this exact bug (fixed in 3b1f955) and it
|
|
99
|
+
// presents as the worker silently doing nothing after a reboot.
|
|
100
|
+
function run(agent, args, { timeoutMs, cwd }) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
const child = spawn(bin(agent), args, {
|
|
103
|
+
cwd,
|
|
104
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
105
|
+
});
|
|
106
|
+
let stdout = "";
|
|
107
|
+
let stderr = "";
|
|
108
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
109
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
child.kill("SIGKILL");
|
|
112
|
+
reject(new Error(`${LABELS[agent]} timed out after ${timeoutMs}ms`));
|
|
113
|
+
}, timeoutMs);
|
|
114
|
+
child.on("error", (err) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
reject(
|
|
117
|
+
err.code === "ENOENT"
|
|
118
|
+
? new Error(
|
|
119
|
+
`${LABELS[agent]} isn't installed on this computer. Install: ${INSTALL_HINTS[agent]} — or switch your agent back to Claude Code in Setup on goosetools.com.`,
|
|
120
|
+
)
|
|
121
|
+
: err,
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
if (code === 0) resolve(stdout);
|
|
127
|
+
else
|
|
128
|
+
reject(
|
|
129
|
+
new Error(`${LABELS[agent]} exited ${code}: ${stderr.slice(0, 600)}`),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Is the CLI on PATH? { installed, hint } — cheap, for startup diagnostics. */
|
|
136
|
+
export function detect(agent) {
|
|
137
|
+
const res = spawnSync(bin(agent), ["--version"], {
|
|
138
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
139
|
+
timeout: 10_000,
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
installed: res.status === 0,
|
|
143
|
+
hint: INSTALL_HINTS[agent],
|
|
144
|
+
label: LABELS[agent],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Tools Claude must NOT touch in oneshot mode — everything is inlined.
|
|
149
|
+
const CLAUDE_TOOLS_OFF = "Read,Edit,Write,Bash,Glob,Grep,WebFetch,WebSearch";
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run a prompt through the chosen agent CLI.
|
|
153
|
+
* Returns { text, sessionId } — sessionId is namespaced and null whenever
|
|
154
|
+
* the CLI can't resume (callers then replay history on the next turn).
|
|
155
|
+
*/
|
|
156
|
+
export async function runAgent({
|
|
157
|
+
agent = "claude-code",
|
|
158
|
+
prompt,
|
|
159
|
+
cwd,
|
|
160
|
+
mode = "text",
|
|
161
|
+
sessionId = null,
|
|
162
|
+
tools = "Read,Edit,Write,Glob,Grep",
|
|
163
|
+
timeoutMs = 5 * 60 * 1000,
|
|
164
|
+
}) {
|
|
165
|
+
const normalized = normalizeAgent(agent);
|
|
166
|
+
if (!normalized) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Unknown agent "${agent}" — update this worker: npx --yes <package>@latest install`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const resume = resolveSession(normalized, sessionId);
|
|
172
|
+
|
|
173
|
+
if (normalized === "claude-code") {
|
|
174
|
+
if (mode === "session") {
|
|
175
|
+
const args = [
|
|
176
|
+
"-p",
|
|
177
|
+
prompt,
|
|
178
|
+
"--output-format",
|
|
179
|
+
"json",
|
|
180
|
+
"--permission-mode",
|
|
181
|
+
"acceptEdits",
|
|
182
|
+
"--allowedTools",
|
|
183
|
+
tools,
|
|
184
|
+
];
|
|
185
|
+
if (resume) args.push("--resume", resume);
|
|
186
|
+
const raw = await run(normalized, args, { timeoutMs, cwd });
|
|
187
|
+
// The json envelope carries the session id. Be forgiving: a shape
|
|
188
|
+
// change shouldn't lose the user's answer.
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(raw);
|
|
191
|
+
return {
|
|
192
|
+
text: parsed.result ?? parsed.text ?? raw,
|
|
193
|
+
sessionId: namespaced(
|
|
194
|
+
normalized,
|
|
195
|
+
parsed.session_id ?? parsed.sessionId ?? resume ?? null,
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
} catch {
|
|
199
|
+
return { text: raw, sessionId: namespaced(normalized, resume) };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const args = ["-p", prompt, "--output-format", "text"];
|
|
203
|
+
if (mode === "oneshot") args.push("--disallowedTools", CLAUDE_TOOLS_OFF);
|
|
204
|
+
if (mode === "read") args.push("--allowedTools", "Read");
|
|
205
|
+
const text = await run(normalized, args, { timeoutMs, cwd });
|
|
206
|
+
return { text, sessionId: null };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (normalized === "codex") {
|
|
210
|
+
// Sandbox is the only tool control codex offers; read-only + the prompt's
|
|
211
|
+
// own "don't use tools" language is the closest match to oneshot.
|
|
212
|
+
const sandbox = mode === "session" ? "workspace-write" : "read-only";
|
|
213
|
+
const args = ["exec"];
|
|
214
|
+
if (resume) args.push("resume", resume);
|
|
215
|
+
args.push("--skip-git-repo-check", "--sandbox", sandbox, "--json");
|
|
216
|
+
if (cwd) args.push("--cd", cwd);
|
|
217
|
+
args.push(prompt);
|
|
218
|
+
const raw = await run(normalized, args, { timeoutMs, cwd });
|
|
219
|
+
// --json emits JSONL events; harvest the thread id and the last agent
|
|
220
|
+
// message, falling back to the raw output if the shape ever changes.
|
|
221
|
+
let threadId = resume ?? null;
|
|
222
|
+
let last = null;
|
|
223
|
+
for (const line of raw.split("\n")) {
|
|
224
|
+
const trimmed = line.trim();
|
|
225
|
+
if (!trimmed.startsWith("{")) continue;
|
|
226
|
+
try {
|
|
227
|
+
const evt = JSON.parse(trimmed);
|
|
228
|
+
threadId =
|
|
229
|
+
evt.thread_id ?? evt.session_id ?? evt.thread?.id ?? threadId;
|
|
230
|
+
const item = evt.item ?? evt;
|
|
231
|
+
const type = item.item_type ?? item.type;
|
|
232
|
+
if (
|
|
233
|
+
(type === "agent_message" || type === "assistant_message") &&
|
|
234
|
+
typeof (item.text ?? item.message) === "string"
|
|
235
|
+
) {
|
|
236
|
+
last = item.text ?? item.message;
|
|
237
|
+
}
|
|
238
|
+
} catch {
|
|
239
|
+
// not an event line — ignore
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
text: cleanText(last ?? raw),
|
|
244
|
+
sessionId: mode === "session" ? namespaced(normalized, threadId) : null,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (normalized === "gemini") {
|
|
249
|
+
// No reliable headless resume — never claim one. Session mode gets yolo
|
|
250
|
+
// approvals so its tools can run unattended.
|
|
251
|
+
const args = ["-p", prompt, "--output-format", "json"];
|
|
252
|
+
if (mode === "session") args.push("--yolo");
|
|
253
|
+
let raw;
|
|
254
|
+
try {
|
|
255
|
+
raw = await run(normalized, args, { timeoutMs, cwd });
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Older gemini builds lack --output-format; retry plain.
|
|
258
|
+
if (!/output-format|unknown option/i.test(String(err.message))) throw err;
|
|
259
|
+
raw = await run(normalized, ["-p", prompt], { timeoutMs, cwd });
|
|
260
|
+
return { text: cleanText(raw), sessionId: null };
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(raw);
|
|
264
|
+
return {
|
|
265
|
+
text: cleanText(parsed.response ?? parsed.result ?? raw),
|
|
266
|
+
sessionId: null,
|
|
267
|
+
};
|
|
268
|
+
} catch {
|
|
269
|
+
return { text: cleanText(raw), sessionId: null };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// opencode — plain text out; we don't claim session continuation (it's
|
|
274
|
+
// version-dependent), so every turn replays history.
|
|
275
|
+
const raw = await run(normalized, ["run", prompt], { timeoutMs, cwd });
|
|
276
|
+
return { text: cleanText(raw), sessionId: null };
|
|
277
|
+
}
|