@wrangled/cli 0.3.22 → 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.
@@ -35,9 +39,10 @@ To read the whole library with your own tools, such as "every email we have" or
35
39
 
36
40
  ## Know-how library: Creative Cartel
37
41
 
38
- For what has worked for others, such as "how do members test new hooks" or "what do people say about advertorials", search the know-how library: \`${cli} knowledge search "<question>"\`. Today it holds Creative Cartel's library (\`creative-cartel\`) and the team's own Exodus copy and ad frameworks (\`exodus\`), both copied into this server every Monday afternoon. It works only when the key has library access (\`${cli} whoami\` says); without it the answer is \`knowledge_not_allowed\`. It finds snippets by their words and by their meaning, and returns them short, never whole documents: each at most 280 characters, with its title, link, kind, caveat, evidence and the day its library was copied in. Evidence \`reported member statement\` is a member's claim, not a checked result; weigh it that way and say so when you use it. Evidence \`team method\` is the team's own framework: reason with it, never paste it into client work. The library writes no answer for you: read the snippets and decide. Use your own words: saving text that repeats 12 words in a row from anything in the library is refused with \`copies_library_text\`.
42
+ For what has worked for others, such as "how do members test new hooks" or "what do people say about advertorials", search the know-how library: \`${cli} knowledge search "<question>"\`. Today it holds Creative Cartel's library (\`creative-cartel\`) the team's own Exodus copy and ad frameworks (\`exodus\`), and Meta's advertising standards (\`meta-policy\`), all copied into this server every Monday afternoon. It works only when the key has library access (\`${cli} whoami\` says); without it the answer is \`knowledge_not_allowed\`. It finds snippets by their words and by their meaning, and returns them short, never whole documents: each at most 280 characters, with its title, link, kind, caveat, evidence and the day its library was copied in. Evidence \`reported member statement\` is a member's claim, not a checked result; weigh it that way and say so when you use it. Evidence \`team method\` is the team's own framework: reason with it, never paste it into client work. The library writes no answer for you: read the snippets and decide. Use your own words: saving text that repeats 12 words in a row from anything in the library is refused with \`copies_library_text\`.
39
43
 
40
44
  - The question is 1 to 4000 characters. \`--limit\` is 1 to 12 (default 5). \`--kind\` (message, transcript, document, note) keeps one kind of source. \`--library <slug>\` keeps one library.
45
+ - \`--library meta-policy\` searches Meta's own advertising standards, copied from Meta's site every Monday. Before you write or judge an ad, check its claims there, such as income, health or before-and-after claims: \`${cli} knowledge search --library meta-policy "income claims"\`. Its evidence is \`meta_published_policy\`, Meta's published rule; Meta's reviewers still decide each ad.
41
46
  - \`knowledge_not_synced\` means the library has not been copied into this server yet, so nothing was searched; the weekly sync fills it.
42
47
 
43
48
  ## Query workflow: schema first
@@ -190,7 +195,7 @@ Tag ads, emails, texts and pages only with the words \`${cli} grammar\` lists (\
190
195
 
191
196
  ## What customers keep saying
192
197
 
193
- Every hour an AI reads the workspace's new Facebook and Instagram comments, Google reviews and Reddit posts and comments, pulls out openers (lines that would open an ad), objections (why people hesitate) and phrases (customers' own words for their problem or result), and groups the ones that say the same thing. Names, emails, phone numbers and handles are taken out first. \`${cli} voice\` lists the groups, most common first (\`--kind opener|objection|phrase\`, \`--limit <n>\` up to 200, \`--json\`), each with how many rows said it, their likes, and up to 3 real quotes. In \`--json\`, each quote's \`{kind, ref, quote}\` goes straight into a creative's \`researchSources\`, and a group itself can be cited as its \`ref\`, \`voice_clusters:<id>\`. The same rows are in the \`voice_clusters\` and \`voice_insights\` tables.
198
+ Every hour an AI reads what is new in the workspace's Facebook and Instagram comments, Google reviews, Reddit posts and comments, recorded calls and meetings (Fathom, Zoom, CallRail) and chats (GoHighLevel texts and chat, Closebot), pulls out openers (lines that would open an ad), objections (why people hesitate), pains (problems they live with), desires (results they want) and phrases (exact wording worth copying into an ad), and groups the ones that say the same thing. From calls and chats only the customer's words count, and names, emails, phone numbers and handles are taken out first. \`${cli} voice\` lists the groups, most common first (\`--kind opener|objection|pain|desire|phrase\`, \`--limit <n>\` up to 200, \`--json\`), each with how many sources said it (a conversation counts once), their likes, and up to 3 real quotes. \`${cli} voice --since <YYYY-MM-DD> --sort useful\` lists single lines said since that day instead, the most usable in an ad first: specific, first-person, recent and often said; \`--json\` adds each line's link. In \`--json\`, each quote's \`kind\` (a line's \`source\`) and \`ref\` go straight into a creative's \`researchSources\`, and a group itself can be cited as its \`ref\`, \`voice_clusters:<id>\`. The same rows are in the \`voice_clusters\` and \`voice_insights\` tables.
194
199
 
195
200
  ## The weekly story
196
201
 
@@ -242,6 +247,7 @@ ${cli} whoami show the current workspace and library acces
242
247
  ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] short, cited snippets from the Creative Cartel know-how library (keys with library access)
243
248
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
244
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
245
251
  ${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
246
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
247
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,11 +10,13 @@ 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";
16
17
  const DEFAULT_WAIT_SECONDS = 300;
17
18
  const SERVER_WAIT_CAP = 50;
19
+ const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
18
20
  const commands = {
19
21
  login: {
20
22
  usage: `login (paste the key when asked, pipe it on stdin, or set ${envPrefix}_API_KEY)`,
@@ -608,6 +610,21 @@ const commands = {
608
610
  process.stdout.write(brief.text);
609
611
  },
610
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
+ },
611
628
  examples: {
612
629
  usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
613
630
  async run(args, options) {
@@ -1125,21 +1142,30 @@ const commands = {
1125
1142
  },
1126
1143
  },
1127
1144
  voice: {
1128
- usage: "voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, most common first",
1145
+ usage: `${VOICE_USAGE} what customers keep saying, most common first, or with --sort useful the most usable single lines`,
1129
1146
  async run(args, options) {
1130
1147
  if (args.length > 0)
1131
- throw new CliError("Usage: voice [--kind opener|objection|phrase] [--limit <n>] [--json]", exitCodes.usage);
1148
+ throw new CliError(`Usage: ${VOICE_USAGE}`, exitCodes.usage);
1132
1149
  const query = new URLSearchParams();
1133
1150
  if (options.kind)
1134
1151
  query.set("kind", options.kind);
1152
+ if (options.since)
1153
+ query.set("since", options.since);
1154
+ if (options.sort)
1155
+ query.set("sort", options.sort);
1135
1156
  if (options.limit)
1136
1157
  query.set("limit", options.limit);
1137
- const { clusters } = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1158
+ const read = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1138
1159
  if (options.format === "json")
1139
- return printJson({ clusters });
1140
- if (clusters.length === 0)
1160
+ return printJson(read);
1161
+ if ("lines" in read) {
1162
+ if (read.lines.length === 0)
1163
+ return console.log("Nothing said in that time yet.");
1164
+ return console.log(renderTable(VOICE_LINE_COLUMNS, read.lines.map(voiceLineRow)));
1165
+ }
1166
+ if (read.clusters.length === 0)
1141
1167
  return console.log("Nothing grouped yet.");
1142
- console.log(renderTable(VOICE_COLUMNS, clusters.map(voiceRow)));
1168
+ console.log(renderTable(VOICE_COLUMNS, read.clusters.map(voiceRow)));
1143
1169
  },
1144
1170
  },
1145
1171
  "ads tags": {
@@ -1199,6 +1225,8 @@ const commands = {
1199
1225
  console.log(`Failed for ${failure.adIds.join(", ")}: ${failure.error}`);
1200
1226
  if (result.stopped === "tool_cap")
1201
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.`);
1202
1230
  },
1203
1231
  },
1204
1232
  "content tags": {
@@ -1683,6 +1711,7 @@ async function withRunner(options, work) {
1683
1711
  apiUrl: resolveApiUrl(options.apiUrl, null),
1684
1712
  token,
1685
1713
  harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
1714
+ openRouterKey: process.env[OPENROUTER_KEY_ENV] || undefined,
1686
1715
  maxSeconds: options.maxSeconds,
1687
1716
  signal: stop.signal,
1688
1717
  say: (line) => console.log(line),
@@ -1759,6 +1788,59 @@ const CAMPAIGN_NUMBERS = [
1759
1788
  "roas_first_touch",
1760
1789
  "roas_last_touch",
1761
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
+ }
1762
1844
  function reportQuery(options, extra) {
1763
1845
  const query = new URLSearchParams();
1764
1846
  for (const [name, value] of Object.entries({ from: options.from, to: options.to, ...extra }))
@@ -1798,6 +1880,7 @@ const VOICE_SUGGESTIONS_PATH = "/api/v1/voice-lists/suggestions";
1798
1880
  const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
1799
1881
  const VOICE_PATH = "/api/v1/voice";
1800
1882
  const VOICE_COLUMNS = ["ref", "kind", "said by", "likes", "label", "top quote"];
1883
+ const VOICE_LINE_COLUMNS = ["date", "kind", "source", "quote", "ref"];
1801
1884
  function knowledgeBlock(r, i) {
1802
1885
  const lines = [`${i + 1}. ${r.title}`, ` ${r.kind}, ${r.evidence.replaceAll("_", " ")} (${r.source}${r.syncedAt ? `, copied in ${r.syncedAt.slice(0, 10)}` : ""})`];
1803
1886
  if (r.url)
@@ -1818,6 +1901,9 @@ function contentTagRow(t) {
1818
1901
  function voiceRow(c) {
1819
1902
  return { ref: c.ref, kind: c.kind, "said by": c.supportCount, likes: c.likeSum, label: excerpt(c.label, 60), "top quote": c.evidence[0] ? excerpt(c.evidence[0].quote, 60) : null };
1820
1903
  }
1904
+ function voiceLineRow(l) {
1905
+ return { date: l.date.slice(0, 10), kind: l.kind, source: l.source, quote: excerpt(l.quote, 80), ref: l.ref };
1906
+ }
1821
1907
  function factRow(f) {
1822
1908
  return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
1823
1909
  }
@@ -1901,7 +1987,8 @@ Options:
1901
1987
  --channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
1902
1988
  for content track-link, where the link goes: facebook, email, sms or web (default)
1903
1989
  --campaign <id> the campaign content track-link puts in utm_campaign; with brief, the one campaign it covers
1904
- --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)
1905
1992
  --body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
1906
1993
  --file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
1907
1994
  --transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
@@ -1934,7 +2021,7 @@ Options:
1934
2021
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1935
2022
  --limit <n> how many results content search, creatives list, campaigns, voice, ads tags, content tags and content to-check
1936
2023
  return, or how many ads ads autotag tags and how many items content autotag tags
1937
- --library <slug> with knowledge search, only this library: creative-cartel or exodus
2024
+ --library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
1938
2025
  --ad <id> with ads tags, only this Meta ad's tags; with ads autotag, only this ad
1939
2026
  --kind email|text|page with content tags and content autotag, only that kind (content autotag takes a comma list)
1940
2027
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
@@ -1987,11 +2074,13 @@ function parseOptions(argv) {
1987
2074
  force: { type: "boolean", default: false },
1988
2075
  yes: { type: "boolean", default: false },
1989
2076
  from: { type: "string" },
2077
+ since: { type: "string" },
1990
2078
  to: { type: "string" },
1991
2079
  grain: { type: "string" },
1992
2080
  channel: { type: "string" },
1993
2081
  campaign: { type: "string" },
1994
2082
  top: { type: "string" },
2083
+ week: { type: "string" },
1995
2084
  name: { type: "string" },
1996
2085
  subject: { type: "string" },
1997
2086
  preview: { type: "string" },
@@ -2082,11 +2171,13 @@ function parseOptions(argv) {
2082
2171
  force: values.force,
2083
2172
  yes: values.yes,
2084
2173
  from: values.from,
2174
+ since: values.since,
2085
2175
  to: values.to,
2086
2176
  grain: values.grain,
2087
2177
  channel: values.channel,
2088
2178
  campaign: values.campaign,
2089
2179
  top: values.top,
2180
+ week: values.week,
2090
2181
  name: values.name,
2091
2182
  subject: values.subject,
2092
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.22",
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": {