weftens 0.1.2 → 0.1.3
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 +4 -3
- package/agents/agent2060.md +1 -1
- package/package.json +4 -3
- package/src/invoke.js +10 -2
- package/content-org/.preflight/telemetry.jsonl +0 -6
- package/content-org/briefs/BRIEFS.md +0 -47
- package/content-org/config.mjs +0 -78
- package/content-org/cost.mjs +0 -36
- package/content-org/lead.js +0 -64
- package/content-org/metrics.mjs +0 -166
- package/content-org/orchestrator.mjs +0 -246
- package/content-org/package.json +0 -14
- package/content-org/producer.mjs +0 -59
- package/content-org/publish.mjs +0 -34
- package/content-org/review.mjs +0 -32
- package/content-org/studio/README.md +0 -27
- package/content-org/studio/assemble.sh +0 -48
- package/content-org/test.mjs +0 -110
package/README.md
CHANGED
|
@@ -48,8 +48,9 @@ whoever manages your ads, or paste this to an AI assistant that can use your com
|
|
|
48
48
|
- **Two seats are not in this repo.** `richy` (representation) and `pio` (prospect research) are
|
|
49
49
|
clients for separate services that aren't open source. Without those services configured they
|
|
50
50
|
return sample data that says so in its own output.
|
|
51
|
-
- **The content pipeline is
|
|
52
|
-
|
|
51
|
+
- **The content pipeline is not in this package.** Ten seats are defined in `agents/`, but the
|
|
52
|
+
pipeline they run through isn't published — it emitted placeholder output, so shipping it would
|
|
53
|
+
have implied a capability that doesn't exist. The `content` seat says so when you call it.
|
|
53
54
|
|
|
54
55
|
Free, MIT licensed, no account, no billing.
|
|
55
56
|
|
|
@@ -63,7 +64,7 @@ npm test
|
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
That's the whole install. Everything lives in this one repo — the deterministic auditors
|
|
66
|
-
(`agent-cores/`)
|
|
67
|
+
(`agent-cores/`) and the agent seat definitions (`agents/`).
|
|
67
68
|
A fresh clone runs on its own; there is no second checkout and no external directory. (Verified by
|
|
68
69
|
cloning to an unrelated path and running the suite there — the earlier version of this claim was
|
|
69
70
|
false: the end-to-end test resolved `agent-cores/` from the repo's *parent*, so it only passed on the
|
package/agents/agent2060.md
CHANGED
|
@@ -15,7 +15,7 @@ unless it names that evidence.
|
|
|
15
15
|
|
|
16
16
|
## Runtime — this seat runs the real engine
|
|
17
17
|
|
|
18
|
-
The canonical runtime is the Agent2060 engine at `
|
|
18
|
+
The canonical runtime is the Agent2060 engine at a local checkoutagent2060` (policy `AGENT2060.md`,
|
|
19
19
|
schema `schemas/agent2060-response.schema.json`). Do not free-form the output shape when the engine
|
|
20
20
|
is available — run it, with cwd set to the repo root:
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weftens",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weftens \u2014 one front door that routes a marketing/representation request to the right agent and runs it on your own AI subscription (bring-your-own-model). Usable as a CLI or an SDK.",
|
|
6
6
|
"main": "index.js",
|
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
"src",
|
|
20
20
|
"agents",
|
|
21
21
|
"agent-cores",
|
|
22
|
-
"content-org",
|
|
23
22
|
"index.js",
|
|
24
23
|
"providers.json.example",
|
|
25
24
|
"README.md",
|
|
26
|
-
"LICENSE"
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"!**/.preflight",
|
|
27
|
+
"!**/.preflight/**"
|
|
27
28
|
],
|
|
28
29
|
"scripts": {
|
|
29
30
|
"test": "npm run test:unit && npm run test:cores",
|
package/src/invoke.js
CHANGED
|
@@ -98,9 +98,17 @@ export async function invoke(agent, opts = {}) {
|
|
|
98
98
|
// it. Say that plainly rather than implying the request drove the run. Defaults to
|
|
99
99
|
// dry-run: a real run is gated on the owner's open content decisions.
|
|
100
100
|
if (agent.tier === 1 && agent.leads === "content-org") {
|
|
101
|
-
|
|
101
|
+
// The pipeline is NOT part of the published package: it emitted placeholder output, and its
|
|
102
|
+
// config carried draft pricing and internal approval routing. Anyone who has a real one can
|
|
103
|
+
// point at it with WEFTENS_CONTENT_DIR. Overridable rather than hardcoded so the
|
|
104
|
+
// not-installed path — which is what every consumer gets — is actually testable.
|
|
105
|
+
const contentDir = process.env.WEFTENS_CONTENT_DIR || join(REPO, "content-org");
|
|
106
|
+
const leadPath = join(contentDir, "lead.js");
|
|
102
107
|
if (!existsSync(leadPath)) {
|
|
103
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
agent: agent.name, path: "content-pipeline",
|
|
110
|
+
note: "content pipeline not installed — it is not part of this package. Point WEFTENS_CONTENT_DIR at a checkout to enable it.",
|
|
111
|
+
};
|
|
104
112
|
}
|
|
105
113
|
const mod = await import(pathToFileURL(leadPath).href);
|
|
106
114
|
const dry = opts.dry !== false; // real run only when explicitly asked
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
{"ts":"2026-07-19T17:03:20.481Z","tool":"edit_file","source":"raw","status":"pass","counts":{"total":8,"pass":8,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\src\\registry.js","durationMs":3}
|
|
2
|
-
{"ts":"2026-07-19T17:03:27.408Z","tool":"edit_file","source":"raw","status":"pass","counts":{"total":8,"pass":8,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\src\\invoke.js","durationMs":2}
|
|
3
|
-
{"ts":"2026-07-19T17:03:35.845Z","tool":"edit_file","source":"raw","status":"pass","counts":{"total":8,"pass":8,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\src\\invoke.js","durationMs":2}
|
|
4
|
-
{"ts":"2026-07-19T17:03:39.916Z","tool":"edit_file","source":"raw","status":"pass","counts":{"total":8,"pass":8,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\src\\invoke.js","durationMs":2}
|
|
5
|
-
{"ts":"2026-07-19T17:03:56.374Z","tool":"write_file","source":"raw","status":"pass","counts":{"total":7,"pass":7,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\index.js","durationMs":2}
|
|
6
|
-
{"ts":"2026-07-19T17:04:01.490Z","tool":"edit_file","source":"raw","status":"pass","counts":{"total":8,"pass":8,"warn":0,"fail":0},"topRules":[],"findings":[],"pathPreview":"C:\\Users\\teche\\weftens\\package.json","durationMs":2}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# Format briefs
|
|
2
|
-
|
|
3
|
-
The five formats the org produces. Each is a **brief the seats consume** — angle patterns, structure,
|
|
4
|
-
and the disclosure stance. `RISK` in `config.mjs` sets which auto-post vs hold. Audience across all:
|
|
5
|
-
**older Facebook users**; brand voice: *declare what's real*; disclosure is a **per-piece call, never an
|
|
6
|
-
always-rule**; the scam guardrail is **theatre** (instant reveal, captures nothing, no real person harmed).
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## traveler · auto
|
|
11
|
-
The recurring time-traveler character dropped into a different time+place each episode. **The moat** —
|
|
12
|
-
people subscribe to a *who*, not a vibe. Needs character-consistent clips (Veo/Seedance hero lane).
|
|
13
|
-
- **Hook (3s):** the character mid-scene in a vivid era ("It's Tokyo, 1985, and…").
|
|
14
|
-
- **Structure:** cold open in-scene → one wistful observation that ties past to now → button line.
|
|
15
|
-
- **Disclosure:** "Imagined with AI" when it fits; sometimes the era-craft *is* the point. Kayla's call.
|
|
16
|
-
- **Continuity:** same face, voice, and one signature line across episodes.
|
|
17
|
-
|
|
18
|
-
## scam-reveal · hold
|
|
19
|
-
Nostalgia/relatable hook → theatrical "attempted hack" → **"YOU COULD HAVE BEEN HACKED"** → one defensive tell.
|
|
20
|
-
- **Hook (3s):** a familiar scenario (a text from "your bank," a too-good deal).
|
|
21
|
-
- **Structure:** set the trap → the reveal beat → one concrete thing the viewer can do tomorrow.
|
|
22
|
-
- **HARD LINE:** theatre only. Instant reveal, page/piece captures NOTHING, no real non-consenting person
|
|
23
|
-
is hacked or harmed. QC blocks anything that crosses this.
|
|
24
|
-
- **Disclosure:** the reveal is the disclosure; label as AI where useful.
|
|
25
|
-
|
|
26
|
-
## is-this-real · hold
|
|
27
|
-
Teach older viewers to spot AI fakes / deepfakes / cloned voices. **Most on-brand** — the content *is* the
|
|
28
|
-
"declare what's real" demo.
|
|
29
|
-
- **Hook (3s):** "Is this real? Watch closely." (a clip with one tell).
|
|
30
|
-
- **Structure:** show the fake → reveal the tell(s) → "here's how to check next time."
|
|
31
|
-
- **Disclosure:** always clarify what was AI vs real in-piece — here it's the whole point.
|
|
32
|
-
- **QC:** claim accuracy is critical; never mislabel a real thing as fake or vice-versa.
|
|
33
|
-
|
|
34
|
-
## nostalgia · auto
|
|
35
|
-
Straight "remember when" archival-feeling reach. Pure audience fuel; highest raw engagement with older FB.
|
|
36
|
-
- **Hook (3s):** a specific sensory memory ("If you remember the sound of a rotary phone…").
|
|
37
|
-
- **Structure:** montage of era details → one warm line of meaning → gentle prompt to comment.
|
|
38
|
-
- **Disclosure:** label as AI-imagined where the footage is fabricated (Meta throttles undisclosed
|
|
39
|
-
fake-real-events — proactive labeling protects reach).
|
|
40
|
-
- **Bulk lane** (Wan) is fine; no recurring face needed.
|
|
41
|
-
|
|
42
|
-
## ai-for-seniors · hold
|
|
43
|
-
Calm, plain-language explainers ("what's an agent?", "is the AI listening to me?"). Empowerment, no fear.
|
|
44
|
-
- **Hook (3s):** a real question they've wondered ("Is your phone spying on you? Sort of — here's the truth").
|
|
45
|
-
- **Structure:** the worry → the plain answer → one practical takeaway.
|
|
46
|
-
- **Disclosure:** AI-made, said plainly; builds the brand credibility the flywheel cashes in.
|
|
47
|
-
- **QC:** no false/overblown claims about AI; accuracy is the whole value.
|
package/content-org/config.mjs
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// content-org — central config. Change tiers/models here; nothing else needs editing.
|
|
2
|
-
|
|
3
|
-
export const MODELS = {
|
|
4
|
-
haiku: "claude-haiku-4-5", // $1/$5 per 1M — routine seats
|
|
5
|
-
sonnet: "claude-sonnet-5", // $3/$15 — writing-quality seats
|
|
6
|
-
opus: "claude-opus-4-8", // $5/$25 — only if a real test shows a lift
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
// Value tier (locked 2026-07-08): pay for quality only where it moves reach/moat.
|
|
10
|
-
// Sonnet on the seats whose prose the viewer feels (Strategy board, Script, Editor);
|
|
11
|
-
// Haiku on the structured hand-offs the viewer never sees.
|
|
12
|
-
export const SEAT_MODEL = {
|
|
13
|
-
showrunner: "haiku",
|
|
14
|
-
strategy: "sonnet",
|
|
15
|
-
ideator: "haiku",
|
|
16
|
-
research: "haiku",
|
|
17
|
-
script: "sonnet",
|
|
18
|
-
editor: "sonnet",
|
|
19
|
-
cut: "sonnet", // Video Editor — the cut decides completion rate; worth Sonnet
|
|
20
|
-
feed: "haiku",
|
|
21
|
-
qc: "haiku",
|
|
22
|
-
growth: "haiku", // Growth/Analyst — reads the deterministic metrics.mjs summary; structured hand-off, viewer never sees it
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// Clip lanes — pick per piece. Prices are RANGES (sources disagree; price the exact
|
|
26
|
-
// tier on fal.ai at commit). No flat-unlimited *automated* lane exists (verified 2026-07-08).
|
|
27
|
-
export const CLIP_LANES = {
|
|
28
|
-
manual: { label: "manual flat sub", usdPerSec: 0, note: "hand-generate; ~$0 marginal; cheapest" },
|
|
29
|
-
bulk: { label: "Wan 2.6 (bulk B-roll)", usdPerSec: 0.06, audio: true },
|
|
30
|
-
hero: { label: "Veo 3.1 (hero / recurring face)", usdPerSec: 0.05, audio: true, note: "value king: audio + consistency" },
|
|
31
|
-
// Kling deliberately omitted from defaults — priciest, no consistency edge over Veo/Seedance.
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Approx token cost per finished piece by model mix (for cost.mjs). ~per-piece USD of the
|
|
35
|
-
// 8 text-seat calls; Batch API halves it. Rough, tune against real usage.
|
|
36
|
-
export const TEXT_COST_PER_PIECE = {
|
|
37
|
-
valueTier: 0.20, // Sonnet(strategy/script/editor) + Haiku(rest)
|
|
38
|
-
valueTierBatch: 0.10, // with Batch API (50% off)
|
|
39
|
-
allHaiku: 0.05,
|
|
40
|
-
allOpus: 0.55,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const VOICE = {
|
|
44
|
-
elevenlabs: { label: "ElevenLabs Starter", usdPerMonth: 5, perPieceUsd: 0.03 },
|
|
45
|
-
kokoro: { label: "Kokoro (OSS, local)", usdPerMonth: 0, perPieceUsd: 0 },
|
|
46
|
-
veoNative: { label: "Veo native audio (no separate voice)", usdPerMonth: 0, perPieceUsd: 0 },
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Client-facing price tiers (DRAFT — Kayla to confirm numbers). reels = pieces/month.
|
|
50
|
-
export const CLIENT_TIERS = {
|
|
51
|
-
starter: {
|
|
52
|
-
name: "Starter", priceUsd: 399, reels: 8,
|
|
53
|
-
clip: "bulk", script: "sonnet", voice: "elevenlabs",
|
|
54
|
-
recurringCharacter: 0, managedPosting: false, complianceQC: "basic", revisions: 1,
|
|
55
|
-
},
|
|
56
|
-
growth: {
|
|
57
|
-
name: "Growth", priceUsd: 899, reels: 20,
|
|
58
|
-
clip: "hero+bulk", script: "sonnet", voice: "elevenlabs",
|
|
59
|
-
recurringCharacter: 1, managedPosting: true, complianceQC: "disclosed+claim-clean",
|
|
60
|
-
analytics: "monthly", revisions: 2,
|
|
61
|
-
},
|
|
62
|
-
signature: {
|
|
63
|
-
name: "Signature", priceUsd: 1999, reels: 40,
|
|
64
|
-
clip: "hero", script: "sonnet+opus-hero", voice: "elevenlabs",
|
|
65
|
-
recurringCharacter: 2, managedPosting: true, complianceQC: "senior/finance/health compliance pass",
|
|
66
|
-
analytics: "full+strategy", revisions: "generous",
|
|
67
|
-
},
|
|
68
|
-
alacarte: { name: "À la carte", priceUsd: 100, reels: 1, note: "$75–150/Reel for one-offs & tests" },
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
// Format → risk tier (tiered approval). Sensitive formats HOLD for Kayla.
|
|
72
|
-
export const RISK = {
|
|
73
|
-
"traveler": "auto",
|
|
74
|
-
"nostalgia": "auto",
|
|
75
|
-
"scam-reveal": "hold",
|
|
76
|
-
"is-this-real": "hold",
|
|
77
|
-
"ai-for-seniors": "hold",
|
|
78
|
-
};
|
package/content-org/cost.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// content-org — COGS + margin calculator. `node cost.mjs [piecesPerMonth]`
|
|
2
|
-
// No dependencies. Prices are estimates from config.mjs — tune against real usage.
|
|
3
|
-
import { CLIENT_TIERS, TEXT_COST_PER_PIECE, CLIP_LANES, VOICE } from "./config.mjs";
|
|
4
|
-
|
|
5
|
-
const usd = (n) => "$" + n.toFixed(2);
|
|
6
|
-
const REEL_SECONDS = 30; // ~30s Reel
|
|
7
|
-
|
|
8
|
-
// per-finished-Reel COGS given a clip lane + text tier + voice
|
|
9
|
-
function cogsPerReel({ clip = "hero", textTier = "valueTierBatch", voice = "elevenlabs" } = {}) {
|
|
10
|
-
const text = TEXT_COST_PER_PIECE[textTier] ?? TEXT_COST_PER_PIECE.valueTierBatch;
|
|
11
|
-
const clipUsd = (CLIP_LANES[clip]?.usdPerSec ?? 0) * REEL_SECONDS;
|
|
12
|
-
const voiceUsd = VOICE[voice]?.perPieceUsd ?? 0;
|
|
13
|
-
return text + clipUsd + voiceUsd;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
console.log("=== per-Reel COGS by clip lane (value text tier + Batch, ElevenLabs voice) ===");
|
|
17
|
-
for (const lane of Object.keys(CLIP_LANES)) {
|
|
18
|
-
console.log(` ${lane.padEnd(7)} ${usd(cogsPerReel({ clip: lane }))} (${CLIP_LANES[lane].label})`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
console.log("\n=== client tiers (DRAFT prices) ===");
|
|
22
|
-
for (const key of ["starter", "growth", "signature"]) {
|
|
23
|
-
const t = CLIENT_TIERS[key];
|
|
24
|
-
const lane = key === "starter" ? "bulk" : "hero";
|
|
25
|
-
const cogs = cogsPerReel({ clip: lane }) * t.reels;
|
|
26
|
-
const voiceSub = VOICE.elevenlabs.usdPerMonth; // one sub covers all clients
|
|
27
|
-
const margin = t.priceUsd - cogs;
|
|
28
|
-
const marginPct = ((margin / t.priceUsd) * 100).toFixed(0);
|
|
29
|
-
console.log(` ${t.name.padEnd(10)} ${usd(t.priceUsd)}/mo ${String(t.reels).padStart(2)} Reels COGS ${usd(cogs)} → margin ${usd(margin)} (${marginPct}%)`);
|
|
30
|
-
}
|
|
31
|
-
console.log(` (+ shared fixed: ElevenLabs sub ${usd(VOICE.elevenlabs.usdPerMonth)}/mo across all clients; tooling floor ~$130/mo automated)`);
|
|
32
|
-
|
|
33
|
-
console.log("\n=== YOUR OWN pages ===");
|
|
34
|
-
const own = Number(process.argv[2] || "90");
|
|
35
|
-
const ownCogs = cogsPerReel({ clip: "hero" }) * own;
|
|
36
|
-
console.log(` ${own} Reels/mo automated (Veo hero): clips+text+voice ≈ ${usd(ownCogs)} | manual clips ≈ ${usd(cogsPerReel({ clip: "manual" }) * own)}`);
|
package/content-org/lead.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// Content lead — the single callable entry the Weftens front door hands to when a
|
|
2
|
-
// request is about making content. The door calls this; this runs the 10-seat
|
|
3
|
-
// pipeline and returns one result. The door never addresses the individual seats
|
|
4
|
-
// (ideator, writer, qc, …) — the Content lead owns them.
|
|
5
|
-
//
|
|
6
|
-
// It shells the existing orchestrator rather than importing a run() function: the
|
|
7
|
-
// orchestrator's full-run body is argv/global-driven, and wrapping it by shell keeps
|
|
8
|
-
// its 27 passing tests untouched (no refactor risk). The orchestrator writes packet
|
|
9
|
-
// JSON into content-org/inbox/<date>/; we run it, read those packets back, and return
|
|
10
|
-
// them as the result.
|
|
11
|
-
//
|
|
12
|
-
// Defaults to dry-run. A real content run depends on Phase-0 decisions that are
|
|
13
|
-
// Kayla's and still open (local vs cloud, cadence, keys) — so this wiring proves the
|
|
14
|
-
// pipeline is callable as one node WITHOUT producing real published content. Pass
|
|
15
|
-
// { dry: false } only once those decisions are made and a key is configured.
|
|
16
|
-
|
|
17
|
-
import { execFile } from "node:child_process";
|
|
18
|
-
import { promisify } from "node:util";
|
|
19
|
-
import { readFile, readdir, rm } from "node:fs/promises";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { dirname, join } from "node:path";
|
|
22
|
-
|
|
23
|
-
const execFileP = promisify(execFile);
|
|
24
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Run the content pipeline as one node.
|
|
28
|
-
* @param opts { format?, count?, client?, tier?, dry? } // dry defaults true (safe)
|
|
29
|
-
* @returns { dry, format, count, artifacts, result, log }
|
|
30
|
-
* artifacts packet file paths produced
|
|
31
|
-
* result the parsed packets (dry-run packets are stub-shaped, clearly slugged dryrun-N)
|
|
32
|
-
*/
|
|
33
|
-
export async function runContent(opts = {}) {
|
|
34
|
-
const { format = "scam-reveal", count = 1, client = null, tier = null, dry = true } = opts;
|
|
35
|
-
|
|
36
|
-
const args = ["orchestrator.mjs", format, String(count)];
|
|
37
|
-
if (client) args.push(`--client=${client}`);
|
|
38
|
-
if (tier) args.push(`--tier=${tier}`);
|
|
39
|
-
if (dry) args.push("--dry-run");
|
|
40
|
-
|
|
41
|
-
const { stdout } = await execFileP("node", args, { cwd: HERE, maxBuffer: 8_000_000 });
|
|
42
|
-
|
|
43
|
-
// The orchestrator writes to inbox/<date>/; recover <date> from its own output line
|
|
44
|
-
// ("Packets in inbox/2026-07-19/.") rather than recomputing a clock here.
|
|
45
|
-
const m = stdout.match(/Packets in (inbox[\\/][^\s/]+)/);
|
|
46
|
-
const outRel = m ? m[1].replace(/[\\/]$/, "") : null;
|
|
47
|
-
const artifacts = [];
|
|
48
|
-
const result = [];
|
|
49
|
-
if (outRel) {
|
|
50
|
-
const outDir = join(HERE, outRel);
|
|
51
|
-
for (const name of (await readdir(outDir)).filter((n) => n.endsWith(".json"))) {
|
|
52
|
-
const file = join(outDir, name);
|
|
53
|
-
artifacts.push(file);
|
|
54
|
-
result.push(JSON.parse(await readFile(file, "utf8")));
|
|
55
|
-
}
|
|
56
|
-
// A dry run's stub packets are throwaway — remove them so a verification run does
|
|
57
|
-
// not litter content-org/inbox. Real runs keep their packets.
|
|
58
|
-
if (dry) {
|
|
59
|
-
await Promise.all(artifacts.map((f) => rm(f, { force: true })));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return { dry, format, count, artifacts, result, log: stdout.trim() };
|
|
64
|
-
}
|
package/content-org/metrics.mjs
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// content-org — deterministic metrics module for the Growth/Analyst seat (#14).
|
|
2
|
-
// Parses a published-piece metrics file (CSV or JSON array), computes per-piece engagement
|
|
3
|
-
// rate, per-angle/format/platform rollups (joined to published/log.jsonl where slugs match),
|
|
4
|
-
// top/bottom performers, and a dataSufficiency verdict. Zero-dependency, no model calls —
|
|
5
|
-
// the growth seat gets this SUMMARY, never raw rows. Tolerant of missing columns: computes
|
|
6
|
-
// what it can and lists what it couldn't in `couldNotCompute`.
|
|
7
|
-
|
|
8
|
-
// Sufficiency boundaries: none = 0 rows; thin = < 10 pieces OR < 14-day span (or unusable dates).
|
|
9
|
-
export const THIN_MIN_PIECES = 10;
|
|
10
|
-
export const THIN_MIN_SPAN_DAYS = 14;
|
|
11
|
-
|
|
12
|
-
// Column aliases — normalize whatever header the platform export used (compared with
|
|
13
|
-
// non-alphanumerics stripped, lowercase: "Piece ID" / "piece_id" / "pieceId" all match).
|
|
14
|
-
const ALIASES = {
|
|
15
|
-
id: ["id", "piece", "pieceid", "slug", "postid"],
|
|
16
|
-
title: ["title", "name"],
|
|
17
|
-
platform: ["platform", "channel", "network"],
|
|
18
|
-
date: ["date", "published", "publishedat", "postdate", "publishdate"],
|
|
19
|
-
angle: ["angle"],
|
|
20
|
-
format: ["format"],
|
|
21
|
-
views: ["views", "plays", "videoviews", "impressions"],
|
|
22
|
-
reach: ["reach", "accountsreached"],
|
|
23
|
-
likes: ["likes", "reactions"],
|
|
24
|
-
comments: ["comments"],
|
|
25
|
-
shares: ["shares", "reposts"],
|
|
26
|
-
saves: ["saves", "bookmarks"],
|
|
27
|
-
watchTime: ["watchtime", "avgwatchtime", "watchtimesec"],
|
|
28
|
-
};
|
|
29
|
-
const NUMERIC = new Set(["views", "reach", "likes", "comments", "shares", "saves", "watchTime"]);
|
|
30
|
-
const INTERACTIONS = ["likes", "comments", "shares", "saves"];
|
|
31
|
-
|
|
32
|
-
function canonical(header) {
|
|
33
|
-
const h = String(header).trim().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
34
|
-
for (const [field, aliases] of Object.entries(ALIASES)) if (aliases.includes(h)) return field;
|
|
35
|
-
return null; // unknown column — ignored
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function coerce(rec) {
|
|
39
|
-
for (const k of Object.keys(rec)) {
|
|
40
|
-
if (NUMERIC.has(k)) {
|
|
41
|
-
const n = Number(String(rec[k]).replace(/,/g, ""));
|
|
42
|
-
if (Number.isNaN(n)) delete rec[k]; else rec[k] = n;
|
|
43
|
-
} else rec[k] = String(rec[k]);
|
|
44
|
-
}
|
|
45
|
-
return rec;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function normalizeRecord(raw) {
|
|
49
|
-
const rec = {};
|
|
50
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
51
|
-
const field = canonical(k);
|
|
52
|
-
if (field && v !== null && v !== undefined && v !== "") rec[field] = v;
|
|
53
|
-
}
|
|
54
|
-
return coerce(rec);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Minimal CSV line split with quoted-field support ("a, b" stays one cell, "" escapes a quote).
|
|
58
|
-
function splitCsvLine(line) {
|
|
59
|
-
const cells = []; let cur = "", inQ = false;
|
|
60
|
-
for (let i = 0; i < line.length; i++) {
|
|
61
|
-
const c = line[i];
|
|
62
|
-
if (inQ) {
|
|
63
|
-
if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
|
|
64
|
-
else cur += c;
|
|
65
|
-
} else if (c === '"') inQ = true;
|
|
66
|
-
else if (c === ",") { cells.push(cur); cur = ""; }
|
|
67
|
-
else cur += c;
|
|
68
|
-
}
|
|
69
|
-
cells.push(cur);
|
|
70
|
-
return cells.map((s) => s.trim());
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const fieldsOf = (rows) => [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
74
|
-
const round = (n) => (n === null || n === undefined ? null : Math.round(n * 10000) / 10000);
|
|
75
|
-
|
|
76
|
-
// --- parse: CSV or JSON array -> normalized rows ---------------------------------------
|
|
77
|
-
export function parseMetrics(text) {
|
|
78
|
-
const trimmed = String(text).trim();
|
|
79
|
-
if (!trimmed) return { rows: [], fields: [] };
|
|
80
|
-
if (trimmed.startsWith("[")) {
|
|
81
|
-
const rows = JSON.parse(trimmed).map(normalizeRecord);
|
|
82
|
-
return { rows, fields: fieldsOf(rows) };
|
|
83
|
-
}
|
|
84
|
-
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
85
|
-
const header = splitCsvLine(lines[0]).map(canonical);
|
|
86
|
-
const rows = lines.slice(1).map((line) => {
|
|
87
|
-
const cells = splitCsvLine(line);
|
|
88
|
-
const rec = {};
|
|
89
|
-
header.forEach((field, i) => { if (field && cells[i] !== undefined && cells[i] !== "") rec[field] = cells[i]; });
|
|
90
|
-
return coerce(rec);
|
|
91
|
-
});
|
|
92
|
-
return { rows, fields: fieldsOf(rows) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// --- engagement rate: interactions / (views, else reach); null when it can't be computed
|
|
96
|
-
export function engagementRate(row) {
|
|
97
|
-
const denom = typeof row.views === "number" ? row.views : row.reach;
|
|
98
|
-
if (typeof denom !== "number" || denom <= 0) return null;
|
|
99
|
-
const present = INTERACTIONS.filter((k) => typeof row[k] === "number");
|
|
100
|
-
if (!present.length) return null;
|
|
101
|
-
return present.reduce((s, k) => s + row[k], 0) / denom;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// --- rollup: group rows by a field (angle/format/platform), avg engagement, sorted best-first
|
|
105
|
-
export function rollup(rows, key) {
|
|
106
|
-
const groups = new Map();
|
|
107
|
-
for (const r of rows) {
|
|
108
|
-
if (!r[key]) continue;
|
|
109
|
-
const g = groups.get(r[key]) || { [key]: r[key], pieces: 0, views: 0, ers: [] };
|
|
110
|
-
g.pieces++;
|
|
111
|
-
if (typeof r.views === "number") g.views += r.views;
|
|
112
|
-
const er = engagementRate(r);
|
|
113
|
-
if (er !== null) g.ers.push(er);
|
|
114
|
-
groups.set(r[key], g);
|
|
115
|
-
}
|
|
116
|
-
return [...groups.values()]
|
|
117
|
-
.map(({ ers, ...g }) => ({ ...g, avgEngagementRate: ers.length ? round(ers.reduce((a, b) => a + b, 0) / ers.length) : null }))
|
|
118
|
-
.sort((a, b) => (b.avgEngagementRate ?? -1) - (a.avgEngagementRate ?? -1));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// --- dataSufficiency: "none" | "thin" | "adequate" + plain-language note ----------------
|
|
122
|
-
export function sufficiency(rows) {
|
|
123
|
-
if (!rows.length) {
|
|
124
|
-
return { verdict: "none", note: `0 metric rows — nothing to read yet. No trend, no recommendation. Revisit once ~${THIN_MIN_PIECES} pieces have published over ${THIN_MIN_SPAN_DAYS}+ days.` };
|
|
125
|
-
}
|
|
126
|
-
const dates = rows.map((r) => Date.parse(r.date)).filter((t) => !Number.isNaN(t));
|
|
127
|
-
const spanDays = dates.length >= 2 ? Math.round((Math.max(...dates) - Math.min(...dates)) / 86400000) : null;
|
|
128
|
-
const reasons = [];
|
|
129
|
-
if (rows.length < THIN_MIN_PIECES) reasons.push(`only ${rows.length} piece(s) (want ${THIN_MIN_PIECES}+)`);
|
|
130
|
-
if (spanDays === null) reasons.push("no usable dates, so the time span is unknown");
|
|
131
|
-
else if (spanDays < THIN_MIN_SPAN_DAYS) reasons.push(`only a ${spanDays}-day span (want ${THIN_MIN_SPAN_DAYS}+)`);
|
|
132
|
-
if (reasons.length) {
|
|
133
|
-
return { verdict: "thin", note: `Data is thin: ${reasons.join("; ")}. Read directionally only — one strong piece is not a strategy; do not restack the board on this.` };
|
|
134
|
-
}
|
|
135
|
-
return { verdict: "adequate", note: `${rows.length} pieces over ${spanDays} days — enough to read angles and formats with normal care.` };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// --- summarize: the one entry point the orchestrator calls ------------------------------
|
|
139
|
-
// input: raw file text (CSV/JSON) or pre-parsed rows. published: entries from published/log.jsonl,
|
|
140
|
-
// joined by slug to fill format/client/tier on rows that lack them.
|
|
141
|
-
export function summarize(input, { published = [] } = {}) {
|
|
142
|
-
const { rows, fields } = typeof input === "string" ? parseMetrics(input) : { rows: input, fields: fieldsOf(input) };
|
|
143
|
-
const bySlug = new Map(published.filter((p) => p.slug).map((p) => [String(p.slug).toLowerCase(), p]));
|
|
144
|
-
for (const r of rows) {
|
|
145
|
-
const p = r.id && bySlug.get(String(r.id).toLowerCase());
|
|
146
|
-
if (p) for (const k of ["format", "client", "tier"]) if (r[k] === undefined && p[k] != null) r[k] = p[k];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const couldNotCompute = [];
|
|
150
|
-
const pieces = rows.map((r) => ({ ...r, engagementRate: round(engagementRate(r)) }));
|
|
151
|
-
const noEr = pieces.filter((p) => p.engagementRate === null).length;
|
|
152
|
-
if (noEr) couldNotCompute.push(`engagementRate for ${noEr}/${pieces.length} piece(s) — missing views/reach or all interaction columns`);
|
|
153
|
-
|
|
154
|
-
const rollups = {};
|
|
155
|
-
for (const [name, key] of [["byAngle", "angle"], ["byFormat", "format"], ["byPlatform", "platform"]]) {
|
|
156
|
-
const g = rollup(rows, key);
|
|
157
|
-
if (g.length) rollups[name] = g;
|
|
158
|
-
else couldNotCompute.push(`${name} rollup — no '${key}' on any row (and no published/log.jsonl join filled it)`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const ranked = pieces.filter((p) => p.engagementRate !== null).sort((a, b) => b.engagementRate - a.engagementRate);
|
|
162
|
-
const top = ranked.slice(0, 3);
|
|
163
|
-
const bottom = ranked.length > 3 ? ranked.slice(-3).reverse() : []; // worst first; empty when it would just repeat top
|
|
164
|
-
|
|
165
|
-
return { dataSufficiency: sufficiency(rows), pieces, rollups, top, bottom, fieldsPresent: fields, couldNotCompute };
|
|
166
|
-
}
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
// content-org — local orchestrator (the "prep half": API-only text seats).
|
|
2
|
-
// Showrunner → Strategy → per-piece [Ideator → Researcher → Script → Editor → Feed → QC]
|
|
3
|
-
// Output: ready-to-assemble packet per piece in inbox/<date>/<slug>.json → Kayla reviews,
|
|
4
|
-
// generates clips into clips/<slug>/, then producer.mjs assembles.
|
|
5
|
-
//
|
|
6
|
-
// Run (full pipeline — "together"):
|
|
7
|
-
// node orchestrator.mjs <format> <count> [--client=acme] [--tier=growth] [--dry-run]
|
|
8
|
-
// e.g. node orchestrator.mjs scam-reveal 2 --client=acme --tier=growth
|
|
9
|
-
// --dry-run uses canned data (NO API key needed) to verify the pipeline end-to-end.
|
|
10
|
-
//
|
|
11
|
-
// Run (single seat — "separately"):
|
|
12
|
-
// node orchestrator.mjs --seat <key> --input <file-or-json-or-text> [--out <file>] [--dry-run]
|
|
13
|
-
// e.g. node orchestrator.mjs --seat ideator --input briefs/angle.json
|
|
14
|
-
// node orchestrator.mjs --seat qc --input '{"script":"..."}' --dry-run
|
|
15
|
-
// node orchestrator.mjs --seat growth --input examples/metrics-sample.csv --dry-run
|
|
16
|
-
// Seat keys: showrunner, strategy, ideator, research, script, editor, cut, feed, qc, growth.
|
|
17
|
-
// (growth runs metrics.mjs deterministically FIRST; the model sees the computed summary, never raw rows)
|
|
18
|
-
// Output: the seat's schema-shaped JSON to stdout (or --out <file>).
|
|
19
|
-
//
|
|
20
|
-
// Model tiers come from config.mjs (Haiku routine / Sonnet script+editor). Producer
|
|
21
|
-
// (clip-gen + voice + FFmpeg) is a separate step — see producer.mjs.
|
|
22
|
-
|
|
23
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { pathToFileURL } from "node:url";
|
|
26
|
-
import { MODELS, SEAT_MODEL, RISK } from "./config.mjs";
|
|
27
|
-
import { summarize } from "./metrics.mjs";
|
|
28
|
-
|
|
29
|
-
const argv = process.argv.slice(2);
|
|
30
|
-
// Flags: --k=v always works; for value-taking flags, `--k v` (space-separated) also works.
|
|
31
|
-
const VALUE_FLAGS = new Set(["seat", "input", "out", "client", "tier"]);
|
|
32
|
-
const flags = {};
|
|
33
|
-
const positional = [];
|
|
34
|
-
for (let i = 0; i < argv.length; i++) {
|
|
35
|
-
const a = argv[i];
|
|
36
|
-
if (!a.startsWith("--")) { positional.push(a); continue; }
|
|
37
|
-
const [k, v] = a.replace(/^--/, "").split(/=(.*)/s);
|
|
38
|
-
if (v !== undefined) flags[k] = v;
|
|
39
|
-
else if (VALUE_FLAGS.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[k] = argv[++i];
|
|
40
|
-
else flags[k] = true;
|
|
41
|
-
}
|
|
42
|
-
const format = positional[0];
|
|
43
|
-
const count = Number(positional[1] || "1");
|
|
44
|
-
const DRY = !!flags["dry-run"];
|
|
45
|
-
|
|
46
|
-
// SDK is loaded lazily so --dry-run needs no dependencies installed.
|
|
47
|
-
let _client = null;
|
|
48
|
-
async function getClient() {
|
|
49
|
-
if (!_client) {
|
|
50
|
-
const { default: Anthropic } = await import("@anthropic-ai/sdk"); // reads ANTHROPIC_API_KEY or `ant auth login`
|
|
51
|
-
_client = new Anthropic();
|
|
52
|
-
}
|
|
53
|
-
return _client;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// --- Seats: role prompt + output JSON schema (model chosen per-seat from config) ------
|
|
57
|
-
const AUDIENCE =
|
|
58
|
-
"Primary audience: older Facebook users. Faceless, disclosed-AI-where-Kayla-chooses. " +
|
|
59
|
-
"Brand voice: 'declare what's real'. NEVER moralize disclosure — it is a per-piece choice. " +
|
|
60
|
-
"The one hard line on scam content: it is THEATRE (instant reveal, captures nothing, no real " +
|
|
61
|
-
"non-consenting person is harmed).";
|
|
62
|
-
|
|
63
|
-
const obj = (properties, required) => ({ type: "object", additionalProperties: false, properties, required });
|
|
64
|
-
const str = { type: "string" };
|
|
65
|
-
const bool = { type: "boolean" };
|
|
66
|
-
const arr = (items) => ({ type: "array", items });
|
|
67
|
-
|
|
68
|
-
const SHOWRUNNER = {
|
|
69
|
-
key: "showrunner", title: "Showrunner",
|
|
70
|
-
system: `You are the Showrunner — voice + continuity across every piece. ${AUDIENCE}`,
|
|
71
|
-
schema: obj({ voiceGuardrails: str, continuityNotes: str }, ["voiceGuardrails", "continuityNotes"]),
|
|
72
|
-
};
|
|
73
|
-
const STRATEGY = {
|
|
74
|
-
key: "strategy", title: "Content Strategy",
|
|
75
|
-
system: `You are Content Strategy. Given a format and count, produce distinct, non-repetitive angles that land with the audience. ${AUDIENCE}`,
|
|
76
|
-
schema: obj({ pieces: arr(obj({ slug: str, angle: str, why: str }, ["slug", "angle", "why"])) }, ["pieces"]),
|
|
77
|
-
};
|
|
78
|
-
const PIPELINE = [
|
|
79
|
-
{ key: "ideator", title: "Ideator",
|
|
80
|
-
system: `You are the Ideator. Turn the angle into a concept + a 3-second scroll-stopping hook + beat list. ${AUDIENCE}`,
|
|
81
|
-
schema: obj({ hook: str, concept: str, beats: arr(str) }, ["hook", "concept", "beats"]) },
|
|
82
|
-
{ key: "research", title: "Researcher",
|
|
83
|
-
system: `You are the Researcher. Source the facts/material; flag any claim that must be verified before publish. ${AUDIENCE}`,
|
|
84
|
-
schema: obj({ facts: arr(str), claimsToVerify: arr(str) }, ["facts", "claimsToVerify"]) },
|
|
85
|
-
{ key: "script", title: "Script Agent",
|
|
86
|
-
system: `You are the Script Agent. Write the full voiceover script (tight, spoken-word, older-FB cadence) + per-beat on-screen text. ${AUDIENCE}`,
|
|
87
|
-
schema: obj({ script: str, onScreenText: arr(str), voNotes: str }, ["script", "onScreenText", "voNotes"]) },
|
|
88
|
-
{ key: "editor", title: "Script Editor",
|
|
89
|
-
system: `You are the Script Editor. Tighten the script, enforce the Showrunner's voice, fact-check against research. Return the improved script and what you changed. ${AUDIENCE}`,
|
|
90
|
-
schema: obj({ script: str, changes: arr(str), voicePass: bool }, ["script", "changes", "voicePass"]) },
|
|
91
|
-
{ key: "cut", title: "Video Editor",
|
|
92
|
-
system: `You are the Video Editor. Turn the script into a concrete shot list Kayla can generate clips from: one entry per clip (5-10s each, ~4-7 clips for a ~30-45s Reel), each with a generation prompt (subject, era/setting, camera, mood — written for an AI video model), plus pacing notes and where each on-screen text lands. Number clips 01, 02… ${AUDIENCE}`,
|
|
93
|
-
schema: obj({
|
|
94
|
-
shots: arr(obj({ file: str, seconds: { type: "number" }, prompt: str, onScreenText: str }, ["file", "seconds", "prompt", "onScreenText"])),
|
|
95
|
-
pacingNotes: str,
|
|
96
|
-
}, ["shots", "pacingNotes"]) },
|
|
97
|
-
{ key: "feed", title: "Feed Optimizer",
|
|
98
|
-
system: `You are the Feed Optimizer. Nail the first 3 seconds; write the caption + hashtags + per-platform notes (Facebook, Instagram Reels). ${AUDIENCE}`,
|
|
99
|
-
schema: obj({ firstThreeSec: str, caption: str, hashtags: arr(str),
|
|
100
|
-
perPlatform: obj({ facebook: str, instagram: str }, ["facebook", "instagram"]) },
|
|
101
|
-
["firstThreeSec", "caption", "hashtags", "perPlatform"]) },
|
|
102
|
-
{ key: "qc", title: "Disclosure/QC",
|
|
103
|
-
system: `You are Disclosure/QC — the publish gate. Check: (1) every claim accurate/supported; (2) if scam-reveal or is-this-real, the THEATRE line holds (instant reveal, captures nothing, no real non-consenting person harmed); (3) recommend Kayla's per-piece disclosure call (labeled / reveal-is-the-point / neither) but DO NOT enforce always-labeling. List blockers if any check fails. ${AUDIENCE}`,
|
|
104
|
-
schema: obj({ claimsClean: bool, theatreLineOk: bool, disclosureCall: str, blockers: arr(str) },
|
|
105
|
-
["claimsClean", "theatreLineOk", "disclosureCall", "blockers"]) },
|
|
106
|
-
];
|
|
107
|
-
// Growth/Analyst (#14) — NOT in the per-piece pipeline; closes the loop after publish.
|
|
108
|
-
// Runs only via --seat growth. metrics.mjs computes the numbers deterministically first;
|
|
109
|
-
// the model reads that summary (never raw rows) and honors its dataSufficiency verdict.
|
|
110
|
-
const GROWTH = {
|
|
111
|
-
key: "growth", title: "Growth/Analyst",
|
|
112
|
-
system: `You are the Growth/Analyst. You receive a DETERMINISTIC metrics summary computed by metrics.mjs — trust its numbers, do not recompute them. Read what landed and why: attribute movement to a specific angle, hook, format, or timing, never vanity totals. One viral piece is not a strategy. Every finding must end in a concrete next-brief recommendation for Content Strategy. Copy the summary's dataSufficiency verdict/note through unchanged; if it is "none" or "thin", say so plainly, scale confidence down, and do NOT fabricate a trend. ${AUDIENCE}`,
|
|
113
|
-
schema: obj({
|
|
114
|
-
dataSufficiency: obj({ verdict: str, note: str }, ["verdict", "note"]), // verdict: "none" | "thin" | "adequate"
|
|
115
|
-
pieceReads: arr(obj({ id: str, read: str }, ["id", "read"])), // per-piece: what landed / didn't, and why
|
|
116
|
-
angleRollup: arr(obj({ angle: str, read: str }, ["angle", "read"])), // per-angle rollup read
|
|
117
|
-
recommendations: arr(str), // next-brief signal for Content Strategy
|
|
118
|
-
}, ["dataSufficiency", "pieceReads", "angleRollup", "recommendations"]),
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// All 10 prompt seats, addressable by key (for --seat single-invocation and tests).
|
|
122
|
-
export const SEATS = Object.fromEntries(
|
|
123
|
-
[SHOWRUNNER, STRATEGY, ...PIPELINE, GROWTH].map((s) => [s.key, s])
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// --- dry-run: fabricate schema-shaped stub data, no API call ---------------------------
|
|
127
|
-
export function stub(schema, seatKey) {
|
|
128
|
-
if (schema.type === "string") return `[${seatKey}:sample]`;
|
|
129
|
-
if (schema.type === "boolean") return true;
|
|
130
|
-
if (schema.type === "number") return 5; // e.g. cut.shots[].seconds
|
|
131
|
-
if (schema.type === "array") return [stub(schema.items, seatKey), stub(schema.items, seatKey)];
|
|
132
|
-
if (schema.type === "object") {
|
|
133
|
-
const o = {};
|
|
134
|
-
for (const [k, v] of Object.entries(schema.properties)) o[k] = stub(v, `${seatKey}.${k}`);
|
|
135
|
-
return o;
|
|
136
|
-
}
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function runSeat(seat, userContent) {
|
|
141
|
-
if (DRY) return stub(seat.schema, seat.key);
|
|
142
|
-
const model = MODELS[SEAT_MODEL[seat.key]] || MODELS.sonnet;
|
|
143
|
-
const client = await getClient();
|
|
144
|
-
const res = await client.messages.create({
|
|
145
|
-
model, max_tokens: 16000, system: seat.system,
|
|
146
|
-
output_config: { format: { type: "json_schema", schema: seat.schema } },
|
|
147
|
-
messages: [{ role: "user", content: userContent }],
|
|
148
|
-
});
|
|
149
|
-
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
150
|
-
return JSON.parse(text);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Growth input prep: parse the metrics file text and join published/log.jsonl (if any) for context.
|
|
154
|
-
function growthSummary(raw) {
|
|
155
|
-
const logPath = join("published", "log.jsonl");
|
|
156
|
-
const published = existsSync(logPath)
|
|
157
|
-
? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l))
|
|
158
|
-
: [];
|
|
159
|
-
return summarize(raw, { published });
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// --- single-seat mode ("separately"): run one seat alone, JSON in -> JSON out ----------
|
|
163
|
-
async function runSingleSeat() {
|
|
164
|
-
const key = flags.seat;
|
|
165
|
-
const seat = SEATS[key];
|
|
166
|
-
if (!seat) {
|
|
167
|
-
console.error(`Unknown seat '${key}'. Seats: ${Object.keys(SEATS).join(", ")}`);
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
if (!flags.input || flags.input === true) {
|
|
171
|
-
console.error(`--seat ${key} needs --input <file-or-json-or-text>`);
|
|
172
|
-
process.exit(1);
|
|
173
|
-
}
|
|
174
|
-
// Input: a file path (read it), or an inline JSON/text string (use as-is).
|
|
175
|
-
let userContent = String(flags.input);
|
|
176
|
-
if (existsSync(userContent)) userContent = readFileSync(userContent, "utf8");
|
|
177
|
-
let out;
|
|
178
|
-
if (key === "growth") {
|
|
179
|
-
// Deterministic metrics module runs FIRST; the model gets the computed summary, never raw rows.
|
|
180
|
-
const summary = growthSummary(userContent);
|
|
181
|
-
console.error(`[metrics.mjs] ${summary.pieces.length} piece(s), sufficiency=${summary.dataSufficiency.verdict}`);
|
|
182
|
-
if (!DRY && summary.dataSufficiency.verdict === "none") {
|
|
183
|
-
// 0 rows: the honest read is deterministic — don't spend a model call restating it.
|
|
184
|
-
out = { dataSufficiency: summary.dataSufficiency, pieceReads: [], angleRollup: [], recommendations: [] };
|
|
185
|
-
} else {
|
|
186
|
-
out = await runSeat(seat, `Deterministic metrics summary (computed by metrics.mjs; trust these numbers):\n${JSON.stringify(summary, null, 2)}`);
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
out = await runSeat(seat, userContent);
|
|
190
|
-
}
|
|
191
|
-
const json = JSON.stringify(out, null, 2);
|
|
192
|
-
if (flags.out && flags.out !== true) {
|
|
193
|
-
writeFileSync(flags.out, json);
|
|
194
|
-
console.error(`${DRY ? "[DRY-RUN] " : ""}[${seat.title}] ${SEAT_MODEL[key]} -> ${flags.out}`);
|
|
195
|
-
} else {
|
|
196
|
-
if (DRY) console.error(`[DRY-RUN] [${seat.title}] ${SEAT_MODEL[key]}`);
|
|
197
|
-
console.log(json);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function main() {
|
|
202
|
-
if (flags.seat) return runSingleSeat();
|
|
203
|
-
if (!format || !RISK[format]) {
|
|
204
|
-
console.error(`Usage: node orchestrator.mjs <format> <count> [--client=x] [--tier=y] [--dry-run]\n formats: ${Object.keys(RISK).join(", ")}`);
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
208
|
-
const outDir = join("inbox", date);
|
|
209
|
-
mkdirSync(outDir, { recursive: true });
|
|
210
|
-
console.log(`${DRY ? "[DRY-RUN] " : ""}format=${format} count=${count} client=${flags.client || "-"} tier=${flags.tier || "-"}`);
|
|
211
|
-
|
|
212
|
-
const showrunner = await runSeat(SHOWRUNNER, `Format: ${format}. Set voice + continuity guardrails.`);
|
|
213
|
-
console.log(`[Showrunner] ${SEAT_MODEL.showrunner} ok`);
|
|
214
|
-
const board = await runSeat(STRATEGY, `Format: ${format}. Count: ${count}. Voice: ${showrunner.voiceGuardrails}. Produce ${count} distinct angles.`);
|
|
215
|
-
console.log(`[Strategy] ${SEAT_MODEL.strategy} ${board.pieces.length} angles`);
|
|
216
|
-
|
|
217
|
-
for (let i = 0; i < Math.min(count, board.pieces.length); i++) {
|
|
218
|
-
const piece = board.pieces[i];
|
|
219
|
-
const slug = DRY ? `dryrun-${i + 1}` : piece.slug;
|
|
220
|
-
console.log(`\n=== ${slug} — ${piece.angle} ===`);
|
|
221
|
-
const packet = {
|
|
222
|
-
slug, format, client: flags.client || null, tier: flags.tier || null,
|
|
223
|
-
angle: piece.angle, why: piece.why,
|
|
224
|
-
voiceGuardrails: showrunner.voiceGuardrails, continuityNotes: showrunner.continuityNotes,
|
|
225
|
-
createdAt: date,
|
|
226
|
-
};
|
|
227
|
-
let context = `Format: ${format}. Angle: ${piece.angle}. Why: ${piece.why}. Voice: ${showrunner.voiceGuardrails}.`;
|
|
228
|
-
for (const seat of PIPELINE) {
|
|
229
|
-
const out = await runSeat(seat, `${context}\n\nWork so far: ${JSON.stringify(packet)}`);
|
|
230
|
-
packet[seat.key] = out;
|
|
231
|
-
console.log(` [${seat.title}] ${SEAT_MODEL[seat.key]} ok`);
|
|
232
|
-
if (out.script) context += `\nScript: ${out.script}`;
|
|
233
|
-
}
|
|
234
|
-
const held = RISK[format] === "hold" || (packet.qc?.blockers?.length ?? 0) > 0;
|
|
235
|
-
packet.approval = held ? "hold" : "auto";
|
|
236
|
-
const file = join(outDir, `${slug}.json`);
|
|
237
|
-
writeFileSync(file, JSON.stringify(packet, null, 2));
|
|
238
|
-
console.log(` -> ${file} [${packet.approval.toUpperCase()}]`);
|
|
239
|
-
}
|
|
240
|
-
console.log(`\nDone. Packets in ${outDir}/. Next: review → generate clips → producer.mjs assembles.`);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Only auto-run when executed directly (lets tests `import { SEATS }` without side effects).
|
|
244
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
245
|
-
main().catch((e) => { console.error(e); process.exit(1); });
|
|
246
|
-
}
|
package/content-org/package.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "content-org",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"description": "AI content production org — local MVP orchestrator (text pipeline)",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"produce": "node orchestrator.mjs",
|
|
9
|
-
"test": "node test.mjs"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@anthropic-ai/sdk": "^0.68.0"
|
|
13
|
-
}
|
|
14
|
-
}
|
package/content-org/producer.mjs
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// content-org — Producer: turns an inbox packet + clips (+ optional voice) into a Reel.
|
|
2
|
-
// Stages clips -> runs studio/assemble.sh (FFmpeg) -> queue/<slug>/{<slug>.mp4, manifest.json}.
|
|
3
|
-
//
|
|
4
|
-
// Run: node producer.mjs <path-to-packet.json>
|
|
5
|
-
//
|
|
6
|
-
// Inputs it expects (each is its own upstream step, kept out of this file):
|
|
7
|
-
// clips/<slug>/*.mp4 — Kayla generates (flat sub) OR a clip-gen API writes them here.
|
|
8
|
-
// studio/voice/vo.mp3 — optional narration; generate with your voice step first
|
|
9
|
-
// (ElevenLabs or Kokoro) — see SERVICE_SOP.md. Absent -> silent cut.
|
|
10
|
-
//
|
|
11
|
-
// CONCURRENCY: run at most 2 producers at once (FFmpeg is the only CPU-heavy step);
|
|
12
|
-
// set FFMPEG_THREADS=3 to cap each. See CONTENT_ENGINE_TECHNICAL_PLAN.md section 5.
|
|
13
|
-
|
|
14
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync, renameSync, rmSync } from "node:fs";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { execFileSync } from "node:child_process";
|
|
17
|
-
|
|
18
|
-
const packetPath = process.argv[2];
|
|
19
|
-
if (!packetPath || !existsSync(packetPath)) {
|
|
20
|
-
console.error("Usage: node producer.mjs <path-to-packet.json>");
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
|
-
const packet = JSON.parse(readFileSync(packetPath, "utf8"));
|
|
24
|
-
const slug = packet.slug;
|
|
25
|
-
const clipsDir = join("clips", slug);
|
|
26
|
-
const outDir = join("queue", slug);
|
|
27
|
-
mkdirSync(outDir, { recursive: true });
|
|
28
|
-
|
|
29
|
-
// Clips: expect clips/<slug>/*.mp4 (manual, or written by a clip-gen step).
|
|
30
|
-
// CLIP-GEN INTEGRATION POINT: drive a fal.ai call (Veo hero / Wan bulk) from the Video
|
|
31
|
-
// Editor's shot list, writing numbered mp4s into clipsDir. Kept manual for now.
|
|
32
|
-
const haveClips = existsSync(clipsDir) && readdirSync(clipsDir).filter((f) => f.endsWith(".mp4")).length > 0;
|
|
33
|
-
if (!haveClips) {
|
|
34
|
-
console.error("[clips] none in " + clipsDir + "/ — drop or generate clips there, then re-run.");
|
|
35
|
-
writeFileSync(join(outDir, "manifest.json"), JSON.stringify({ ...packet, status: "awaiting-clips" }, null, 2));
|
|
36
|
-
process.exit(2);
|
|
37
|
-
}
|
|
38
|
-
const voiceNote = existsSync("studio/voice/vo.mp3") ? "vo.mp3 present" : "no vo.mp3 (silent cut)";
|
|
39
|
-
console.log("[voice] " + voiceNote);
|
|
40
|
-
|
|
41
|
-
// Staging must hold ONLY this piece's clips — leftovers from a prior slug end up in the cut.
|
|
42
|
-
rmSync("studio/clips", { recursive: true, force: true });
|
|
43
|
-
mkdirSync("studio/clips", { recursive: true });
|
|
44
|
-
readdirSync(clipsDir).filter((f) => f.endsWith(".mp4")).sort().forEach((f) => copyFileSync(join(clipsDir, f), join("studio/clips", f)));
|
|
45
|
-
|
|
46
|
-
try {
|
|
47
|
-
execFileSync("bash", ["studio/assemble.sh", slug], { stdio: "inherit", env: { ...process.env } });
|
|
48
|
-
const rendered = join("studio/output", slug + ".mp4");
|
|
49
|
-
if (existsSync(rendered)) renameSync(rendered, join(outDir, slug + ".mp4"));
|
|
50
|
-
} catch (e) {
|
|
51
|
-
console.error("[assemble] failed: " + e.message);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const ready = existsSync(join(outDir, slug + ".mp4"));
|
|
55
|
-
writeFileSync(
|
|
56
|
-
join(outDir, "manifest.json"),
|
|
57
|
-
JSON.stringify({ ...packet, reel: ready ? slug + ".mp4" : null, voiceNote, status: ready ? "ready-for-approval" : "assemble-failed" }, null, 2)
|
|
58
|
-
);
|
|
59
|
-
console.log("[producer] " + slug + ": " + (ready ? "ready-for-approval" : "assemble-failed") + " (approval=" + packet.approval + ")");
|
package/content-org/publish.mjs
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
// content-org — Publish approved Reels. `node publish.mjs <slug> [<slug>...]`
|
|
2
|
-
// Moves a reviewed Reel from queue/ to a published/ append-only log. The actual post to
|
|
3
|
-
// Facebook/Instagram is a marked integration point (needs Meta Business Suite creds).
|
|
4
|
-
import { existsSync, readdirSync, readFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
|
|
7
|
-
const slugs = process.argv.slice(2);
|
|
8
|
-
if (!slugs.length) { console.error("Usage: node publish.mjs <slug> [<slug>...] (or 'auto' to publish all auto-approved)"); process.exit(1); }
|
|
9
|
-
mkdirSync("published", { recursive: true });
|
|
10
|
-
|
|
11
|
-
let list = slugs;
|
|
12
|
-
if (slugs[0] === "auto") {
|
|
13
|
-
list = readdirSync("queue").filter((s) => {
|
|
14
|
-
const mf = join("queue", s, "manifest.json");
|
|
15
|
-
if (!existsSync(mf)) return false;
|
|
16
|
-
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
17
|
-
return m.approval === "auto" && (m.qc?.blockers || []).filter(Boolean).length === 0 && m.reel;
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const stampNote = "(timestamp added by publish step at runtime)";
|
|
22
|
-
for (const slug of list) {
|
|
23
|
-
const mf = join("queue", slug, "manifest.json");
|
|
24
|
-
if (!existsSync(mf)) { console.error(`skip ${slug}: no manifest`); continue; }
|
|
25
|
-
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
26
|
-
if (m.approval === "hold") { console.error(`skip ${slug}: HOLD — needs explicit approval, run with the slug to override`); }
|
|
27
|
-
|
|
28
|
-
// POSTING INTEGRATION POINT: call Meta Business Suite / Graph API here to publish
|
|
29
|
-
// queue/<slug>/<reel>.mp4 with m.feed.caption to the client's page. Left unwired.
|
|
30
|
-
const entry = { slug, client: m.client, tier: m.tier, format: m.format, reel: m.reel, caption: m.feed?.caption, note: "logged; Meta post not wired " + stampNote };
|
|
31
|
-
appendFileSync(join("published", "log.jsonl"), JSON.stringify(entry) + "\n");
|
|
32
|
-
console.log(`published (logged): ${slug} [${m.client || "-"}/${m.tier || "-"}]`);
|
|
33
|
-
}
|
|
34
|
-
console.log(`\n${list.length} logged to published/log.jsonl. Wire the Meta post at the marked integration point to go live.`);
|
package/content-org/review.mjs
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// content-org — Review queue. `node review.mjs`
|
|
2
|
-
// Lists finished Reels awaiting Kayla, showing tiered-approval status + any QC blockers.
|
|
3
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
const Q = "queue";
|
|
7
|
-
if (!existsSync(Q)) { console.log("queue/ empty — run orchestrator.mjs then producer.mjs first."); process.exit(0); }
|
|
8
|
-
|
|
9
|
-
const rows = [];
|
|
10
|
-
for (const slug of readdirSync(Q)) {
|
|
11
|
-
const mf = join(Q, slug, "manifest.json");
|
|
12
|
-
if (!existsSync(mf)) continue;
|
|
13
|
-
const m = JSON.parse(readFileSync(mf, "utf8"));
|
|
14
|
-
rows.push({
|
|
15
|
-
slug, tier: m.tier || "-", client: m.client || "-",
|
|
16
|
-
status: m.status || "?", approval: m.approval || "?",
|
|
17
|
-
blockers: (m.qc?.blockers || []).filter(Boolean).length,
|
|
18
|
-
reel: m.reel ? "yes" : "no",
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
if (!rows.length) { console.log("No manifests in queue/."); process.exit(0); }
|
|
22
|
-
|
|
23
|
-
console.log("slug".padEnd(22), "client".padEnd(10), "tier".padEnd(8), "approval".padEnd(9), "reel", "blockers", "status");
|
|
24
|
-
for (const r of rows) {
|
|
25
|
-
console.log(
|
|
26
|
-
r.slug.padEnd(22), String(r.client).padEnd(10), String(r.tier).padEnd(8),
|
|
27
|
-
r.approval.padEnd(9), (r.reel + " ").padEnd(4), String(r.blockers).padStart(3).padEnd(9), r.status
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
const holds = rows.filter((r) => r.approval === "hold" || r.blockers > 0);
|
|
31
|
-
console.log(`\n${rows.length} in queue · ${holds.length} need your yes (hold or blocked) · rest auto-postable.`);
|
|
32
|
-
console.log("Approve → node publish.mjs <slug> [<slug>...]");
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# The Traveler — Studio (FFmpeg assembly pipeline)
|
|
2
|
-
|
|
3
|
-
**Locked stack:** Kling (clips, you) → ElevenLabs (voice, Claude) → **FFmpeg (edit, Claude)**.
|
|
4
|
-
Anti-slop = consistent character + Claude-controlled edit, never a preset.
|
|
5
|
-
|
|
6
|
-
## How it works
|
|
7
|
-
1. **You** generate clips in Kling and drop them in `clips/` — **named in order**: `01.mp4`, `02.mp4`, …
|
|
8
|
-
2. Drop the voiceover in `voice/vo.mp3` (Claude can generate this via ElevenLabs).
|
|
9
|
-
3. Drop a music bed in `music/bed.mp3` (royalty-free, e.g. Epidemic).
|
|
10
|
-
4. Captions go in `captions/captions.ass` (Claude writes these from the script).
|
|
11
|
-
5. Claude runs `assemble.sh` → finished vertical Reel lands in `output/`.
|
|
12
|
-
|
|
13
|
-
## CLIP DELIVERY RULES (critical — so it doesn't look like slop)
|
|
14
|
-
- **Silent** (no Kling auto-audio) — Claude owns the audio layer.
|
|
15
|
-
- **No baked-in captions / no auto-music / no watermark.**
|
|
16
|
-
- Vertical, highest resolution Kling allows; consistent across clips.
|
|
17
|
-
- **Same locked character reference image every clip** (the recurring face = the moat).
|
|
18
|
-
- Name strictly in play order: `01.mp4`, `02.mp4`, …
|
|
19
|
-
|
|
20
|
-
## Folders
|
|
21
|
-
- `clips/` — raw Kling footage (you)
|
|
22
|
-
- `voice/` — `vo.mp3` narrator (Claude/ElevenLabs)
|
|
23
|
-
- `music/` — `bed.mp3` (you, or Mubert)
|
|
24
|
-
- `captions/` — `captions.ass` (Claude)
|
|
25
|
-
- `output/` — finished Reels (Claude)
|
|
26
|
-
|
|
27
|
-
`assemble.sh` is v1 — tuned further once we see real Kling footage.
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# The Traveler — FFmpeg assembly pipeline (v1)
|
|
3
|
-
# Usage: ./assemble.sh [episode_name]
|
|
4
|
-
# Reads clips/*.mp4 (in name order) + voice/vo.mp3 + music/bed.mp3 + captions/captions.ass
|
|
5
|
-
# Outputs a vertical 1080x1920 Reel to output/<episode_name>.mp4
|
|
6
|
-
# Anti-slop layer: uniform grade + film grain + subtle vignette so heterogeneous clips read as ONE piece.
|
|
7
|
-
set -euo pipefail
|
|
8
|
-
|
|
9
|
-
EP="${1:-episode}"
|
|
10
|
-
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
|
11
|
-
CLIPS="$ROOT/clips"; VOICE="$ROOT/voice/vo.mp3"; MUSIC="$ROOT/music/bed.mp3"
|
|
12
|
-
CAPS="$ROOT/captions/captions.ass"; OUT="$ROOT/output/$EP.mp4"; TMP="$ROOT/.tmp"
|
|
13
|
-
W=1080; H=1920; FPS=30
|
|
14
|
-
mkdir -p "$TMP" "$ROOT/output"
|
|
15
|
-
|
|
16
|
-
# 1) Normalize every clip to uniform res/fps/codec (reliable concat across mixed Kling outputs)
|
|
17
|
-
i=0; : > "$TMP/list.txt"
|
|
18
|
-
for f in $(ls "$CLIPS"/*.mp4 2>/dev/null | sort); do
|
|
19
|
-
i=$((i+1)); n=$(printf "%03d" "$i")
|
|
20
|
-
ffmpeg -y -i "$f" -vf "scale=$W:$H:force_original_aspect_ratio=increase,crop=$W:$H,fps=$FPS" \
|
|
21
|
-
-c:v libx264 -preset medium -crf 18 -an "$TMP/n_$n.mp4"
|
|
22
|
-
# Windows fix: write the entry relative to list.txt (concat demuxer resolves it from
|
|
23
|
-
# the list's own directory). Absolute Git-Bash paths (/c/...) break native ffmpeg.exe.
|
|
24
|
-
echo "file 'n_$n.mp4'" >> "$TMP/list.txt"
|
|
25
|
-
done
|
|
26
|
-
[ "$i" -gt 0 ] || { echo "No clips in $CLIPS (name them 01.mp4, 02.mp4 ...)"; exit 1; }
|
|
27
|
-
|
|
28
|
-
# 2) Concatenate normalized clips (video only)
|
|
29
|
-
ffmpeg -y -f concat -safe 0 -i "$TMP/list.txt" -c copy "$TMP/video.mp4"
|
|
30
|
-
|
|
31
|
-
# 3) Grade + grain + vignette (cohesion / anti-slop) and burn captions if present
|
|
32
|
-
GRADE="eq=contrast=1.06:saturation=1.05:gamma=0.98,noise=alls=8:allf=t+u,vignette=PI/5"
|
|
33
|
-
if [ -f "$CAPS" ]; then VF="$GRADE,subtitles='$CAPS'"; else VF="$GRADE"; fi
|
|
34
|
-
ffmpeg -y -i "$TMP/video.mp4" -vf "$VF" -c:v libx264 -preset medium -crf 18 -an "$TMP/graded.mp4"
|
|
35
|
-
|
|
36
|
-
# 4) Mix audio: narrator full + music bed ducked under it
|
|
37
|
-
if [ -f "$VOICE" ] && [ -f "$MUSIC" ]; then
|
|
38
|
-
ffmpeg -y -i "$TMP/graded.mp4" -i "$VOICE" -i "$MUSIC" -filter_complex \
|
|
39
|
-
"[2:a]volume=0.18[m];[1:a][m]amix=inputs=2:duration=first:dropout_transition=2[a]" \
|
|
40
|
-
-map 0:v -map "[a]" -c:v copy -c:a aac -shortest "$OUT"
|
|
41
|
-
elif [ -f "$VOICE" ]; then
|
|
42
|
-
ffmpeg -y -i "$TMP/graded.mp4" -i "$VOICE" -map 0:v -map 1:a -c:v copy -c:a aac -shortest "$OUT"
|
|
43
|
-
else
|
|
44
|
-
cp "$TMP/graded.mp4" "$OUT"; echo "(no voice/vo.mp3 — output is silent)"
|
|
45
|
-
fi
|
|
46
|
-
|
|
47
|
-
rm -rf "$TMP"
|
|
48
|
-
echo "Done -> $OUT"
|
package/content-org/test.mjs
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// content-org — no-dependency test script (plain node, no vitest).
|
|
2
|
-
// Run: npm test
|
|
3
|
-
// Covers: (1) seat schema validation for all 10 seats in dry-run,
|
|
4
|
-
// (2) single-seat CLI mode, (3) review/publish queue round-trip.
|
|
5
|
-
// The queue round-trip runs in a scratch dir so it never touches the real queue/.
|
|
6
|
-
|
|
7
|
-
import { execFileSync } from "node:child_process";
|
|
8
|
-
import { mkdirSync, writeFileSync, readFileSync, rmSync, mkdtempSync } from "node:fs";
|
|
9
|
-
import { join, dirname } from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { tmpdir } from "node:os";
|
|
12
|
-
import { SEATS, stub } from "./orchestrator.mjs";
|
|
13
|
-
import { SEAT_MODEL, MODELS } from "./config.mjs";
|
|
14
|
-
|
|
15
|
-
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
let pass = 0, fail = 0;
|
|
17
|
-
function check(name, ok, detail = "") {
|
|
18
|
-
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
19
|
-
else { fail++; console.error(`FAIL ${name}${detail ? " — " + detail : ""}`); }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Minimal validator for the subset of JSON Schema the seats use.
|
|
23
|
-
function validate(value, schema, path = "$") {
|
|
24
|
-
const errs = [];
|
|
25
|
-
if (schema.type === "string") { if (typeof value !== "string") errs.push(`${path}: not a string`); return errs; }
|
|
26
|
-
if (schema.type === "boolean") { if (typeof value !== "boolean") errs.push(`${path}: not a boolean`); return errs; }
|
|
27
|
-
if (schema.type === "number") { if (typeof value !== "number") errs.push(`${path}: not a number`); return errs; }
|
|
28
|
-
if (schema.type === "array") {
|
|
29
|
-
if (!Array.isArray(value)) { errs.push(`${path}: not an array`); return errs; }
|
|
30
|
-
value.forEach((v, i) => errs.push(...validate(v, schema.items, `${path}[${i}]`)));
|
|
31
|
-
return errs;
|
|
32
|
-
}
|
|
33
|
-
if (schema.type === "object") {
|
|
34
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) { errs.push(`${path}: not an object`); return errs; }
|
|
35
|
-
for (const req of schema.required || []) if (!(req in value)) errs.push(`${path}.${req}: missing (required)`);
|
|
36
|
-
for (const [k, v] of Object.entries(value)) {
|
|
37
|
-
if (!schema.properties[k]) { if (schema.additionalProperties === false) errs.push(`${path}.${k}: unexpected property`); continue; }
|
|
38
|
-
errs.push(...validate(v, schema.properties[k], `${path}.${k}`));
|
|
39
|
-
}
|
|
40
|
-
return errs;
|
|
41
|
-
}
|
|
42
|
-
errs.push(`${path}: unknown schema type ${schema.type}`);
|
|
43
|
-
return errs;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// --- 1) All 9 seats: dry-run stub output conforms to each seat's own schema ------------
|
|
47
|
-
console.log("\n[1] seat schemas (dry-run stubs) — 10 seats");
|
|
48
|
-
const EXPECTED_SEATS = ["showrunner", "strategy", "ideator", "research", "script", "editor", "cut", "feed", "qc", "growth"];
|
|
49
|
-
check("all 10 seats registered", EXPECTED_SEATS.every((k) => SEATS[k]) && Object.keys(SEATS).length === 10,
|
|
50
|
-
`have: ${Object.keys(SEATS).join(",")}`);
|
|
51
|
-
for (const key of EXPECTED_SEATS) {
|
|
52
|
-
const seat = SEATS[key];
|
|
53
|
-
if (!seat) { check(`seat ${key}`, false, "missing"); continue; }
|
|
54
|
-
const out = stub(seat.schema, key);
|
|
55
|
-
const errs = validate(out, seat.schema);
|
|
56
|
-
check(`stub(${key}) matches schema`, errs.length === 0, errs.join("; "));
|
|
57
|
-
check(`${key} routes to a real model`, !!MODELS[SEAT_MODEL[key]], `SEAT_MODEL.${key}=${SEAT_MODEL[key]}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// --- 2) Single-seat CLI mode (dry-run, no API key needed) ------------------------------
|
|
61
|
-
console.log("\n[2] single-seat CLI mode");
|
|
62
|
-
for (const key of ["showrunner", "qc"]) {
|
|
63
|
-
let out, parsed = null, errs = ["did not run"];
|
|
64
|
-
try {
|
|
65
|
-
out = execFileSync(process.execPath, ["orchestrator.mjs", "--seat", key, "--input", '{"format":"scam-reveal"}', "--dry-run"],
|
|
66
|
-
{ cwd: ROOT, encoding: "utf8" });
|
|
67
|
-
parsed = JSON.parse(out);
|
|
68
|
-
errs = validate(parsed, SEATS[key].schema);
|
|
69
|
-
} catch (e) { errs = [e.message]; }
|
|
70
|
-
check(`--seat ${key} --dry-run emits schema-valid JSON`, parsed !== null && errs.length === 0, errs.join("; "));
|
|
71
|
-
}
|
|
72
|
-
// unknown seat must exit non-zero
|
|
73
|
-
let badExit = false;
|
|
74
|
-
try { execFileSync(process.execPath, ["orchestrator.mjs", "--seat", "nope", "--input", "x", "--dry-run"], { cwd: ROOT, stdio: "pipe" }); }
|
|
75
|
-
catch { badExit = true; }
|
|
76
|
-
check("--seat with unknown key exits non-zero", badExit);
|
|
77
|
-
|
|
78
|
-
// --- 3) review/publish queue round-trip (scratch dir; real queue/ untouched) -----------
|
|
79
|
-
console.log("\n[3] review/publish queue round-trip");
|
|
80
|
-
const scratch = mkdtempSync(join(tmpdir(), "content-org-test-"));
|
|
81
|
-
try {
|
|
82
|
-
const slug = "test-piece";
|
|
83
|
-
mkdirSync(join(scratch, "queue", slug), { recursive: true });
|
|
84
|
-
const manifest = {
|
|
85
|
-
slug, format: "nostalgia", client: "testco", tier: "starter",
|
|
86
|
-
approval: "auto", reel: `${slug}.mp4`, status: "ready-for-approval",
|
|
87
|
-
qc: { blockers: [] }, feed: { caption: "test caption" },
|
|
88
|
-
};
|
|
89
|
-
writeFileSync(join(scratch, "queue", slug, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
90
|
-
|
|
91
|
-
const reviewOut = execFileSync(process.execPath, [join(ROOT, "review.mjs")], { cwd: scratch, encoding: "utf8" });
|
|
92
|
-
check("review.mjs lists the queued piece", reviewOut.includes(slug) && reviewOut.includes("1 in queue"));
|
|
93
|
-
check("review.mjs shows auto approval", /auto/.test(reviewOut));
|
|
94
|
-
|
|
95
|
-
execFileSync(process.execPath, [join(ROOT, "publish.mjs"), slug], { cwd: scratch, encoding: "utf8" });
|
|
96
|
-
const log = readFileSync(join(scratch, "published", "log.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
|
97
|
-
check("publish.mjs appends to published/log.jsonl", log.length === 1 && log[0].slug === slug);
|
|
98
|
-
check("published entry carries caption + reel", log[0].caption === "test caption" && log[0].reel === `${slug}.mp4`);
|
|
99
|
-
check("published entry marks Meta post as not wired", /not wired/.test(log[0].note || ""));
|
|
100
|
-
|
|
101
|
-
// 'auto' mode publishes only clean auto-approved pieces
|
|
102
|
-
execFileSync(process.execPath, [join(ROOT, "publish.mjs"), "auto"], { cwd: scratch, encoding: "utf8" });
|
|
103
|
-
const log2 = readFileSync(join(scratch, "published", "log.jsonl"), "utf8").trim().split("\n");
|
|
104
|
-
check("publish.mjs auto mode picks up the auto-approved piece", log2.length === 2);
|
|
105
|
-
} finally {
|
|
106
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
console.log(`\n${pass} passed, ${fail} failed`);
|
|
110
|
-
process.exit(fail ? 1 : 0);
|