@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 +5 -0
- package/dist/guide.js +9 -1
- package/dist/main.js +140 -11
- package/dist/playbooks.js +3 -3
- package/dist/runner/claim.js +8 -1
- package/dist/runner/run.js +31 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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\`.
|
|
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
|
|
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
|
package/dist/runner/claim.js
CHANGED
|
@@ -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:
|
|
47
|
+
billing: typeof body.openRouterKey === "string" ? { kind: "openrouter", key: body.openRouterKey } : { kind: aiBilling },
|
|
41
48
|
};
|
|
42
49
|
}
|
package/dist/runner/run.js
CHANGED
|
@@ -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
|
|
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 () => {
|