@wrangled/cli 0.3.23 → 0.3.25

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/api.js CHANGED
@@ -16,6 +16,11 @@ export class UnreadableCredentialsError extends CliError {
16
16
  super(`The saved credentials at ${path} are unreadable. Run \`${brand.slug} login\` again.`, exitCodes.auth);
17
17
  }
18
18
  }
19
+ /**
20
+ * The grammar fields library entries are tagged with, and the CLI flag for each; the server's list is entryTagFields
21
+ * in src/lib/knowledge/tags.ts. format's flag is --ad-format, because --format picks the output.
22
+ */
23
+ export const entryTagFlags = { angle: "angle", hook_kind: "hook-kind", format: "ad-format", awareness: "awareness" };
19
24
  /** Copies of the server's IMPORT_LIMITS and import name rule; src/lib/cli-consistency.test.ts keeps them in step. */
20
25
  export const IMPORT_LIMITS = { bytes: 50 * 1024 * 1024, rows: 1_000_000, columns: 500 };
21
26
  export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
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.
@@ -40,6 +44,8 @@ For what has worked for others, such as "how do members test new hooks" or "what
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.
41
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.
42
46
  - \`knowledge_not_synced\` means the library has not been copied into this server yet, so nothing was searched; the weekly sync fills it.
47
+ - Library entries carry the marketing grammar's tags: \`--angle\`, \`--hook-kind\`, \`--ad-format\` and \`--awareness\` (the ad tag fields angle, hook_kind, format and awareness, with the same values). Give any of them to keep to entries tagged that way; the question is then optional, and without one the entries come surest tags first. With a tag filter the answer also lists this workspace's own Meta ads whose live tags hold the same values, with their lifetime spend and results, biggest spend first (at most 20): what the library says next to how it went for this client.
48
+ - Each result shows its entry id and tags. Tag an entry you read with \`${cli} knowledge tag <entry-id> --hook-kind big_win --confidence medium\` (several flags at once is fine), take a wrong tag off with \`--remove\`, or tag a batch with \`${cli} knowledge tag --file tags.json\`, where the file is \`{"tags": [{"entry_id", "field", "value", "confidence"}]}\` (up to 500; \`"remove": true\` in place of confidence takes one off). Values off the grammar's list are refused.
43
49
 
44
50
  ## Query workflow: schema first
45
51
 
@@ -240,9 +246,11 @@ A scheduled hand's run acts as its skill's agent, the way the Facebook ads agent
240
246
  ${cli} login validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it
241
247
  ${cli} logout delete saved credentials
242
248
  ${cli} whoami show the current workspace and library access
243
- ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] short, cited snippets from the Creative Cartel know-how library (keys with library access)
249
+ ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] [--hook-kind <v> ...] short, cited snippets from the know-how libraries (keys with library access)
250
+ ${cli} knowledge tag <entry-id> --hook-kind <v> --confidence <c> tag a library entry with the marketing grammar (or --file tags.json)
244
251
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
245
252
  ${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
253
+ ${cli} winners [--week <date>] [--top <n>] which Facebook ads won and flopped this week and over 30 days, judged on first-touch sales
246
254
  ${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
247
255
  ${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
256
  ${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
package/dist/main.js CHANGED
@@ -4,12 +4,13 @@ import { mkdir, readFile, stat } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
- import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, streamSigned, } from "./api.js";
7
+ import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
8
8
  import { brand, envPrefix } from "./brand.js";
9
9
  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";
@@ -56,23 +57,55 @@ const commands = {
56
57
  },
57
58
  },
58
59
  "knowledge search": {
59
- usage: 'knowledge search "<question>" [--limit <n>] [--kind message|transcript|document|note] [--library <slug>]',
60
+ usage: 'knowledge search ["<question>"] [--limit <n>] [--kind message|transcript|document|note] [--library <slug>] [--angle <v>] [--hook-kind <v>] [--ad-format <v>] [--awareness <v>]',
60
61
  async run(args, options) {
61
- if (args.length !== 1)
62
- throw new CliError('Usage: knowledge search "<question>" [--limit <n>] [--kind <kind>] [--library <slug>]; put the question in quotes', exitCodes.usage);
63
- const query = new URLSearchParams({ q: args[0] });
62
+ const filters = Object.entries(options.entryTags);
63
+ if (args.length > 1 || (args.length === 0 && filters.length === 0)) {
64
+ throw new CliError('Usage: knowledge search "<question>" [--limit <n>] [--kind <kind>] [--library <slug>] [--angle <v>] [--hook-kind <v>] [--ad-format <v>] [--awareness <v>]; put the question in quotes, or give a tag filter', exitCodes.usage);
65
+ }
66
+ const query = new URLSearchParams(args[0] === undefined ? {} : { q: args[0] });
64
67
  if (options.limit !== undefined)
65
68
  query.set("limit", options.limit);
66
69
  if (options.kind !== undefined)
67
70
  query.set("kind", options.kind);
68
71
  if (options.library !== undefined)
69
72
  query.set("library", options.library);
70
- const { results } = await api(await resolveConnection(options.apiUrl), `/api/v1/knowledge/search?${query}`);
73
+ for (const [field, value] of filters)
74
+ query.set(field, value);
75
+ const answer = await api(await resolveConnection(options.apiUrl), `/api/v1/knowledge/search?${query}`);
71
76
  if (options.format === "json")
72
- return printJson({ results });
73
- if (results.length === 0)
74
- return console.log("Nothing in the library matched.");
75
- console.log(results.map(knowledgeBlock).join("\n\n"));
77
+ return printJson(answer);
78
+ console.log(answer.results.length === 0 ? "Nothing in the library matched." : answer.results.map(knowledgeBlock).join("\n\n"));
79
+ if (answer.yourAds === undefined)
80
+ return;
81
+ const tagged = filters.map(([field, value]) => `${field} ${value}`).join(", ");
82
+ if (answer.yourAds.length === 0)
83
+ return console.log(`\nNone of your ads is tagged ${tagged}.`);
84
+ console.log(`\nYour ads tagged ${tagged}, biggest spend first:`);
85
+ console.log(renderTable(["ad", "name", "tags", "spend", "results"], answer.yourAds.map(yourAdRow)));
86
+ },
87
+ },
88
+ "knowledge tag": {
89
+ usage: "knowledge tag <entry-id> [--angle <v>] [--hook-kind <v>] [--ad-format <v>] [--awareness <v>] --confidence high|medium|low [--remove] | --file <tags.json> tag library entries with the marketing grammar",
90
+ async run(args, options) {
91
+ const usage = "Usage: knowledge tag <entry-id> [--angle <v>] [--hook-kind <v>] [--ad-format <v>] [--awareness <v>] --confidence high|medium|low [--remove], or knowledge tag --file <tags.json>";
92
+ const filters = Object.entries(options.entryTags);
93
+ if (args.length > 1 || (args.length === 1 && filters.length === 0))
94
+ throw new CliError(usage, exitCodes.usage);
95
+ const body = args.length === 1
96
+ ? {
97
+ tags: filters.map(([field, value]) => ({
98
+ entry_id: args[0],
99
+ field,
100
+ value,
101
+ ...(options.remove ? { remove: true } : { confidence: options.confidence }),
102
+ })),
103
+ }
104
+ : await readToolInput(options);
105
+ const result = await api(await resolveConnection(options.apiUrl), KNOWLEDGE_TAGS_PATH, body);
106
+ if (options.format === "json")
107
+ return printJson(result);
108
+ console.log(`Tagged ${result.tagged}, took off ${result.removed}.`);
76
109
  },
77
110
  },
78
111
  "warehouse schema": {
@@ -609,6 +642,21 @@ const commands = {
609
642
  process.stdout.write(brief.text);
610
643
  },
611
644
  },
645
+ winners: {
646
+ usage: "winners [--week <YYYY-MM-DD>] [--top <n>] (which Facebook ads won and flopped, judged on real first-touch sales)",
647
+ async run(args, options) {
648
+ if (args.length > 0)
649
+ throw new CliError("Usage: winners [--week <date>] [--top <n>]", exitCodes.usage);
650
+ const query = new URLSearchParams();
651
+ for (const [name, value] of Object.entries({ week: options.week, top: options.top }))
652
+ if (value !== undefined)
653
+ query.set(name, value);
654
+ const report = await api(await resolveConnection(options.apiUrl), `/api/v1/winners${query.size ? `?${query}` : ""}`);
655
+ if (options.format === "json")
656
+ return printJson(report);
657
+ process.stdout.write(winnersText(report));
658
+ },
659
+ },
612
660
  examples: {
613
661
  usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
614
662
  async run(args, options) {
@@ -1209,6 +1257,8 @@ const commands = {
1209
1257
  console.log(`Failed for ${failure.adIds.join(", ")}: ${failure.error}`);
1210
1258
  if (result.stopped === "tool_cap")
1211
1259
  console.log("Stopped early: the run's tool spending cap was reached.");
1260
+ if (result.nothingToRead > 0)
1261
+ console.log(`Left out ${plural(result.nothingToRead)} with nothing to read: no copy, headline, transcript or picture description.`);
1212
1262
  },
1213
1263
  },
1214
1264
  "content tags": {
@@ -1693,6 +1743,7 @@ async function withRunner(options, work) {
1693
1743
  apiUrl: resolveApiUrl(options.apiUrl, null),
1694
1744
  token,
1695
1745
  harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
1746
+ openRouterKey: process.env[OPENROUTER_KEY_ENV] || undefined,
1696
1747
  maxSeconds: options.maxSeconds,
1697
1748
  signal: stop.signal,
1698
1749
  say: (line) => console.log(line),
@@ -1769,6 +1820,59 @@ const CAMPAIGN_NUMBERS = [
1769
1820
  "roas_first_touch",
1770
1821
  "roas_last_touch",
1771
1822
  ];
1823
+ function money(value, currency) {
1824
+ try {
1825
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(value);
1826
+ }
1827
+ catch {
1828
+ return `${value.toFixed(2)} ${currency}`;
1829
+ }
1830
+ }
1831
+ const counted = (n, one) => `${n} ${n === 1 ? one : `${one}s`}`;
1832
+ /** An ad's line: name, SKU and campaign, its numbers, its tags, then why it is on the list. */
1833
+ function winnersAdLines(ad, currency) {
1834
+ const n = ad.numbers;
1835
+ const onSales = ad.judgedOn === "sales";
1836
+ const cost = onSales
1837
+ ? n.costPerSale === null ? "no sales" : `${money(n.costPerSale, currency)} per sale`
1838
+ : n.costPerSignup === null ? "no sign-ups" : `${money(n.costPerSignup, currency)} per sign-up`;
1839
+ const name = [ad.name ?? `Ad ${ad.adId}`, ad.sku && `[${ad.sku}]`, ad.campaignName && `(${ad.campaignName})`].filter(Boolean).join(" ");
1840
+ const tags = Object.entries(ad.tags).map(([field, value]) => `${field}: ${value}`).join(", ");
1841
+ const count = onSales ? n.sales !== null && counted(n.sales, "sale") : n.signups !== null && counted(n.signups, "sign-up");
1842
+ return `- ${name}: spent ${money(n.spend, currency)}, ${count ? `${count}, ` : ""}${cost}${tags ? `. Tags: ${tags}` : ""}\n ${ad.pickedBecause}\n`;
1843
+ }
1844
+ function winnersWindowText(title, window) {
1845
+ const head = `\n## ${title}, ${window.from} to ${window.to}\n`;
1846
+ if (window.sections.length === 0)
1847
+ return `${head}\nNo Facebook ad spent in these dates.\n`;
1848
+ return (head +
1849
+ window.sections
1850
+ .map((s) => {
1851
+ const list = (heading, ads) => `\n${heading}\n${ads.length === 0 ? "- none\n" : ads.map((ad) => winnersAdLines(ad, s.currency)).join("")}`;
1852
+ const account = s.salesSource === "not_connected"
1853
+ ? "sales are not connected"
1854
+ : s.accountCostPerSale === null
1855
+ ? "no sale credited to a Facebook ad in these dates"
1856
+ : `the account paid ${money(s.accountCostPerSale, s.currency)} per sale`;
1857
+ 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")}` +
1858
+ `${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per sale`}\n`);
1859
+ return (`\nIn ${s.currency}: ${account}. ${s.adsJudgedOnSales} judged on sales, ${s.adsJudgedOnSignups} judged on sign-ups.\n` +
1860
+ s.notes.map((note) => `${note}\n`).join("") +
1861
+ list("Winners", s.winners) +
1862
+ list("Flops", s.flops) +
1863
+ list(`${s.onSignups.title}: best`, s.onSignups.best) +
1864
+ list(`${s.onSignups.title}: worst`, s.onSignups.worst) +
1865
+ (tests.length > 0 ? `\nAdzara tests\n${tests.join("")}` : ""));
1866
+ })
1867
+ .join(""));
1868
+ }
1869
+ /** The winners report as plain Markdown: what a sale means, then this week and the last 30 days, each currency apart. */
1870
+ function winnersText(report) {
1871
+ return (`# Winners and flops for ${report.workspace}\n\n` +
1872
+ `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` +
1873
+ winnersWindowText("This week", report.week) +
1874
+ winnersWindowText("Last 30 days", report.last30Days));
1875
+ }
1772
1876
  function reportQuery(options, extra) {
1773
1877
  const query = new URLSearchParams();
1774
1878
  for (const [name, value] of Object.entries({ from: options.from, to: options.to, ...extra }))
@@ -1795,6 +1899,7 @@ function printReport(title, report, labels, numbers) {
1795
1899
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
1796
1900
  const FACTS_PATH = "/api/v1/facts";
1797
1901
  const AD_TAGS_PATH = "/api/v1/ad-tags";
1902
+ const KNOWLEDGE_TAGS_PATH = "/api/v1/knowledge/tags";
1798
1903
  const AD_AUTOTAG_PATH = "/api/v1/ad-tags/autotag";
1799
1904
  const SKIP_REASONS = { not_marketing: "Not marketing, so only that answer was saved", gate_unclear: "Could not tell whether it is marketing, so only that was saved", no_content: "Nothing to read", not_found: "No such ad" };
1800
1905
  const AD_TAG_COLUMNS = ["id", "ad", "status", "confidence", "offer", "persona", "angle", "hook", "format", "author"];
@@ -1816,8 +1921,16 @@ function knowledgeBlock(r, i) {
1816
1921
  lines.push(` ${r.snippet}`);
1817
1922
  if (r.caveat)
1818
1923
  lines.push(` Caveat: ${r.caveat}`);
1924
+ if (r.entryId) {
1925
+ const tags = Object.entries(r.tags ?? {}).flatMap(([field, values]) => values.map((value) => `${field} ${value}`));
1926
+ lines.push(` Entry ${r.entryId}${tags.length > 0 ? `, tagged ${tags.join(", ")}` : ""}`);
1927
+ }
1819
1928
  return lines.join("\n");
1820
1929
  }
1930
+ function yourAdRow(a) {
1931
+ const results = a.results === null ? null : `${a.results} ${a.resultType ?? ""}`.trim();
1932
+ return { ad: a.adId, name: a.adName, tags: `${a.tagStatus}, ${a.tagConfidence}`, spend: a.spend === null ? null : `${a.spend.toFixed(2)} ${a.currency ?? ""}`.trim(), results };
1933
+ }
1821
1934
  function adTagRow(t) {
1822
1935
  const { offer_key, persona, angle, hook_kind, hook_spot, format, style } = t.tags;
1823
1936
  return { id: t.id, ad: t.adId, status: t.status, confidence: t.confidence, offer: offer_key, persona, angle, hook: [hook_kind, hook_spot].filter(Boolean).join(" in ") || null, format: [format, style].filter(Boolean).join(", ") || null, author: t.author };
@@ -1915,7 +2028,8 @@ Options:
1915
2028
  --channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
1916
2029
  for content track-link, where the link goes: facebook, email, sms or web (default)
1917
2030
  --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)
2031
+ --top <n> how many best ads, worst ads, pages and campaigns brief shows, or ads in each winners list (default: 5, at most 20)
2032
+ --week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
1919
2033
  --body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
1920
2034
  --file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
1921
2035
  --transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
@@ -1949,6 +2063,10 @@ Options:
1949
2063
  --limit <n> how many results content search, creatives list, campaigns, voice, ads tags, content tags and content to-check
1950
2064
  return, or how many ads ads autotag tags and how many items content autotag tags
1951
2065
  --library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
2066
+ --angle, --hook-kind, --ad-format, --awareness <value> with knowledge search, only library entries tagged that way, and
2067
+ your own ads tagged the same way with their results; with knowledge tag, the tags to put on an entry
2068
+ --confidence high|medium|low with knowledge tag, how sure you are of the tags
2069
+ --remove with knowledge tag, take the tags off instead
1952
2070
  --ad <id> with ads tags, only this Meta ad's tags; with ads autotag, only this ad
1953
2071
  --kind email|text|page with content tags and content autotag, only that kind (content autotag takes a comma list)
1954
2072
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
@@ -2007,6 +2125,7 @@ function parseOptions(argv) {
2007
2125
  channel: { type: "string" },
2008
2126
  campaign: { type: "string" },
2009
2127
  top: { type: "string" },
2128
+ week: { type: "string" },
2010
2129
  name: { type: "string" },
2011
2130
  subject: { type: "string" },
2012
2131
  preview: { type: "string" },
@@ -2048,6 +2167,12 @@ function parseOptions(argv) {
2048
2167
  step: { type: "string" },
2049
2168
  workspace: { type: "string" },
2050
2169
  "max-seconds": { type: "string" },
2170
+ angle: { type: "string" },
2171
+ "hook-kind": { type: "string" },
2172
+ "ad-format": { type: "string" },
2173
+ awareness: { type: "string" },
2174
+ confidence: { type: "string" },
2175
+ remove: { type: "boolean", default: false },
2051
2176
  help: { type: "boolean", short: "h", default: false },
2052
2177
  },
2053
2178
  });
@@ -2103,6 +2228,7 @@ function parseOptions(argv) {
2103
2228
  channel: values.channel,
2104
2229
  campaign: values.campaign,
2105
2230
  top: values.top,
2231
+ week: values.week,
2106
2232
  name: values.name,
2107
2233
  subject: values.subject,
2108
2234
  preview: values.preview,
@@ -2144,6 +2270,9 @@ function parseOptions(argv) {
2144
2270
  step: values.step,
2145
2271
  workspace: values.workspace,
2146
2272
  maxSeconds,
2273
+ entryTags: Object.fromEntries(Object.entries(entryTagFlags).flatMap(([field, flag]) => (values[flag] === undefined ? [] : [[field, values[flag]]]))),
2274
+ confidence: values.confidence,
2275
+ remove: values.remove,
2147
2276
  },
2148
2277
  };
2149
2278
  }
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.25",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {