@wrangled/cli 0.3.23 → 0.3.24

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/dist/guide.js CHANGED
@@ -20,6 +20,10 @@ Run \`${cli} guide\` first. It says, in plain words, what this workspace has. It
20
20
 
21
21
  Before writing new ads or a campaign plan, run \`${cli} brief\`. In one read it gives the offer (from the confirmed offers and funnels map, else the brand notes and landing pages, or it says the offer is unknown), the brand voice, the best ads by spend and by results with their words and a piece of their video transcript, the ads to avoid repeating, the pages those ads send people to, the funnel and top campaigns, who the top Facebook campaigns were aimed at, and what is missing. \`--from\` and \`--to\` pick the dates (default: the last 30 days), \`--channel\` or \`--campaign <id>\` narrow it, and \`--top <n>\` sets how many of each list it shows (default 5, at most 20). It prints plain Markdown to paste into a prompt; \`--format json\` gives the same brief as data. It says what it cut to stay short.
22
22
 
23
+ ## Which Facebook ads won: winners and flops
24
+
25
+ \`${cli} winners\` says which Facebook ads won and which flopped, for one Monday-to-Sunday week and for the 30 days ending that Sunday, judged on real sales: each sale goes to the Facebook ad that first brought the buyer in, and payments typed into GoHighLevel by hand never count toward an ad. An ad with no sales that has spent less than twice the account's cost per sale is too new to judge on sales, so it is judged on cost per sign-up and listed apart. Each ad says in a sentence why it is on its list. \`--week <YYYY-MM-DD>\` picks any day of the week (default: the latest complete week), \`--top <n>\` how many ads each list shows (default 5, at most 20), and \`--format json\` gives the report as data.
26
+
23
27
  ## Content: search before SQL
24
28
 
25
29
  To find content, such as "the 10 emails with the best click rate", "our 5 best videos by return on ad spend" or "every landing page that mentions the guarantee", use \`${cli} content search\` before writing SQL on the \`content_\` tables.
@@ -243,6 +247,7 @@ ${cli} whoami show the current workspace and library acces
243
247
  ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] short, cited snippets from the Creative Cartel know-how library (keys with library access)
244
248
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
245
249
  ${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>] the offer, voice, best and worst ads, pages, results and audience: start here before writing ads
250
+ ${cli} winners [--week <date>] [--top <n>] which Facebook ads won and flopped this week and over 30 days, judged on first-touch sales
246
251
  ${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
247
252
  ${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.ndjson | file.json> saves the rows; add --all for every row
248
253
  ${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
package/dist/main.js CHANGED
@@ -10,6 +10,7 @@ import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
10
10
  import { guide, skill } from "./guide.js";
11
11
  import { PULL_STATE_FILE, pullContent } from "./pull.js";
12
12
  import { AGENTS_MD_MARKER, metaAdsAgentsMd, PLAYBOOK_CHOICES, playbooks } from "./playbooks.js";
13
+ import { OPENROUTER_KEY_ENV } from "./runner/codex.js";
13
14
  import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } from "./runner/run.js";
14
15
  import { SKILL_NAME_PATTERN } from "./skill-zip.js";
15
16
  import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
@@ -609,6 +610,21 @@ const commands = {
609
610
  process.stdout.write(brief.text);
610
611
  },
611
612
  },
613
+ winners: {
614
+ usage: "winners [--week <YYYY-MM-DD>] [--top <n>] (which Facebook ads won and flopped, judged on real first-touch sales)",
615
+ async run(args, options) {
616
+ if (args.length > 0)
617
+ throw new CliError("Usage: winners [--week <date>] [--top <n>]", exitCodes.usage);
618
+ const query = new URLSearchParams();
619
+ for (const [name, value] of Object.entries({ week: options.week, top: options.top }))
620
+ if (value !== undefined)
621
+ query.set(name, value);
622
+ const report = await api(await resolveConnection(options.apiUrl), `/api/v1/winners${query.size ? `?${query}` : ""}`);
623
+ if (options.format === "json")
624
+ return printJson(report);
625
+ process.stdout.write(winnersText(report));
626
+ },
627
+ },
612
628
  examples: {
613
629
  usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
614
630
  async run(args, options) {
@@ -1209,6 +1225,8 @@ const commands = {
1209
1225
  console.log(`Failed for ${failure.adIds.join(", ")}: ${failure.error}`);
1210
1226
  if (result.stopped === "tool_cap")
1211
1227
  console.log("Stopped early: the run's tool spending cap was reached.");
1228
+ if (result.nothingToRead > 0)
1229
+ console.log(`Left out ${plural(result.nothingToRead)} with nothing to read: no copy, headline, transcript or picture description.`);
1212
1230
  },
1213
1231
  },
1214
1232
  "content tags": {
@@ -1693,6 +1711,7 @@ async function withRunner(options, work) {
1693
1711
  apiUrl: resolveApiUrl(options.apiUrl, null),
1694
1712
  token,
1695
1713
  harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
1714
+ openRouterKey: process.env[OPENROUTER_KEY_ENV] || undefined,
1696
1715
  maxSeconds: options.maxSeconds,
1697
1716
  signal: stop.signal,
1698
1717
  say: (line) => console.log(line),
@@ -1769,6 +1788,59 @@ const CAMPAIGN_NUMBERS = [
1769
1788
  "roas_first_touch",
1770
1789
  "roas_last_touch",
1771
1790
  ];
1791
+ function money(value, currency) {
1792
+ try {
1793
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
1794
+ }
1795
+ catch {
1796
+ return `${value.toFixed(2)} ${currency}`;
1797
+ }
1798
+ }
1799
+ const counted = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
1800
+ /** An ad's line: name, SKU and campaign, its numbers, its tags, then why it is on the list. */
1801
+ function winnersAdLines(ad, currency) {
1802
+ const n = ad.numbers;
1803
+ const onSales = ad.judgedOn === "sales";
1804
+ const cost = onSales
1805
+ ? n.costPerSale === null ? "no sales" : `${money(n.costPerSale, currency)} per sale`
1806
+ : n.costPerSignup === null ? "no sign-ups" : `${money(n.costPerSignup, currency)} per sign-up`;
1807
+ const name = [ad.name ?? `Ad ${ad.adId}`, ad.sku && `[${ad.sku}]`, ad.campaignName && `(${ad.campaignName})`].filter(Boolean).join(" ");
1808
+ const tags = Object.entries(ad.tags).map(([field, value]) => `${field}: ${value}`).join(", ");
1809
+ const count = onSales ? n.sales !== null && counted(n.sales, "sale") : n.signups !== null && counted(n.signups, "sign-up");
1810
+ return `- ${name}: spent ${money(n.spend, currency)}, ${count ? `${count}, ` : ""}${cost}${tags ? `. Tags: ${tags}` : ""}\n ${ad.pickedBecause}\n`;
1811
+ }
1812
+ function winnersWindowText(title, window) {
1813
+ const head = `\n## ${title}, ${window.from} to ${window.to}\n`;
1814
+ if (window.sections.length === 0)
1815
+ return `${head}\nNo Facebook ad spent in these dates.\n`;
1816
+ return (head +
1817
+ window.sections
1818
+ .map((s) => {
1819
+ const list = (heading, ads) => `\n${heading}\n${ads.length === 0 ? "- none\n" : ads.map((ad) => winnersAdLines(ad, s.currency)).join("")}`;
1820
+ const account = s.salesSource === "not_connected"
1821
+ ? "sales are not connected"
1822
+ : s.accountCostPerSale === null
1823
+ ? "no sale credited to a Facebook ad in these dates"
1824
+ : `the account paid ${money(s.accountCostPerSale, s.currency)} per sale`;
1825
+ const tests = s.tests.map((t) => `- Test ${t.testId}${t.hypothesisId ? ` (idea ${t.hypothesisId})` : ""}: ${counted(t.ads.length, "ad")}, spent ${money(t.spend, s.currency)}, ${counted(t.sales, "sale")}` +
1826
+ `${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per sale`}\n`);
1827
+ return (`\nIn ${s.currency}: ${account}. ${s.adsJudgedOnSales} judged on sales, ${s.adsJudgedOnSignups} judged on sign-ups.\n` +
1828
+ s.notes.map((note) => `${note}\n`).join("") +
1829
+ list("Winners", s.winners) +
1830
+ list("Flops", s.flops) +
1831
+ list(`${s.onSignups.title}: best`, s.onSignups.best) +
1832
+ list(`${s.onSignups.title}: worst`, s.onSignups.worst) +
1833
+ (tests.length > 0 ? `\nAdzara tests\n${tests.join("")}` : ""));
1834
+ })
1835
+ .join(""));
1836
+ }
1837
+ /** The winners report as plain Markdown: what a sale means, then this week and the last 30 days, each currency apart. */
1838
+ function winnersText(report) {
1839
+ return (`# Winners and flops for ${report.workspace}\n\n` +
1840
+ `A sale means ${report.saleMeans}. Each sale goes to the Facebook ad that first brought the buyer in; payments typed into GoHighLevel by hand never count toward an ad.\n` +
1841
+ winnersWindowText("This week", report.week) +
1842
+ winnersWindowText("Last 30 days", report.last30Days));
1843
+ }
1772
1844
  function reportQuery(options, extra) {
1773
1845
  const query = new URLSearchParams();
1774
1846
  for (const [name, value] of Object.entries({ from: options.from, to: options.to, ...extra }))
@@ -1915,7 +1987,8 @@ Options:
1915
1987
  --channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
1916
1988
  for content track-link, where the link goes: facebook, email, sms or web (default)
1917
1989
  --campaign <id> the campaign content track-link puts in utm_campaign; with brief, the one campaign it covers
1918
- --top <n> how many best ads, worst ads, pages and campaigns brief shows (default: 5, at most 20)
1990
+ --top <n> how many best ads, worst ads, pages and campaigns brief shows, or ads in each winners list (default: 5, at most 20)
1991
+ --week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
1919
1992
  --body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
1920
1993
  --file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
1921
1994
  --transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
@@ -2007,6 +2080,7 @@ function parseOptions(argv) {
2007
2080
  channel: { type: "string" },
2008
2081
  campaign: { type: "string" },
2009
2082
  top: { type: "string" },
2083
+ week: { type: "string" },
2010
2084
  name: { type: "string" },
2011
2085
  subject: { type: "string" },
2012
2086
  preview: { type: "string" },
@@ -2103,6 +2177,7 @@ function parseOptions(argv) {
2103
2177
  channel: values.channel,
2104
2178
  campaign: values.campaign,
2105
2179
  top: values.top,
2180
+ week: values.week,
2106
2181
  name: values.name,
2107
2182
  subject: values.subject,
2108
2183
  preview: values.preview,
package/dist/playbooks.js CHANGED
@@ -864,11 +864,11 @@ ${cli} ads autotag --limit ${BACK_TAG_BATCH} --json > batch.json
864
864
 
865
865
  Each call takes the ${BACK_TAG_BATCH} biggest-spending ads with no tags yet, so the next call moves on to the next ones. Open \`batch.json\` after each one. An ad Jev says is not marketing (\`not_marketing\`), or can't tell about (\`gate_unclear\`), is listed under \`skipped\` but was saved with only that answer, so it counts as progress. Run it again until one of these happens:
866
866
 
867
- - A batch saved nothing new: \`proposed\` is empty, and every ad under \`skipped\` is \`no_content\` (nothing to read: no copy, transcript or picture description) or \`not_found\`, with the rest under \`failed\`. The same ads would come back next time, so asking again won't help.
867
+ - A batch saved nothing new: \`proposed\` is empty, and every ad under \`skipped\` is \`no_content\` (nothing to read: no copy, transcript or picture description) or \`not_found\`, with the rest under \`failed\`. Asking again won't help.
868
868
  - You have run ${BACK_TAG_MAX_BATCHES} batches, or the person's spending limit would be passed.
869
869
  - \`stopped\` is \`tool_cap\`: the run's tool spending cap was reached.
870
870
 
871
- Keep a running count of ads proposed, ads skipped (and why), failures and \`costUsd\` from each batch.
871
+ Keep a running count of ads proposed, ads skipped (and why), failures and \`costUsd\` from each batch. \`nothingToRead\` counts the untagged ads the tagger left out because they have nothing to read; keep the last batch's number.
872
872
 
873
873
  ## 3. Report
874
874
 
@@ -876,7 +876,7 @@ Run the coverage query from step 1 again, then finish with a short report in pla
876
876
 
877
877
  - How many ads you tagged this run and what it cost.
878
878
  - How many ads that spent anything now have tags, and what share of the spend that is (\`pct_spend_tagged\`); what share of spend a person has confirmed (\`pct_spend_confirmed\`). Give each currency's shares on their own; never add them together.
879
- - How many ads are still untagged and why, if the batches said (nothing to read, failures).
879
+ - How many ads are still untagged and why, if the batches said: \`nothingToRead\` ads have nothing to read (no copy, headline, transcript or picture description), plus any failures.
880
880
  - What to do next: open the Ad tags page in the ${brand.name} web app, press Make the list (or Make a new list) under Check these first, and confirm, fix or skip the ads that carry the top 20% of spend. Everything else stays as the tagger's best guess, marked not yet confirmed.
881
881
 
882
882
  ## Commands
@@ -1,6 +1,7 @@
1
1
  import { CliError, exitCodes } from "../api.js";
2
2
  import { SKILL_NAME_PATTERN } from "../skill-zip.js";
3
3
  export const harnessNames = ["claude", "codex"];
4
+ const aiBillings = ["run_key", "shared_key", "subscription"];
4
5
  const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5
6
  /** The claim response as the runner uses it. Never echoes the body: it holds the run key. */
6
7
  export function parseClaim(body) {
@@ -28,6 +29,12 @@ export function parseClaim(body) {
28
29
  throw bad("deadline");
29
30
  if (body.openRouterKey !== undefined && (typeof body.openRouterKey !== "string" || body.openRouterKey.length === 0))
30
31
  throw bad("openRouterKey");
32
+ // A server from before `aiBilling` says run_key only by sending a key.
33
+ const aiBilling = body.aiBilling ?? (body.openRouterKey === undefined ? "subscription" : "run_key");
34
+ if (!aiBillings.includes(aiBilling))
35
+ throw bad("aiBilling");
36
+ if ((aiBilling === "run_key") !== (typeof body.openRouterKey === "string"))
37
+ throw bad("openRouterKey");
31
38
  return {
32
39
  runId: run.id,
33
40
  runKey: body.runKey,
@@ -37,6 +44,6 @@ export function parseClaim(body) {
37
44
  prompt: body.prompt,
38
45
  aiCapUsd: caps.aiCents / 100,
39
46
  deadline,
40
- billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind: "subscription" },
47
+ billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind: aiBilling },
41
48
  };
42
49
  }
@@ -8,6 +8,7 @@ import { brand, envPrefix } from "../brand.js";
8
8
  import { RUN_ROOT_ENV } from "../files.js";
9
9
  import { installSkillFolder, unpackSkill } from "../skills.js";
10
10
  import { parseClaim } from "./claim.js";
11
+ import { OPENROUTER_KEY_ENV } from "./codex.js";
11
12
  import { MAX_TURNS } from "./harness.js";
12
13
  import { commandHarness, harnesses } from "./harnesses.js";
13
14
  import { LogTail, redact, RUN_ERROR_MAX_BYTES, tailBytes } from "./redact.js";
@@ -68,7 +69,7 @@ export async function runOnce(options) {
68
69
  if (answer === "")
69
70
  return { kind: "idle" };
70
71
  const claim = parseClaim(JSON.parse(answer));
71
- const secrets = [claim.runKey, options.token, ...(claim.billing.kind === "openrouter" ? [claim.billing.key] : [])];
72
+ const secrets = [claim.runKey, options.token, ...(claim.billing.kind === "openrouter" ? [claim.billing.key] : []), ...(options.openRouterKey ? [options.openRouterKey] : [])];
72
73
  const say = (line) => options.say(redact(line, secrets));
73
74
  say(`Claimed run ${claim.runId}: ${claim.skill.name} version ${claim.skill.version} with ${claim.harness}.`);
74
75
  const stop = new AbortController();
@@ -90,6 +91,9 @@ export async function runOnce(options) {
90
91
  dir = await mkdtemp(join(tmpdir(), `${brand.slug}-run-`));
91
92
  const result = await execute(claim, dir, options, stop.signal, (text) => log.append(text));
92
93
  report = finishReport(result, stop.signal.reason);
94
+ const aiCents = reportedAiCents(claim.billing, result.tokens);
95
+ if (aiCents !== undefined)
96
+ report.aiCents = aiCents;
93
97
  }
94
98
  catch (error) {
95
99
  report = { status: "failed", error: stop.signal.aborted ? stopMessages[stop.signal.reason] : `The runner failed: ${error.message}` };
@@ -143,6 +147,7 @@ function finishReport(result, stopped) {
143
147
  }
144
148
  /** Puts the skill, the run key's login and a `wr` in `dir`, then runs the harness there. */
145
149
  async function execute(claim, dir, options, signal, log) {
150
+ const billing = harnessBilling(claim, options.openRouterKey);
146
151
  const skillsDir = join(dir, claim.harness === "claude" ? ".claude" : ".codex", "skills");
147
152
  await installSkillFolder(skillsDir, claim.skill.name, unpackSkill(claim.skill, await getSigned(claim.skill.url)), []);
148
153
  const configHome = join(dir, ".config");
@@ -158,7 +163,7 @@ async function execute(claim, dir, options, signal, log) {
158
163
  skill: claim.skill.name,
159
164
  prompt: runPrompt(claim.prompt),
160
165
  model: claim.model,
161
- billing: claim.billing,
166
+ billing,
162
167
  aiCapUsd: claim.aiCapUsd,
163
168
  maxTurns: MAX_TURNS,
164
169
  env: harnessEnv(process.env, { dir, binDir, configHome, apiUrl: options.apiUrl }),
@@ -166,6 +171,30 @@ async function execute(claim, dir, options, signal, log) {
166
171
  log,
167
172
  });
168
173
  }
174
+ /**
175
+ * How the harness pays. A shared_key run pays OpenRouter with the runner's own key, and only Claude Code, whose budget
176
+ * stops it at the AI cap, may run that way: nothing would hold a Codex run to its cap on a key shared by every run.
177
+ */
178
+ export function harnessBilling(claim, openRouterKey) {
179
+ if (claim.billing.kind !== "shared_key")
180
+ return claim.billing;
181
+ if (!openRouterKey) {
182
+ throw new Error(`This run pays for AI with the runner's own OpenRouter key, but ${OPENROUTER_KEY_ENV} is not set on this runner. Set it, or set OPENROUTER_PROVISIONING_KEY on the server to an OpenRouter management key so each run gets its own key.`);
183
+ }
184
+ if (claim.harness === "codex") {
185
+ throw new Error(`A Codex run cannot keep to its AI cap on the runner's shared OpenRouter key. Set OPENROUTER_PROVISIONING_KEY on the server to an OpenRouter management key so each run gets its own capped key.`);
186
+ }
187
+ return { kind: "openrouter", key: openRouterKey };
188
+ }
189
+ /**
190
+ * The AI spend a finish report carries: only a shared_key run's, whose spend the server cannot read from OpenRouter
191
+ * itself, from the harness's own estimate. A zero estimate may mean the harness could not price the model, so it is not
192
+ * sent; a run without a report counts at its full cap.
193
+ */
194
+ export function reportedAiCents(billing, tokens) {
195
+ const costUsd = tokens?.costUsd;
196
+ return billing.kind === "shared_key" && costUsd !== undefined && costUsd > 0 ? Math.ceil(costUsd * 100) : undefined;
197
+ }
169
198
  /** Beats now (moving the run to running) and every minute; a cancel, or a server that no longer knows the run, stops it. */
170
199
  function startHeartbeat(connection, runId, cancel, say) {
171
200
  const beat = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {