@aicopycoders/exodus 2026.9.1400-beta.1 → 2026.9.1400
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 +0 -11
- package/assets/docs/CLAUDE.md +1 -3
- package/assets/docs/PIPELINES.md +0 -22
- package/assets/skills/exodus/SKILL.md +0 -2
- package/assets/skills/exodus-winners/SKILL.md +17 -65
- package/dist/bin/exodus.js +0 -5
- package/dist/commands/swipe.js +1 -8
- package/dist/commands/winners.d.ts +0 -50
- package/dist/commands/winners.js +2 -175
- package/package.json +1 -1
- package/assets/skills/exodus-ads/SKILL.md +0 -165
- package/assets/skills/exodus-video/SKILL.md +0 -248
- package/assets/skills/exodus-video/scripts/first-cut.mjs +0 -568
- package/dist/commands/ads.d.ts +0 -50
- package/dist/commands/ads.js +0 -414
- package/dist/commands/comments.d.ts +0 -28
- package/dist/commands/comments.js +0 -198
- package/dist/commands/video.d.ts +0 -226
- package/dist/commands/video.js +0 -1200
- package/dist/lib/meta-ads.d.ts +0 -108
- package/dist/lib/meta-ads.js +0 -156
package/README.md
CHANGED
|
@@ -49,17 +49,6 @@ health, CLI currency — and tells you exactly what to fix.
|
|
|
49
49
|
- `npx -y @aicopycoders/exodus@beta hooks --help` — browse the Organic Scout
|
|
50
50
|
hook library: filter by score, lane, validation status and more; explain a
|
|
51
51
|
score; find cards sharing a hook pattern; export to CSV or JSON. Read-only.
|
|
52
|
-
- `npx -y @aicopycoders/exodus@beta ads --help` — read your own live Meta ads
|
|
53
|
-
once the ad account is connected on the dashboard: `ads list` sorts them by
|
|
54
|
-
cost per result, spend or CTR (lifetime, or `--since 90d`), and
|
|
55
|
-
`ads show <adId>` prints the copy, both windows, the media links and the
|
|
56
|
-
comment counts. Read-only — no Meta login needed.
|
|
57
|
-
- `npx -y @aicopycoders/exodus@beta comments --help` — read the comments on
|
|
58
|
-
those ads, on Facebook and Instagram. Commenter names and links are stripped
|
|
59
|
-
before storage; you get the words only. Read-only.
|
|
60
|
-
- `npx -y @aicopycoders/exodus@beta winners definition` — what this brand means
|
|
61
|
-
by "a winner" for one ad account: the campaign roles, the rule, the dials and
|
|
62
|
-
the last run's summary, exactly as confirmed on the dashboard.
|
|
63
52
|
|
|
64
53
|
Inside Claude Code, just say **"exodus"** plus what you want
|
|
65
54
|
("exodus, write me some ads") and the skills route the rest.
|
package/assets/docs/CLAUDE.md
CHANGED
|
@@ -61,12 +61,10 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
|
|
|
61
61
|
| **Meme** | `exodus-meme` | Meme ads — recommends formats for a brief, then one batched run renders AI-image and classic Imgflip-template memes server-side. |
|
|
62
62
|
| **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve checkpoint/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
|
|
63
63
|
| **Hooks** | `exodus-hooks` | Read the Scout library — the hook cards captured from Instagram outliers. Compose filters (score, lane, validation status, language, hook type, recency), inspect one card, find cards sharing a hook pattern, export to CSV. Read-only. |
|
|
64
|
-
| **Video** | `exodus-video` | Make a video ad from a Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, upload the cut and approve it. Admin-only. |
|
|
65
64
|
| **Browse** | `exodus-browse` | View history and retrieve past outputs |
|
|
66
65
|
| **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
|
|
67
66
|
| **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds without starting research, #1366), `swipe run` / `swipe mine`. There is no `competitors` verb. |
|
|
68
|
-
| **
|
|
69
|
-
| **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import`. A brand whose Meta account is connected reads its ads and its winner definition straight from Exodus; only an unconnected brand needs the Meta Ads MCP. |
|
|
67
|
+
| **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads (via the Meta Ads MCP) and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import` |
|
|
70
68
|
|
|
71
69
|
For the operator-facing quick reference on how to invoke these in Claude Code, see `PIPELINES.md` in the workspace root.
|
|
72
70
|
|
package/assets/docs/PIPELINES.md
CHANGED
|
@@ -16,7 +16,6 @@ In Claude Code you don't click buttons. You say **"exodus"** plus what you want
|
|
|
16
16
|
| exodus-meme | Meme Ad Generator — recommend formats, one batched server-side run |
|
|
17
17
|
| exodus-workflow | Run/build saved multi-node workflows, resolve inbox gate/repair parks, continue sessions, fire triggers, read banks + promote winners |
|
|
18
18
|
| exodus-hooks | Read the Scout library of hook cards — composable filters, one-card detail, pattern matches, CSV export (read-only) |
|
|
19
|
-
| exodus-video | Make a video ad from a Show — start, storyboard gate, pull every piece + manifest, first cut, upload, approve (admin-only) |
|
|
20
19
|
| exodus-browse | View History and Surface the Right Run |
|
|
21
20
|
| exodus-drive | Google Drive, Docs, Sheets via the Dashboard's OAuth |
|
|
22
21
|
| exodus-winners | Mine your own Meta ad account for winners (needs the Meta Ads MCP) and import them into Exodus |
|
|
@@ -243,27 +242,6 @@ Returns: a table, --json data, or an exported CSV/JSON file
|
|
|
243
242
|
|
|
244
243
|
---
|
|
245
244
|
|
|
246
|
-
## exodus-video
|
|
247
|
-
|
|
248
|
-
**What it does:** Makes a video ad from a locked Show, in pieces. The run writes the storyboard, draws a picture per scene, records a voice per scene, renders a clip per scene and composes a music bed, then parks for a cut. The CLI pulls the pieces to a folder with a `manifest.json`, the bundled script makes a plain cut, and the upload attaches it for approval. Admin-only.
|
|
249
|
-
|
|
250
|
-
```operator-guide
|
|
251
|
-
Commands:
|
|
252
|
-
exodus video shows [--json] Shows and whether each is ready
|
|
253
|
-
exodus video start --show <id> --script <file> [--wait] [--json] start an ad run; --wait parks at the storyboard gate
|
|
254
|
-
exodus video storyboard <runId> [--json] the scene cards
|
|
255
|
-
exodus video approve <runId> [--json] approve the storyboard, or the uploaded cut
|
|
256
|
-
exodus video flag <runId> --note "<what is wrong>" [--json] send the storyboard back
|
|
257
|
-
exodus video status <runId> [--json] the park and each scene's clip/voice/picture
|
|
258
|
-
exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
|
|
259
|
-
exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
|
|
260
|
-
Cut:
|
|
261
|
-
node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
|
|
262
|
-
Returns: park lines with the exact next command; pull writes files + manifest.json
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
---
|
|
266
|
-
|
|
267
245
|
## exodus-browse
|
|
268
246
|
|
|
269
247
|
**What it does:** View past pipeline runs and pull outputs from previous sessions.
|
|
@@ -25,10 +25,8 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
|
|
|
25
25
|
| Onboard a new brand / build the primer | `exodus-primer` (with winning ads) or `exodus-foundation` (no ads yet) |
|
|
26
26
|
| List/switch/troubleshoot brands | `exodus-brand` |
|
|
27
27
|
| Read the Scout hook library — "what do my 15×+ hooks have in common", "show me my Spanish on-screen hooks", "why did that reel score 22×", "export my hooks to a spreadsheet" | `exodus-hooks` (read-only; promoting a card is a dashboard act) |
|
|
28
|
-
| Make a video ad, or anything about a Show or a video run — "start a video ad from this script", "is my video run done", "pull the clips and cut them", "upload my cut", "what's wrong with scene 3" (`exodus video`) | `exodus-video` (admin-only on the dashboard; it drives start → storyboard → pull → cut → upload) |
|
|
29
28
|
| See past runs / find an output | `exodus-browse` |
|
|
30
29
|
| Open or create Google Docs/Sheets | `exodus-drive` |
|
|
31
|
-
| Read their OWN live Meta ads and their numbers — "what are my cheapest ads", "where is my spend going", "show me that ad", "what are people saying in the comments on my ads" (`exodus ads` / `exodus comments`) | `exodus-ads` (read-only; already synced by the dashboard, no Meta MCP needed) |
|
|
32
30
|
| Import their OWN winning ads from their Meta ad account ("import my winners", "mine my ad account for winners", "exodus winners") | `exodus-winners` (it checks first whether the brand's Meta account is connected — if it is, it reads `exodus ads` and `exodus winners definition` instead of a Meta MCP) |
|
|
33
31
|
| Figure out WHO to sell to, segments, personas, "map the market" | `odf` (or `positioning-spiral` when no coherent positioning exists yet) |
|
|
34
32
|
| The product's mechanism, "why does this work", "which ingredient leads" | `mechanism` |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: exodus-winners
|
|
3
|
-
description: Mine the member's OWN Meta ad account for its winning ads and import them into Exodus as generative fuel.
|
|
3
|
+
description: Mine the member's OWN Meta ad account for its winning ads and import them into Exodus as generative fuel. The member's Claude Code session holds BOTH the official Meta Ads MCP (their Facebook login, their machine) and the Exodus CLI; this skill orchestrates the whole journey — point them at the Meta MCP, pick one ad account, run a NO-NUMBERS winner-definition interview, mine the account via the MCP, confirm each pick visually, then push a winner-package JSON with `npx @aicopycoders/exodus winners import` and run the two-phase video gap-filler. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request ("exodus, import my winning ads", "exodus winners", "exodus, mine my ad account for winners", "exodus, which of my own ads are winners"), named this skill or /exodus-winners, ran an `npx @aicopycoders/exodus winners` command, or the `exodus` hub skill routed here. Never claim generic asks ("what are my best ads", "pull my Meta ads", "analyze my Facebook ads") without Exodus context — in shared folders those may belong to the user's other tools; if the user did not say exodus, this skill is not for them. This is the member's OWN account's winners, NOT competitor swipes — competitor swipe mining lives on the dashboard's Swipe Mining surfaces, not here. The bare word "Genesis" without "exodus" refers to the member's own Genesis API key and personal bot recipes, NOT to Exodus.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```operator-guide
|
|
@@ -8,7 +8,6 @@ Subcommands:
|
|
|
8
8
|
exodus winners import <file.json | -> [--dry-run] [--no-wait] [--json] Push a winner package (- reads stdin)
|
|
9
9
|
exodus winners status <importId> [--json] Re-poll an import later
|
|
10
10
|
exodus winners list [--json] Winners Exodus already holds
|
|
11
|
-
exodus winners definition [--account act_…] [--json] What THIS brand means by "a winner"
|
|
12
11
|
|
|
13
12
|
Key flags:
|
|
14
13
|
--dry-run Local schema check + server dry-run: would-create vs would-update per winner,
|
|
@@ -24,49 +23,27 @@ Facts baked into the CLI:
|
|
|
24
23
|
upgrade in place; winners absent from a re-push are untouched. It DOES re-run the
|
|
25
24
|
billed match scrape + enrichment — push updates, don't push as a retry reflex.
|
|
26
25
|
|
|
27
|
-
Meta side
|
|
28
|
-
|
|
29
|
-
returns ads): NO MCP. Ads + numbers come from `exodus ads list` / `ads show`, comments from
|
|
30
|
-
`exodus comments list`, the definition from `exodus winners definition` (read-only — the
|
|
31
|
-
definition lives on the server, never in state/own-brand-winners.json).
|
|
32
|
-
• Brand WITHOUT one: the official Meta Ads MCP (https://mcp.facebook.com/ads) supplies all
|
|
33
|
-
account data, exactly as §1–§5 describe.
|
|
34
|
-
Either way this skill NEVER calls the Meta Graph API directly and NEVER calls ads_library_search.
|
|
26
|
+
Meta side: the official Meta Ads MCP (https://mcp.facebook.com/ads) supplies all account data.
|
|
27
|
+
This skill NEVER calls the Meta Graph API directly and NEVER calls ads_library_search.
|
|
35
28
|
```
|
|
36
29
|
|
|
37
30
|
# Winners — Mine Your Own Ad Account and Import the Champions
|
|
38
31
|
|
|
39
|
-
This is the agent-side journey for **Own-Brand Winners**: turn the member's real Meta ad account into a set of designated winners that Exodus stores as generative fuel.
|
|
32
|
+
This is the agent-side journey for **Own-Brand Winners**: turn the member's real Meta ad account into a set of designated winners that Exodus stores as generative fuel. The session holds the member's **own Meta Ads MCP** (their Facebook OAuth) alongside the Exodus CLI — you drive both.
|
|
40
33
|
|
|
41
34
|
The member is a **creative strategist, not a media buyer**. This entire journey is data *collection and preparation* — the analysis happens later, server-side, on the stored winners. So the interview **never asks a single numeric question**: no budgets, no ROAS targets, no thresholds, no "top how many?". You infer the mechanics and confirm in plain language. Make that rule impossible to break — if you catch yourself about to ask for a number, stop and infer it instead.
|
|
42
35
|
|
|
43
|
-
The arc, in order: **
|
|
36
|
+
The arc, in order: **MCP check → pick one account → winner-definition interview → mine via MCP → 30k-foot view → visual confirmation → write the package → dry-run → import → video gap-filler.** Persist the definition so re-mining re-asks nothing already settled.
|
|
44
37
|
|
|
45
|
-
## 0.
|
|
38
|
+
## 0. Meta Ads MCP must be connected
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
npx @aicopycoders/exodus winners definition
|
|
51
|
-
npx @aicopycoders/exodus ads list --limit 5
|
|
52
|
-
```
|
|
53
|
-
|
|
54
|
-
**If either answers with real data — a definition, or ads — this brand has a Meta integration. Skip the MCP entirely.** The dashboard already syncs this account daily, so everything §1–§5 mines by hand is already sitting in Exodus:
|
|
55
|
-
|
|
56
|
-
- **Ads and numbers** come from `npx @aicopycoders/exodus ads list` / `ads show <adId>` (the `exodus-ads` skill has the full surface). No `ads_get_ad_entities` paging, no campaign-by-campaign loop, no 500-avoidance mechanics — none of §3 applies.
|
|
57
|
-
- **The winner definition** comes from `npx @aicopycoders/exodus winners definition`. Do **not** re-run the interview: a human already answered it on the dashboard, and asking again invites two different answers for one account.
|
|
58
|
-
- **Comments** come from `npx @aicopycoders/exodus comments list`.
|
|
59
|
-
- The Meta Ads MCP may still sit alongside for extras (a live preview image, today's numbers before tomorrow's sync). It is **not required**, and you must not ask the user to connect it just to read their own ads.
|
|
60
|
-
|
|
61
|
-
**If neither answers — no integration — this brand keeps the MCP path below, exactly as written.** Everything here reads from the **official Meta Ads MCP**: a remote MCP server at `https://mcp.facebook.com/ads`, OAuth'd in the user's browser with their own Facebook login. We don't own that flow; there is exactly one setup pointer and no troubleshooting beyond it.
|
|
40
|
+
Everything here reads from the **official Meta Ads MCP** — a remote MCP server at `https://mcp.facebook.com/ads`, OAuth'd in the user's browser with their own Facebook login. We don't own that flow; there is exactly one setup pointer and no troubleshooting beyond it.
|
|
62
41
|
|
|
63
42
|
**If the `ads_*` Meta tools are not present in this session, stop.** Tell the user to add `https://mcp.facebook.com/ads` as a remote MCP server and complete the browser OAuth, then come back. Do not attempt any workaround, any Graph API call, or any other connection method.
|
|
64
43
|
|
|
65
44
|
## 1. Account selection — the required first step, pinned forever
|
|
66
45
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
**Unconnected brand:** call `ads_get_ad_accounts`. It returns **every** account the login can touch — often a dozen-plus across several businesses. For each: `ad_account_id`, `ad_account_name`, `business_name`, `is_queryable`, `not_queryable_reason`, currency.
|
|
46
|
+
Call `ads_get_ad_accounts`. It returns **every** account the login can touch — often a dozen-plus across several businesses. For each: `ad_account_id`, `ad_account_name`, `business_name`, `is_queryable`, `not_queryable_reason`, currency.
|
|
70
47
|
|
|
71
48
|
- Show only **queryable** accounts (`is_queryable: true`). For any flagged one, surface its `not_queryable_reason` so the user understands why it's absent — don't silently drop it.
|
|
72
49
|
- **Account names can be empty strings** — fall back to the id and business name so the list is still legible.
|
|
@@ -78,15 +55,7 @@ You are not collecting preferences here so much as confirming inferences. Four t
|
|
|
78
55
|
|
|
79
56
|
**a) Objective → metric is invisible.** Ads group by their **self-labeled result type** (within objective — §2c); each group's success metric is the account's own label — Meta hands it to you already labeled (`results: {"value": "98 (Website appointments scheduled)"}`, `cost_per_result: {"value": "$91.64 USD (Website appointments scheduled)"}` — the string lives under `.value`). **Never ask the user to pick a metric.** The account already declared it. Offer one free-text escape hatch only — *"If your definition of a winner is different from 'the ads that drove the most results,' tell me in your own words."* — and record whatever they say **verbatim** as `customDefinition`, applied agent-side. Do not turn their words into a number.
|
|
80
57
|
|
|
81
|
-
**b)
|
|
82
|
-
|
|
83
|
-
- **testing** — where new creative goes to be tried. Small budgets, many near-duplicate ads, short lives.
|
|
84
|
-
- **scaling** — where proven creative goes to be spent behind. Fewer ads, bigger budgets, longer lives.
|
|
85
|
-
- **other** — retargeting, retention, brand, anything that is neither of the above.
|
|
86
|
-
|
|
87
|
-
Infer each one from the campaign objective plus the campaign/adset **names** (naming is read-only creative context; see §6). Present the whole proposed map **once** for a single confirm/adjust pass, then save it. On a re-mine, only **new** campaigns get proposed; already-confirmed ones stay put.
|
|
88
|
-
|
|
89
|
-
Why roles and not awareness levels: a testing campaign and a scaling campaign are **not comparable**, and the winner rule has to know which is which before it ranks anything — an ad that "won" a test at 30 results is not the same object as an ad carrying a scaling budget. This is also the exact question the dashboard's winner setup asks, in the same three words, so the CLI and the dashboard can never propose different winners for one account. **A brand with a Meta integration has already answered it** — read the confirmed map from `npx @aicopycoders/exodus winners definition` (§0) instead of asking again.
|
|
58
|
+
**b) Funnel mapping is infer-then-confirm.** Propose, per campaign, an **awareness level** from the Schwartz five — `unaware` / `problem-aware` / `solution-aware` / `product-aware` / `most-aware` — inferred from the campaign objective plus the campaign/adset **names** (naming is read-only creative context; see §6). Present the whole proposed map **once** for a single confirm/adjust pass, then save it. On a re-mine, only **new** campaigns get proposed; already-mapped ones stay put.
|
|
90
59
|
|
|
91
60
|
**c) The winner rule is fixed — volume over efficiency, all-time, no caps.** State it, don't negotiate it:
|
|
92
61
|
|
|
@@ -103,9 +72,7 @@ The defaults behind this rule (`window: "maximum"`, `resultsFloor: 10`, `contrib
|
|
|
103
72
|
|
|
104
73
|
## 3. Mining mechanics — the MCP facts that make or break this
|
|
105
74
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
**Unconnected brand:** mine on `ads_get_ad_entities`. These are hard-won live-probe facts; encode them into how you call, not into a doc you can look up later:
|
|
75
|
+
Mine on `ads_get_ad_entities`. These are hard-won live-probe facts; encode them into how you call, not into a doc you can look up later:
|
|
109
76
|
|
|
110
77
|
- **Never mine account-wide — scope every metrics pull to one campaign.** Account-wide ad-level pulls that include `results` **500 consistently** on real accounts (mixed objectives, thousands of near-duplicate test ads) — no limit, no sort tweak, and no retry unsticks them, and `results` 500s at campaign level too. Enumerate campaigns first (attributes, `objective`, and spend all work fine at campaign level), then pull ads **campaign-by-campaign** with `results` over `date_preset: "maximum"` and aggregate agent-side into result-type groups (ads carry `campaign_id`; join `objective` from the campaign pull).
|
|
111
78
|
- **Sort server-side, tally compactly — never hold full blobs, and never floor raw rows.** Metric *filtering* on `ads_get_ad_entities` consistently **500s** — never filter by a metric. Metric *sorting* works. Per campaign: sort by **results descending**, page through, and retain one **compact tuple per result-bearing ad** — ad id, ad name, campaign id, creative id, created time, results, spend — discarding zero-result rows (they can't move any sum) and every other field. **The 10-result floor does NOT apply while paging**: sub-floor instances of one post can sum to a winner (§2c), so no row is ever dropped for being small. A creative-testing account runs 100+ near-duplicate ads per campaign; compact tuples keep the whole account to a few hundred short lines, where retaining or printing whole per-campaign blobs burns the session's context before the winner rule ever runs.
|
|
@@ -137,7 +104,7 @@ The instance-to-creative collapse, the format skew, the count of winners, the sh
|
|
|
137
104
|
|
|
138
105
|
## 5. Visual confirmation — the human gate
|
|
139
106
|
|
|
140
|
-
This is a HITL gate; treat it like one. For each proposed winner,
|
|
107
|
+
This is a HITL gate; treat it like one. For each proposed winner, render it with **`ads_get_ad_preview`** (an actual in-context creative image) and show it. The strategist:
|
|
141
108
|
|
|
142
109
|
- **strikes** picks that don't belong,
|
|
143
110
|
- **adds** any obvious winner the rule missed,
|
|
@@ -147,7 +114,7 @@ Their edits win over the algorithm. Don't argue the rule against their eyes —
|
|
|
147
114
|
|
|
148
115
|
## 6. Naming is read-only
|
|
149
116
|
|
|
150
|
-
Campaign / adset / ad names feed two things: your **
|
|
117
|
+
Campaign / adset / ad names feed two things: your **awareness inference** (§2b) and the winner's `sourceNames` as creative context. That's all. **No naming-conventions guidance** — nobody is asked to rename anything, and the account's naming hygiene is not your concern.
|
|
151
118
|
|
|
152
119
|
## 7. The verdict snapshot & the winner-package JSON
|
|
153
120
|
|
|
@@ -185,7 +152,7 @@ Every winner carries a **verdict snapshot** composed to the schema below. Single
|
|
|
185
152
|
"costPerResult": "$18.20",
|
|
186
153
|
"asOf": "2026-07-14",
|
|
187
154
|
"objective": "OUTCOME_SALES",
|
|
188
|
-
"awarenessLevel": "product-aware", //
|
|
155
|
+
"awarenessLevel": "product-aware", // from the confirmed mapping (§2b)
|
|
189
156
|
"selectionBasis": ["overall", "top-video"], // which cut(s) it cleared
|
|
190
157
|
"strategistNote": "…" // optional, from §5
|
|
191
158
|
},
|
|
@@ -235,25 +202,12 @@ Any winner the user can't supply a file for **stays amber and visible** in `exod
|
|
|
235
202
|
|
|
236
203
|
**5. Re-push safety** (from the CLI itself): re-pushing never duplicates rows — verdict snapshots replace wholesale, gap-filled rows upgrade in place, winners absent from a re-push are left untouched. But it is not free: every push re-uploads referenced assets, re-runs enrichment, and re-fires the Scrape Creators match scrape (billed to the member) — so re-push when there's something to update, not as a retry reflex.
|
|
237
204
|
|
|
238
|
-
## 9. Definition persistence
|
|
239
|
-
|
|
240
|
-
**Which of the two applies is settled in §0**, and the two must never both hold a definition for the same account.
|
|
241
|
-
|
|
242
|
-
**Brand WITH a Meta integration — the definition lives on the server. Full stop.**
|
|
243
|
-
|
|
244
|
-
```bash
|
|
245
|
-
npx @aicopycoders/exodus winners definition # one connected account
|
|
246
|
-
npx @aicopycoders/exodus winners definition --account act_1234567890
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
That command reads the confirmed answers a human gave on the dashboard: the pinned account, the campaign-role map (§2b), the rule variant, the dials, and the last run's summary. It is **read-only, and there is no CLI write** — you do not create, patch, or mirror `state/own-brand-winners.json` for these brands, and you never re-run the §2 interview to "fill in" anything. Changes are made on the dashboard (Settings → Meta → winner setup); a second copy in a local file is exactly how the CLI and the dashboard would drift into two different answers for one account.
|
|
250
|
-
|
|
251
|
-
If it answers with a list of accounts instead of a definition, the brand has several connected — ask which one the member means and pass `--account`. If it says no definition exists yet, point them at the dashboard's winner setup; don't improvise one locally.
|
|
205
|
+
## 9. Definition persistence
|
|
252
206
|
|
|
253
|
-
|
|
207
|
+
Persist the interview to `state/own-brand-winners.json`, keyed by **brand slug** (`state/` survives skill refreshes). It holds:
|
|
254
208
|
|
|
255
209
|
- pinned `adAccountId` + name (§1),
|
|
256
|
-
- `
|
|
210
|
+
- `campaignAwarenessMap` (§2b),
|
|
257
211
|
- editable `defaults` — `{ "window": "maximum", "resultsFloor": 10, "contributionLine": 0.80 }`,
|
|
258
212
|
- `customDefinition` (the §2a escape-hatch text, verbatim),
|
|
259
213
|
- `lastMinedAt`.
|
|
@@ -262,9 +216,7 @@ Re-running re-asks **nothing already pinned** — only genuinely new campaigns g
|
|
|
262
216
|
|
|
263
217
|
## Failure handling
|
|
264
218
|
|
|
265
|
-
- **Meta `ads_*` tools not in the session** —
|
|
266
|
-
- **`exodus winners definition` lists ad accounts instead of a definition** — the brand has several connected. Ask which one, then re-run with `--account act_…`. Never pick for them silently.
|
|
267
|
-
- **`exodus winners definition` says no definition exists** — nobody has finished winner setup for that account. Point at the dashboard (Settings → Meta → winner setup); do not build a local one for an integrated brand (§9).
|
|
219
|
+
- **Meta `ads_*` tools not in the session** — stop and point at `https://mcp.facebook.com/ads` (§0). No workaround exists here.
|
|
268
220
|
- **`ads_get_ad_entities` 500 with a metric `filtering`** — that's the known filter bug, not a transient. Drop the filter, sort instead, threshold agent-side (§3).
|
|
269
221
|
- **`ads_get_ad_entities` 500 on an account-wide (or campaign-level) `results` pull** — structural, not transient; retries never help. Scope to one campaign per call and aggregate agent-side (§3).
|
|
270
222
|
- **Any tool 500s once on a scoped call** — retry the identical call once before reporting it.
|
package/dist/bin/exodus.js
CHANGED
|
@@ -23,15 +23,12 @@ const OTHER_COMMANDS = new Set([
|
|
|
23
23
|
"session",
|
|
24
24
|
"bank",
|
|
25
25
|
"hooks",
|
|
26
|
-
"ads",
|
|
27
|
-
"comments",
|
|
28
26
|
]);
|
|
29
27
|
const HIDDEN_COMMANDS = new Set([
|
|
30
28
|
"creative",
|
|
31
29
|
"template",
|
|
32
30
|
"idea",
|
|
33
31
|
"swipe",
|
|
34
|
-
"video",
|
|
35
32
|
]);
|
|
36
33
|
const EXAMPLES = [
|
|
37
34
|
'exodus genesis run --brief "joint pain relief in 30 days" --variants 6',
|
|
@@ -44,8 +41,6 @@ const EXAMPLES = [
|
|
|
44
41
|
'exodus meme run --brief "grounding sheets reduce inflammation" --formats \'[...]\'',
|
|
45
42
|
'exodus browse',
|
|
46
43
|
'exodus hooks list --min-score 10',
|
|
47
|
-
'exodus ads list --sort cost-per-result',
|
|
48
|
-
'exodus comments list --ad <adId>',
|
|
49
44
|
'exodus status --id <runId> --type genesis',
|
|
50
45
|
];
|
|
51
46
|
function installedCommands() {
|
package/dist/commands/swipe.js
CHANGED
|
@@ -307,14 +307,7 @@ async function runBrandsAdd(positional, flags) {
|
|
|
307
307
|
console.log(formatError(res));
|
|
308
308
|
process.exit(1);
|
|
309
309
|
}
|
|
310
|
-
|
|
311
|
-
console.log(`Added "${name}". Finding its Ad Library page and mining its ads now.`);
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
console.log(`Added "${name}".`);
|
|
315
|
-
if (res.data.warning)
|
|
316
|
-
console.log(` ${res.data.warning}`);
|
|
317
|
-
}
|
|
310
|
+
console.log(`Added "${name}".`);
|
|
318
311
|
}
|
|
319
312
|
async function runBrandsRemove(positional) {
|
|
320
313
|
const id = positional[0];
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type AdAccountRef } from "../lib/meta-ads.js";
|
|
2
1
|
export declare const helpText: string;
|
|
3
2
|
export interface LocalValidation {
|
|
4
3
|
ok: boolean;
|
|
@@ -9,52 +8,3 @@ export interface LocalValidation {
|
|
|
9
8
|
export declare function validatePackageLocally(pkg: unknown): LocalValidation;
|
|
10
9
|
export declare function contentTypeFor(filePath: string): string;
|
|
11
10
|
export declare function run(flags: Record<string, string | boolean>): Promise<void>;
|
|
12
|
-
export declare const VALUE_FLAGS: Set<string>;
|
|
13
|
-
export declare function parsePositional(args?: string[]): string[];
|
|
14
|
-
export interface WinnerDefinitionView {
|
|
15
|
-
accountId?: string | null;
|
|
16
|
-
campaignRoleMap?: Record<string, string> | null;
|
|
17
|
-
ruleVariant?: string | null;
|
|
18
|
-
otherDefinition?: string | null;
|
|
19
|
-
ignoredCampaignIds?: string[] | null;
|
|
20
|
-
defaults?: {
|
|
21
|
-
window?: string | null;
|
|
22
|
-
resultsFloor?: number | null;
|
|
23
|
-
contributionLine?: number | null;
|
|
24
|
-
} | null;
|
|
25
|
-
lastAppliedAt?: number | null;
|
|
26
|
-
setupCompletedAt?: number | null;
|
|
27
|
-
summary?: WinnerDefinitionSummary | null;
|
|
28
|
-
}
|
|
29
|
-
export interface WinnerDefinitionSummaryGroup {
|
|
30
|
-
resultLabel?: string | null;
|
|
31
|
-
objective?: string | null;
|
|
32
|
-
instanceCount?: number | null;
|
|
33
|
-
creativeCount?: number | null;
|
|
34
|
-
totalResults?: number | null;
|
|
35
|
-
winnerCount?: number | null;
|
|
36
|
-
winnerShare?: number | null;
|
|
37
|
-
videoWinners?: number | null;
|
|
38
|
-
imageWinners?: number | null;
|
|
39
|
-
flatCurve?: boolean | null;
|
|
40
|
-
}
|
|
41
|
-
export interface WinnerDefinitionSummary {
|
|
42
|
-
instanceCount?: number | null;
|
|
43
|
-
creativeCount?: number | null;
|
|
44
|
-
winnerCount?: number | null;
|
|
45
|
-
groups?: WinnerDefinitionSummaryGroup[] | null;
|
|
46
|
-
ignoredCampaignCount?: number | null;
|
|
47
|
-
computedAt?: number | null;
|
|
48
|
-
}
|
|
49
|
-
export interface WinnerDefinitionResponse {
|
|
50
|
-
account?: AdAccountRef | null;
|
|
51
|
-
definition?: WinnerDefinitionView | null;
|
|
52
|
-
accounts?: AdAccountRef[];
|
|
53
|
-
}
|
|
54
|
-
export declare function roleTally(map: Record<string, string> | null | undefined): {
|
|
55
|
-
total: number;
|
|
56
|
-
line: string;
|
|
57
|
-
};
|
|
58
|
-
export declare function sharePercent(value: unknown): string;
|
|
59
|
-
export declare function summaryLines(summary: WinnerDefinitionSummary | null | undefined): string[];
|
|
60
|
-
export declare function formatDefinition(data: WinnerDefinitionResponse): string[];
|
package/dist/commands/winners.js
CHANGED
|
@@ -3,7 +3,6 @@ import path from "node:path";
|
|
|
3
3
|
import { apiGet, apiPost } from "../lib/client.js";
|
|
4
4
|
import { displayRunStatus, formatError, tickerRunStatus } from "../lib/format.js";
|
|
5
5
|
import { pollUntilDone } from "../lib/poll.js";
|
|
6
|
-
import { accountLine, accountRequiredLines, count as formatCount, dateOrDash, errorCode, } from "../lib/meta-ads.js";
|
|
7
6
|
export const helpText = `
|
|
8
7
|
exodus winners — Import your own brand's winning ads as generative fuel
|
|
9
8
|
|
|
@@ -17,14 +16,6 @@ Usage:
|
|
|
17
16
|
exodus winners import <file.json | -> Push a winner package (- reads stdin)
|
|
18
17
|
exodus winners status <importId> Re-poll an import later
|
|
19
18
|
exodus winners list Winners Exodus already holds
|
|
20
|
-
exodus winners definition [--account act_…] What THIS brand means by "a winner"
|
|
21
|
-
|
|
22
|
-
Definition flags:
|
|
23
|
-
--account <id> Which connected ad account, e.g. act_1234567890. Required
|
|
24
|
-
only when the brand has more than one connected — a
|
|
25
|
-
definition read off the wrong account is a wrong answer that
|
|
26
|
-
looks right.
|
|
27
|
-
--json Machine-readable JSON output
|
|
28
19
|
|
|
29
20
|
Import flags:
|
|
30
21
|
--dry-run Local schema check + server dry-run: reports would-create vs
|
|
@@ -34,10 +25,6 @@ Import flags:
|
|
|
34
25
|
|
|
35
26
|
Notes:
|
|
36
27
|
• Scopes to your active brand's workspace (exodus brand current).
|
|
37
|
-
• \`definition\` reads the answers a human already gave on the dashboard — it
|
|
38
|
-
never writes, and it never shows a machine guess nobody has confirmed. A
|
|
39
|
-
brand with a Meta integration keeps its definition there, not in a local
|
|
40
|
-
file. Its ads read back with \`exodus ads list\`.
|
|
41
28
|
• Requires your Scrape Creators API key (Settings → Keys) — the own-page
|
|
42
29
|
match scrape bills your account.
|
|
43
30
|
• Re-pushing the same file is safe: no duplicate rows, the verdict snapshot
|
|
@@ -51,8 +38,6 @@ Examples:
|
|
|
51
38
|
cat winners.json | exodus winners import -
|
|
52
39
|
exodus winners status k97abc...
|
|
53
40
|
exodus winners list
|
|
54
|
-
exodus winners definition
|
|
55
|
-
exodus winners definition --account act_1234567890 --json
|
|
56
41
|
`.trim();
|
|
57
42
|
export function validatePackageLocally(pkg) {
|
|
58
43
|
const errors = [];
|
|
@@ -131,8 +116,6 @@ export async function run(flags) {
|
|
|
131
116
|
return runStatus(rest, flags);
|
|
132
117
|
if (sub === "list")
|
|
133
118
|
return runList(flags);
|
|
134
|
-
if (sub === "definition")
|
|
135
|
-
return runDefinition(flags);
|
|
136
119
|
if (!sub) {
|
|
137
120
|
console.log(helpText);
|
|
138
121
|
return;
|
|
@@ -141,24 +124,8 @@ export async function run(flags) {
|
|
|
141
124
|
console.log(helpText);
|
|
142
125
|
process.exit(1);
|
|
143
126
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const out = [];
|
|
147
|
-
let i = 0;
|
|
148
|
-
while (i < args.length) {
|
|
149
|
-
const arg = args[i];
|
|
150
|
-
if (arg.startsWith("--")) {
|
|
151
|
-
const key = arg.slice(2).split("=", 1)[0] ?? "";
|
|
152
|
-
if (!arg.includes("=") && VALUE_FLAGS.has(key))
|
|
153
|
-
i += 2;
|
|
154
|
-
else
|
|
155
|
-
i++;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
out.push(arg);
|
|
159
|
-
i++;
|
|
160
|
-
}
|
|
161
|
-
return out;
|
|
127
|
+
function parsePositional() {
|
|
128
|
+
return process.argv.slice(3).filter((a) => !a.startsWith("--"));
|
|
162
129
|
}
|
|
163
130
|
async function runImport(positional, flags) {
|
|
164
131
|
const json = !!flags["json"];
|
|
@@ -442,143 +409,3 @@ async function runList(flags) {
|
|
|
442
409
|
console.log(` ${w.verdictSentence}`);
|
|
443
410
|
}
|
|
444
411
|
}
|
|
445
|
-
const RULE_VARIANTS = {
|
|
446
|
-
standard: "standard — the smallest set of creatives that together carry most of a result group's results",
|
|
447
|
-
efficiency: "efficiency — the creatives with the best cost per result in each group",
|
|
448
|
-
"ignore-campaigns": "standard, with some campaigns deliberately left out of the count",
|
|
449
|
-
other: "written out in the brand's own words (below)",
|
|
450
|
-
};
|
|
451
|
-
export function roleTally(map) {
|
|
452
|
-
const entries = map && typeof map === "object" ? Object.values(map) : [];
|
|
453
|
-
const counts = new Map();
|
|
454
|
-
for (const role of entries) {
|
|
455
|
-
if (typeof role !== "string" || !role.trim())
|
|
456
|
-
continue;
|
|
457
|
-
const key = role.trim();
|
|
458
|
-
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
459
|
-
}
|
|
460
|
-
const order = ["testing", "scaling", "other"];
|
|
461
|
-
const parts = [...counts.entries()]
|
|
462
|
-
.sort((a, b) => {
|
|
463
|
-
const ai = order.indexOf(a[0]);
|
|
464
|
-
const bi = order.indexOf(b[0]);
|
|
465
|
-
return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi);
|
|
466
|
-
})
|
|
467
|
-
.map(([role, n]) => `${n} ${role}`);
|
|
468
|
-
return { total: entries.length, line: parts.join(" · ") || "none confirmed yet" };
|
|
469
|
-
}
|
|
470
|
-
export function sharePercent(value) {
|
|
471
|
-
if (typeof value !== "number" || !Number.isFinite(value))
|
|
472
|
-
return "—";
|
|
473
|
-
const pct = value <= 1 ? value * 100 : value;
|
|
474
|
-
return `${Math.round(pct)}%`;
|
|
475
|
-
}
|
|
476
|
-
export function summaryLines(summary) {
|
|
477
|
-
if (!summary) {
|
|
478
|
-
return ["Last run", " (the rule has not been run over this account yet)"];
|
|
479
|
-
}
|
|
480
|
-
const lines = [`Last run — ${dateOrDash(summary.computedAt)}`];
|
|
481
|
-
lines.push(` ${formatCount(summary.instanceCount)} ad instances → ${formatCount(summary.creativeCount)} distinct creatives · ${formatCount(summary.winnerCount)} winners`);
|
|
482
|
-
const groups = Array.isArray(summary.groups) ? summary.groups : [];
|
|
483
|
-
for (const group of groups) {
|
|
484
|
-
const label = group.resultLabel?.trim() || group.objective?.trim() || "(unlabelled group)";
|
|
485
|
-
lines.push(` ${label}: ${formatCount(group.winnerCount)} of ${formatCount(group.creativeCount)} creatives carry ${sharePercent(group.winnerShare)} of ${formatCount(group.totalResults)} results (${formatCount(group.videoWinners)} video / ${formatCount(group.imageWinners)} image)`);
|
|
486
|
-
if (group.flatCurve) {
|
|
487
|
-
lines.push(" flat curve — results spread evenly, so there is no clean winner set here; treat these as top contributors, not outliers.");
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
if (typeof summary.ignoredCampaignCount === "number" && summary.ignoredCampaignCount > 0) {
|
|
491
|
-
lines.push(` ${summary.ignoredCampaignCount} campaign(s) were left out of the count.`);
|
|
492
|
-
}
|
|
493
|
-
return lines;
|
|
494
|
-
}
|
|
495
|
-
export function formatDefinition(data) {
|
|
496
|
-
const accounts = Array.isArray(data.accounts) ? data.accounts : [];
|
|
497
|
-
const account = data.account ?? null;
|
|
498
|
-
const definition = data.definition ?? null;
|
|
499
|
-
if (!account) {
|
|
500
|
-
if (accounts.length === 0) {
|
|
501
|
-
return [
|
|
502
|
-
"No Meta ad account is connected to this brand.",
|
|
503
|
-
"Connect one on the dashboard (Settings → Meta) — then the daily sync fills in the ads and this definition.",
|
|
504
|
-
];
|
|
505
|
-
}
|
|
506
|
-
const lines = ["Pick which ad account you mean:", ...accounts.map(accountLine)];
|
|
507
|
-
const first = accounts[0]?.accountId;
|
|
508
|
-
if (first) {
|
|
509
|
-
lines.push("");
|
|
510
|
-
lines.push(` exodus winners definition --account ${first}`);
|
|
511
|
-
}
|
|
512
|
-
return lines;
|
|
513
|
-
}
|
|
514
|
-
const label = account.name?.trim()
|
|
515
|
-
? `${account.name.trim()} (${account.accountId ?? "?"})`
|
|
516
|
-
: String(account.accountId ?? "?");
|
|
517
|
-
if (!definition) {
|
|
518
|
-
return [
|
|
519
|
-
`No winner definition for ${label} yet.`,
|
|
520
|
-
"Someone has to say what a winner means for this account before the rule can run — that happens on the dashboard (Settings → Meta → winner setup).",
|
|
521
|
-
];
|
|
522
|
-
}
|
|
523
|
-
const lines = [];
|
|
524
|
-
lines.push(`Winner definition — ${label}`);
|
|
525
|
-
lines.push(` setup: ${definition.setupCompletedAt ? `confirmed ${dateOrDash(definition.setupCompletedAt)}` : "not finished yet"}`);
|
|
526
|
-
const variant = definition.ruleVariant?.trim() ?? "";
|
|
527
|
-
lines.push(` rule: ${RULE_VARIANTS[variant] ?? (variant || "—")}`);
|
|
528
|
-
const defaults = definition.defaults ?? {};
|
|
529
|
-
lines.push(` dials: window ${defaults.window ?? "—"} · results floor ${formatCount(defaults.resultsFloor)} · contribution line ${sharePercent(defaults.contributionLine)}`);
|
|
530
|
-
const roles = roleTally(definition.campaignRoleMap);
|
|
531
|
-
lines.push(` campaigns: ${roles.total} with a confirmed role — ${roles.line}`);
|
|
532
|
-
const ignored = Array.isArray(definition.ignoredCampaignIds) ? definition.ignoredCampaignIds : [];
|
|
533
|
-
if (ignored.length > 0) {
|
|
534
|
-
lines.push(` ignored: ${ignored.length} campaign(s) left out of the rule`);
|
|
535
|
-
}
|
|
536
|
-
if (definition.lastAppliedAt) {
|
|
537
|
-
lines.push(` last run: ${dateOrDash(definition.lastAppliedAt)}`);
|
|
538
|
-
}
|
|
539
|
-
if (definition.otherDefinition?.trim()) {
|
|
540
|
-
lines.push("");
|
|
541
|
-
lines.push("In the brand's own words");
|
|
542
|
-
for (const line of definition.otherDefinition.trim().split("\n"))
|
|
543
|
-
lines.push(` ${line}`);
|
|
544
|
-
}
|
|
545
|
-
lines.push("");
|
|
546
|
-
lines.push(...summaryLines(definition.summary));
|
|
547
|
-
if (accounts.length > 1) {
|
|
548
|
-
lines.push("");
|
|
549
|
-
lines.push(`This brand has ${accounts.length} connected accounts — each keeps its own definition (--account).`);
|
|
550
|
-
}
|
|
551
|
-
return lines;
|
|
552
|
-
}
|
|
553
|
-
async function runDefinition(flags) {
|
|
554
|
-
const json = !!flags["json"];
|
|
555
|
-
const accountRaw = flags["account"];
|
|
556
|
-
if (accountRaw !== undefined && (typeof accountRaw !== "string" || !accountRaw.trim())) {
|
|
557
|
-
console.error("Error: --account needs an ad account id, e.g. act_1234567890");
|
|
558
|
-
console.log("Usage: exodus winners definition [--account act_…] [--json]");
|
|
559
|
-
process.exit(1);
|
|
560
|
-
}
|
|
561
|
-
const account = typeof accountRaw === "string" ? accountRaw.trim() : undefined;
|
|
562
|
-
const query = account ? `?account=${encodeURIComponent(account)}` : "";
|
|
563
|
-
const res = await apiGet(`/api/v2/winners/definition${query}`);
|
|
564
|
-
if (!res.ok) {
|
|
565
|
-
if (json) {
|
|
566
|
-
console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
|
|
567
|
-
process.exit(1);
|
|
568
|
-
}
|
|
569
|
-
if (res.status === 400 && errorCode(res.data) === "ACCOUNT_REQUIRED") {
|
|
570
|
-
for (const line of accountRequiredLines(res, (id) => `exodus winners definition --account ${id}`)) {
|
|
571
|
-
console.log(line);
|
|
572
|
-
}
|
|
573
|
-
process.exit(1);
|
|
574
|
-
}
|
|
575
|
-
console.log(formatError(res));
|
|
576
|
-
process.exit(1);
|
|
577
|
-
}
|
|
578
|
-
if (json) {
|
|
579
|
-
console.log(JSON.stringify({ ok: true, ...res.data }));
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
for (const line of formatDefinition(res.data))
|
|
583
|
-
console.log(line);
|
|
584
|
-
}
|