weftens 0.0.1 → 0.1.1

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.
Files changed (101) hide show
  1. package/README.md +178 -12
  2. package/agent-cores/amazon-ppc/package.json +17 -0
  3. package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
  4. package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
  5. package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
  6. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
  7. package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
  8. package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
  9. package/agent-cores/content-ops/package.json +17 -0
  10. package/agent-cores/content-ops/src/cli.mjs +51 -0
  11. package/agent-cores/content-ops/src/inventory.mjs +343 -0
  12. package/agent-cores/content-ops/src/parse.mjs +121 -0
  13. package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
  14. package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
  15. package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
  16. package/agent-cores/meta-ads/package.json +16 -0
  17. package/agent-cores/meta-ads/src/analyze.mjs +166 -0
  18. package/agent-cores/meta-ads/src/cli.mjs +39 -0
  19. package/agent-cores/meta-ads/src/parse.mjs +123 -0
  20. package/agent-cores/meta-ads/test/analyze.test.js +72 -0
  21. package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
  22. package/agent-cores/paid-search/package.json +17 -0
  23. package/agent-cores/paid-search/src/analyze.mjs +303 -0
  24. package/agent-cores/paid-search/src/cli.mjs +57 -0
  25. package/agent-cores/paid-search/src/parse.mjs +158 -0
  26. package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
  27. package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
  28. package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
  29. package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
  30. package/agent-cores/paid-search/test/parse.test.mjs +56 -0
  31. package/agent-cores/seo-content/package.json +17 -0
  32. package/agent-cores/seo-content/src/cli.mjs +41 -0
  33. package/agent-cores/seo-content/src/parse.mjs +141 -0
  34. package/agent-cores/seo-content/src/strategy.mjs +266 -0
  35. package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
  36. package/agent-cores/seo-content/test/parse.test.mjs +59 -0
  37. package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
  38. package/agent-cores/seo-technical/package.json +17 -0
  39. package/agent-cores/seo-technical/src/audit.mjs +233 -0
  40. package/agent-cores/seo-technical/src/cli.mjs +48 -0
  41. package/agent-cores/seo-technical/src/fetch.mjs +51 -0
  42. package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
  43. package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
  44. package/agent-cores/supply-verify/package.json +17 -0
  45. package/agent-cores/supply-verify/src/cli.mjs +44 -0
  46. package/agent-cores/supply-verify/src/verify.mjs +303 -0
  47. package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
  48. package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
  49. package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
  50. package/agents/agent2060.md +99 -0
  51. package/agents/amazon-ppc.md +42 -0
  52. package/agents/claims-content.md +35 -0
  53. package/agents/content-ops.md +46 -0
  54. package/agents/content-strategy.md +13 -0
  55. package/agents/disclosure-qc.md +13 -0
  56. package/agents/feed-optimizer.md +13 -0
  57. package/agents/growth-analyst.md +24 -0
  58. package/agents/hilbert.md +55 -0
  59. package/agents/ideator.md +13 -0
  60. package/agents/lippmann.md +68 -0
  61. package/agents/paid-search.md +35 -0
  62. package/agents/pio.md +34 -0
  63. package/agents/researcher.md +13 -0
  64. package/agents/richy.md +72 -0
  65. package/agents/script-editor.md +13 -0
  66. package/agents/seo-content.md +57 -0
  67. package/agents/seo-technical.md +53 -0
  68. package/agents/showrunner.md +13 -0
  69. package/agents/social-voice.md +38 -0
  70. package/agents/supply-verify.md +38 -0
  71. package/agents/video-editor.md +13 -0
  72. package/agents/writer.md +45 -0
  73. package/bin/weftens.js +234 -0
  74. package/content-org/.preflight/telemetry.jsonl +6 -0
  75. package/content-org/briefs/BRIEFS.md +47 -0
  76. package/content-org/config.mjs +78 -0
  77. package/content-org/cost.mjs +36 -0
  78. package/content-org/lead.js +64 -0
  79. package/content-org/metrics.mjs +166 -0
  80. package/content-org/orchestrator.mjs +246 -0
  81. package/content-org/package.json +14 -0
  82. package/content-org/producer.mjs +59 -0
  83. package/content-org/publish.mjs +34 -0
  84. package/content-org/review.mjs +32 -0
  85. package/content-org/studio/README.md +27 -0
  86. package/content-org/studio/assemble.sh +48 -0
  87. package/content-org/test.mjs +110 -0
  88. package/index.js +32 -0
  89. package/package.json +49 -10
  90. package/providers.json.example +9 -0
  91. package/src/agent-router.js +124 -0
  92. package/src/agent.js +123 -0
  93. package/src/format.js +66 -0
  94. package/src/invoke.js +129 -0
  95. package/src/pio-client.js +142 -0
  96. package/src/provider.js +81 -0
  97. package/src/registry.js +160 -0
  98. package/src/richy-client.js +216 -0
  99. package/src/router.js +34 -0
  100. package/src/synthesize.js +111 -0
  101. package/src/weftens.js +61 -0
package/README.md CHANGED
@@ -1,12 +1,178 @@
1
- # Weftens
2
-
3
- **Placeholder release.** This version reserves the package name. It contains no code and does nothing
4
- if installed the working release follows.
5
-
6
- Weftens is one front door for marketing and representation work: you say what you need in plain
7
- language, it routes the request to the right agent and runs it on **your own AI subscription** — no
8
- account, no billing, no API key. Several of its agents need no model at all: the ad and SEO auditors
9
- are deterministic, run on your exported reports, and propose changes a human applies. They never write
10
- to a live account.
11
-
12
- MIT licensed. Free to use, including commercially.
1
+ # Weftens
2
+
3
+ **Where did the ad money go, and what should change?** Give Weftens an exported report from
4
+ Google Ads, Meta, Amazon, or an SEO crawl, and it gives you back a ranked change sheet — each
5
+ recommendation with where it applies, why it's proposed, what it's worth, and what could go
6
+ wrong if you're wrong.
7
+
8
+ 4 proposed changes | $604.27 of spend flagged
9
+ account: spend $910.00, 208 clicks, 11 conversions, CPA $82.73
10
+
11
+ 1. Bid down or restrict "premium widget wholesale bulk" $417.27
12
+ where campaign: Brand / Brand - Exact
13
+ why CPA $500 vs account $82.73 (6x); $500 for 1 conv
14
+ risk Converting term — restricting too hard can cut real volume; prefer a bid cut first.
15
+
16
+ 2. Add "free blue widgets" as a negative keyword $90.00
17
+ where campaign: Generic / Widgets - Broad
18
+ why 60 clicks, $90 spent, 0 conversions (expected ~3.2 at the 5.3% account rate)
19
+ risk If the query is actually relevant, you forfeit those impressions.
20
+
21
+ Proposals only — nothing was sent to your ad account. Apply what you agree with.
22
+
23
+ That is the actual output of the sample export included in this repo, produced by:
24
+
25
+ npm i weftens
26
+ npx weftens send paid-search audit --input agent-cores/paid-search/test/fixtures/search-terms.csv
27
+
28
+ **Same file in, same change sheet out, every time.** The ad and SEO auditors are arithmetic, not
29
+ a model — nothing is generated, so nothing can be invented, and two runs can be diffed to prove
30
+ nothing moved. They read a file you already have and **never sign in to your ad account**. Note
31
+ the second line of the first recommendation: that keyword *converted*. "Flagged" means the numbers
32
+ crossed a threshold, not that the money is guaranteed back — the risk line is there because the
33
+ call is still yours.
34
+
35
+ **Not technical?** You don't have to run this. Send the repo link and your exported report to
36
+ whoever manages your ads, or paste this to an AI assistant that can use your computer
37
+ (Claude, ChatGPT/Codex, Gemini): *"install weftens and run the paid-search audit on my export."*
38
+
39
+ ## What's finished, and what isn't
40
+
41
+ - **Finished and deterministic — seven auditors:** paid search (Google Ads), paid social (Meta),
42
+ Amazon PPC, technical SEO, content SEO, supply-path verification, content operations. No model,
43
+ no key, propose-only.
44
+ - **Model-backed seats** run on an AI CLI *you* already have and are logged into — your
45
+ subscription, no API key, nobody is billed through Weftens. They are only as good as the model
46
+ you point them at. `claude` and `codex` are verified; `gemini` and `grok` are configured but have
47
+ never been run.
48
+ - **Two seats are not in this repo.** `richy` (representation) and `pio` (prospect research) are
49
+ clients for separate services that aren't open source. Without those services configured they
50
+ return sample data that says so in its own output.
51
+ - **The content pipeline is a placeholder.** Ten seats exist; it emits stub output and does not yet
52
+ take a free-text brief.
53
+
54
+ Free, MIT licensed, no account, no billing.
55
+
56
+ ## Install
57
+
58
+ ```
59
+ git clone git@github.com:Weftens/weftens.git
60
+ cd weftens
61
+ npm install
62
+ npm test
63
+ ```
64
+
65
+ That's the whole install. Everything lives in this one repo — the deterministic auditors
66
+ (`agent-cores/`), the agent seat definitions (`agents/`), and the content pipeline (`content-org/`).
67
+ A fresh clone runs on its own; there is no second checkout and no external directory. (Verified by
68
+ cloning to an unrelated path and running the suite there — the earlier version of this claim was
69
+ false: the end-to-end test resolved `agent-cores/` from the repo's *parent*, so it only passed on the
70
+ author's machine.)
71
+
72
+ Node 22+. `npm test` runs both suites: `test:unit` (vitest, the router/registry/CLI layer) and
73
+ `test:cores` (`node --test`, the deterministic auditors — they are zero-install by design so a single
74
+ core can be handed to someone who has nothing but Node).
75
+
76
+ For the model-backed agents, have a model CLI installed and logged in (e.g. `claude`) — that's your
77
+ own subscription, no key. The deterministic ad/SEO auditors need no model at all.
78
+
79
+ ## Use it as an SDK
80
+
81
+ ```js
82
+ import { weftens, route, invoke, REGISTRY } from "weftens";
83
+
84
+ // one call — route a request and run the chosen agent:
85
+ const { decision, outcome } = await weftens("audit our facebook ads", { inputFile: "meta.csv" });
86
+ console.log(decision.agent.name, outcome.result);
87
+
88
+ // or drive the pieces yourself:
89
+ const d = route({ text: "who are the funders for this grant" });
90
+ const o = await invoke(d.agent, { text: "..." });
91
+ ```
92
+
93
+ ## Run
94
+
95
+ ```
96
+ node bin/weftens.js list # the agent directory + which have a deterministic core
97
+ node bin/weftens.js --route "<request>" # dry run: which agent it routes to, and the words it matched
98
+ node bin/weftens.js send "<request>" # route, then run
99
+ node bin/weftens.js send <agent> "<request>" # address one agent directly
100
+ node bin/weftens.js send <agent> --input report.csv # feed a file to a calculator agent
101
+ node bin/weftens.js send <agent> "<...>" --provider gemini # pick which model runs it
102
+ ```
103
+
104
+ Examples:
105
+
106
+ ```
107
+ # paid media — PROPOSE-ONLY, never touches an account:
108
+ node bin/weftens.js send paid-search x --input google-ads-export.csv
109
+ node bin/weftens.js send meta-ads x --input meta-ads-export.csv
110
+ node bin/weftens.js send amazon-ppc x --input amazon-search-terms.csv
111
+
112
+ # a judgment call, run on your own model:
113
+ node bin/weftens.js send hilbert "will this tagline survive being repeated: ..."
114
+ ```
115
+
116
+ ## What's inside
117
+
118
+ - **Deterministic auditors** (no model; run on an exported report; propose changes a human applies):
119
+ `paid-search` (Google Ads), `meta-ads` (Facebook/Instagram), `amazon-ppc`, `seo-technical`,
120
+ `seo-content`, `supply-verify`, `content-ops`.
121
+ - **Model-backed seats** (run on your AI CLI): the evaluation and marketing agents — `hilbert`,
122
+ `lippmann`, `agent2060`, `growth-marketing`, `social-voice`, `claims-content`, `security-writer`.
123
+ - **Content team**: the `content` lead runs the 10-seat media pipeline as one node.
124
+ - **Richy + PIO**: representation and operations agents with their own orchestrator/synthesizer —
125
+ `node bin/weftens.js route request.json`, or `weftens ask` / `weftens chat` for a conversation.
126
+
127
+ Routing is deterministic and explainable: a request matches an agent by its declared keywords, and the
128
+ CLI prints which words matched, so you always see why a request went where it did.
129
+
130
+ ## Configuration
131
+
132
+ - `WEFTENS_PROVIDER` — which model CLI to use (default `claude`).
133
+ - `providers.json` (or `WEFTENS_PROVIDERS_FILE`) — map a provider name to its command, e.g.
134
+ `{ "providers": { "gemini": { "cmd": "gemini", "args": ["-p"] } } }`.
135
+ - `WEFTENS_WORKSPACE` — where `agent-cores` / `content-org` live (default: this repo's parent dir).
136
+ - `WEFTENS_AGENTS_DIR` — where the agent seat files live. Defaults to the copies bundled in this repo
137
+ (`agents/`), so a fresh clone runs self-contained; override to point at your own set.
138
+
139
+ ## Honest status
140
+
141
+ - **Verified:** deterministic routing, all seven auditor cores end-to-end, both specialist legs
142
+ (Richy/PIO) through the door, and BYOM model-produce via `claude`. **171 tests pass — 67 unit/CLI
143
+ (vitest) + 104 core (`node --test`)**, confirmed in a fresh clone at an unrelated path, not just in
144
+ the author's workspace.
145
+ - **Richy and PIO are interfaces here, not implementations — read this before you install.**
146
+ Two of the seats (`richy`, representation intelligence; `pio`, prospect research and grants) are
147
+ clients for **separate services that are not part of this repo and are not open source**. What ships
148
+ here is the seat definition, the wire client, the orchestrator that joins their answers, and
149
+ fixtures. Point `WEFTENS_RICHY_URL`/`WEFTENS_RICHY_KEY` and the `WEFTENS_PIO_*` group at running
150
+ services and they work. Without those, they return **sample data that labels itself as sample data**
151
+ (`"sample": true`, and the headline says so) — so you can see the shape of the output, and you can
152
+ never mistake it for a finding about a real business. Everything else in this repo — all seven
153
+ deterministic auditors, the router, the CLI, the SDK — runs entirely on what you just cloned.
154
+ `weftens send richy --org "Acme Co" "audit our listings"` shows you exactly this.
155
+ - **The router refuses rather than guesses.** Generic words alone ("site", "listing", "people") no
156
+ longer dispatch — a confident wrong route in front of a customer costs more than a reprompt.
157
+ - **Licensed MIT.** Free to use, including commercially.
158
+ - **Published:** `npm i weftens` installs the real thing (`weftens@0.1.0`, MIT). Verified from the
159
+ registry by a clean third-party install — `npx weftens list` prints the registry, and a
160
+ deterministic core runs a real audit from inside `node_modules`. (`0.0.1` was an empty name-holder;
161
+ ignore it.)
162
+ - **Two providers verified, not one:** `claude` and `codex` both produce end-to-end through the
163
+ user's own subscription (codex confirmed 2026-07-19). `gemini` and `grok` remain config-only — the
164
+ shape is right, but neither CLI is installed here, so neither has been run. A missing or broken
165
+ provider degrades to a plain note; it never fabricates an answer.
166
+ - **Dry-run only:** the content pipeline produces placeholder output; real content generation is gated
167
+ on open decisions.
168
+ - **Propose-only:** every ad/SEO core proposes changes and writes nothing to a live account.
169
+
170
+ ## Notes
171
+
172
+ **Weftens is the company AND this front door — one thing, not two.** Richy and PIO are two of the
173
+ agent seats behind it, not standalone products under a brand. Architecture and agent-wiring detail:
174
+ [`ARCHITECTURE.md`](ARCHITECTURE.md).
175
+
176
+ Invariants that must not break: each agent stays independently runnable; PIO's wire-shape
177
+ (`grantengine_session` cookie, `x-grantengine-*` headers) is frozen; the privacy spine — sanitized
178
+ exports, human review gates, receipts — is shared across every seat.
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@agent-cores/amazon-ppc",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Amazon PPC search-term optimizer. Deterministic: parse a search-term report + margin/target-ACOS, compute harvest→negate→promote→re-bid proposals. PROPOSE-ONLY. No LLM in the core.",
7
+ "bin": {
8
+ "amazon-ppc": "src/cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test",
12
+ "ppc": "node src/cli.mjs"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ }
17
+ }
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ // Real CLI. Reads an Amazon search-term report (CSV/TSV/JSON) from a file path,
3
+ // computes the PROPOSE-ONLY change sheet, prints JSON on stdout and a one-line
4
+ // summary on stderr.
5
+ //
6
+ // amazon-ppc <report.csv> --target-acos 25 --margin 35
7
+ // amazon-ppc <report.json> --low-inventory "Auto - Garlic" --pretty
8
+ //
9
+ // Flags:
10
+ // --target-acos <n> target ACOS (percent or fraction)
11
+ // --margin <n> product contribution margin (percent or fraction)
12
+ // --neg-clicks <n> zero-order click threshold to negate (default 10)
13
+ // --neg-spend <n> zero-order spend threshold to negate (default 5)
14
+ // --low-inventory <c> campaign to suppress scaling on (repeatable)
15
+ // --pretty pretty-print JSON
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { optimize } from './optimize.mjs';
19
+
20
+ function parseArgs(argv) {
21
+ const args = { _: [], lowInventory: [] };
22
+ for (let i = 0; i < argv.length; i += 1) {
23
+ const a = argv[i];
24
+ if (a === '--target-acos') args.targetAcos = Number(argv[++i]);
25
+ else if (a === '--margin') args.margin = Number(argv[++i]);
26
+ else if (a === '--neg-clicks') args.negClickThreshold = Number(argv[++i]);
27
+ else if (a === '--neg-spend') args.negSpendThreshold = Number(argv[++i]);
28
+ else if (a === '--low-inventory') args.lowInventory.push(argv[++i]);
29
+ else if (a === '--pretty') args.pretty = true;
30
+ else args._.push(a);
31
+ }
32
+ return args;
33
+ }
34
+
35
+ function main() {
36
+ const args = parseArgs(process.argv.slice(2));
37
+ const file = args._[0];
38
+ if (!file) {
39
+ console.error('Usage: amazon-ppc <report.csv|.json> [--target-acos 25] [--margin 35] [--low-inventory "Campaign"] [--pretty]');
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+
44
+ const text = readFileSync(file, 'utf8');
45
+ const cfg = {};
46
+ if (args.targetAcos !== undefined) cfg.targetAcos = args.targetAcos;
47
+ if (args.margin !== undefined) cfg.margin = args.margin;
48
+ if (args.negClickThreshold !== undefined) cfg.negClickThreshold = args.negClickThreshold;
49
+ if (args.negSpendThreshold !== undefined) cfg.negSpendThreshold = args.negSpendThreshold;
50
+ if (args.lowInventory.length) cfg.lowInventoryCampaigns = args.lowInventory;
51
+
52
+ const result = optimize(text, cfg);
53
+
54
+ console.error(
55
+ `amazon-ppc: ${result.totals.terms} terms, $${result.totals.spend} spend, ` +
56
+ `ACOS ${result.totals.acos ?? 'n/a'}% (target ${result.config.targetAcos}%) — ${result.summary.headline}`
57
+ );
58
+ console.log(JSON.stringify(result, null, args.pretty ? 2 : 0));
59
+ }
60
+
61
+ try {
62
+ main();
63
+ } catch (err) {
64
+ console.error(err instanceof Error ? err.message : String(err));
65
+ process.exitCode = 1;
66
+ }
@@ -0,0 +1,272 @@
1
+ // Deterministic Amazon PPC optimizer. No LLM. Given normalized search-term rows
2
+ // and an economics config (target ACOS derived from product margin), compute the
3
+ // harvest -> negate -> promote -> re-bid change sheet. PROPOSE-ONLY: every row is
4
+ // a proposal a human applies in the account, with the numbers attached.
5
+ //
6
+ // The whole thing is arithmetic and thresholds. Same input -> same output.
7
+
8
+ import { normalizeRows } from './parse.mjs';
9
+
10
+ const round2 = (n) => Math.round((n + Number.EPSILON) * 100) / 100;
11
+ const pct = (n) => round2(n * 100); // fraction -> percentage number
12
+
13
+ // A value like 25 (percent) or 0.25 (fraction) -> fraction.
14
+ function asFraction(v) {
15
+ if (v === undefined || v === null) return null;
16
+ const n = Number(v);
17
+ if (Number.isNaN(n)) return null;
18
+ return n > 1 ? n / 100 : n;
19
+ }
20
+
21
+ export const DEFAULTS = {
22
+ targetAcos: 0.25, // fraction; where a term's ACOS should land for profit
23
+ margin: null, // product contribution margin (fraction). Break-even ACOS = margin.
24
+ negClickThreshold: 10, // clicks with zero orders => repeat waste => negate
25
+ negSpendThreshold: 5, // OR spend (with zero orders) at/above this => negate
26
+ minPromoteOrders: 2, // orders needed to call a term a signal, not luck
27
+ minPromoteClicks: 10, // clicks needed before promotion is credible
28
+ minRebidClicks: 8, // clicks needed before we trust a re-bid
29
+ rebidDeadband: 0.1, // ignore bid changes smaller than +/-10%
30
+ maxRaise: 1.0, // cap a single-cycle bid increase to +100%
31
+ maxCut: 0.5, // cap a single-cycle bid decrease to -50%
32
+ promoteBidCap: 1.5, // starting exact bid capped to 1.5x current effective CPC
33
+ bidFloor: 0.02, // Amazon minimum bid
34
+ lowInventoryCampaigns: [], // campaigns near stock-out: suppress scaling proposals
35
+ };
36
+
37
+ function resolveConfig(cfg = {}) {
38
+ const out = { ...DEFAULTS, ...cfg };
39
+ out.margin = asFraction(cfg.margin ?? out.margin);
40
+ // If only margin is given, target sits below break-even for actual profit.
41
+ if (cfg.targetAcos === undefined && out.margin != null) {
42
+ out.targetAcos = round2(out.margin * 0.75);
43
+ } else {
44
+ out.targetAcos = asFraction(out.targetAcos) ?? DEFAULTS.targetAcos;
45
+ }
46
+ out.breakEvenAcos = out.margin; // may be null if margin unknown
47
+ const lc = new Set((out.lowInventoryCampaigns || []).map((s) => String(s).toLowerCase()));
48
+ out.isLowInventory = (campaign) => lc.has(String(campaign).toLowerCase());
49
+ return out;
50
+ }
51
+
52
+ function isExactMatch(row) {
53
+ return /exact/i.test(row.matchType) || /exact/i.test(row.campaign);
54
+ }
55
+ function isAuto(row) {
56
+ return /auto/i.test(row.matchType) || /auto/i.test(row.campaign) || row.matchType === '' || row.matchType === '-';
57
+ }
58
+
59
+ function termAcos(row) {
60
+ return row.sales > 0 ? row.spend / row.sales : Infinity;
61
+ }
62
+
63
+ // Effective per-click bid we observed. Prefer reported CPC, else spend/clicks.
64
+ function effectiveCpc(row) {
65
+ if (row.cpc > 0) return row.cpc;
66
+ return row.clicks > 0 ? row.spend / row.clicks : 0;
67
+ }
68
+
69
+ // The bid at which, at this term's observed revenue-per-click, ACOS == target.
70
+ // rev/click * targetAcos. This is the economic anchor for every bid decision.
71
+ function idealBid(row, targetAcos) {
72
+ const rpc = row.clicks > 0 ? row.sales / row.clicks : 0;
73
+ return rpc * targetAcos;
74
+ }
75
+
76
+ export function optimize(input, cfg = {}) {
77
+ const config = resolveConfig(cfg);
78
+ const rows = Array.isArray(input) && typeof input[0] === 'object' && input[0]?.searchTerm !== undefined
79
+ ? input
80
+ : normalizeRows(input);
81
+
82
+ const negatives = [];
83
+ const promotions = [];
84
+ const rebids = [];
85
+ const held = [];
86
+
87
+ const totals = { terms: 0, impressions: 0, clicks: 0, spend: 0, sales: 0, orders: 0 };
88
+
89
+ for (const row of rows) {
90
+ totals.terms += 1;
91
+ totals.impressions += row.impressions;
92
+ totals.clicks += row.clicks;
93
+ totals.spend += round2(row.spend);
94
+ totals.sales += round2(row.sales);
95
+ totals.orders += row.orders;
96
+
97
+ const acos = termAcos(row);
98
+ const cpc = effectiveCpc(row);
99
+ const ideal = idealBid(row, config.targetAcos);
100
+ const lowInv = config.isLowInventory(row.campaign);
101
+
102
+ // 1. NEGATE — repeat waste: clicks/spend accrued, zero orders.
103
+ if (row.orders === 0 && (row.clicks >= config.negClickThreshold || row.spend >= config.negSpendThreshold)) {
104
+ negatives.push({
105
+ term: row.searchTerm,
106
+ campaign: row.campaign,
107
+ matchType: row.matchType || (isAuto(row) ? 'auto' : ''),
108
+ clicks: row.clicks,
109
+ spend: round2(row.spend),
110
+ orders: 0,
111
+ spendStopped: round2(row.spend),
112
+ reason: row.clicks >= config.negClickThreshold
113
+ ? `${row.clicks} clicks, 0 orders (>= ${config.negClickThreshold}-click waste bar)`
114
+ : `$${round2(row.spend)} spent, 0 orders (>= $${config.negSpendThreshold} waste bar)`,
115
+ });
116
+ continue;
117
+ }
118
+
119
+ // 2. PROMOTE — a converting non-exact winner isolated into its own exact campaign.
120
+ const meetsPromoteBar =
121
+ row.orders >= config.minPromoteOrders &&
122
+ row.clicks >= config.minPromoteClicks &&
123
+ acos <= config.targetAcos &&
124
+ !isExactMatch(row);
125
+
126
+ if (meetsPromoteBar) {
127
+ if (lowInv) {
128
+ held.push({
129
+ term: row.searchTerm,
130
+ campaign: row.campaign,
131
+ action: 'promote-suppressed',
132
+ reason: `winner (ACOS ${pct(acos)}%) but campaign flagged low inventory — do not scale into a stock-out`,
133
+ });
134
+ continue;
135
+ }
136
+ const rpc = row.sales / row.clicks;
137
+ const cap = cpc > 0 ? cpc * config.promoteBidCap : ideal;
138
+ const capped = ideal > cap;
139
+ const startingBid = Math.max(config.bidFloor, round2(capped ? cap : ideal));
140
+ promotions.push({
141
+ term: row.searchTerm,
142
+ sourceCampaign: row.campaign,
143
+ sourceMatchType: row.matchType || (isAuto(row) ? 'auto' : ''),
144
+ clicks: row.clicks,
145
+ orders: row.orders,
146
+ sales: round2(row.sales),
147
+ acos: pct(acos),
148
+ newCampaign: `Exact - ${row.searchTerm}`,
149
+ newMatchType: 'exact',
150
+ startingBid,
151
+ capped,
152
+ negateInSource: isAuto(row),
153
+ marginMath:
154
+ `${row.orders} orders / ${row.clicks} clicks, $${round2(row.sales)} sales -> ` +
155
+ `$${round2(rpc)} rev/click x ${pct(config.targetAcos)}% target ACOS = $${round2(ideal)} ideal bid` +
156
+ (capped ? `, capped to ${config.promoteBidCap}x CPC ($${round2(cpc)}) = $${startingBid}` : ` = $${startingBid}`),
157
+ });
158
+ continue;
159
+ }
160
+
161
+ // 3. RE-BID — converting term whose current CPC is off its economic ideal bid.
162
+ if (row.orders >= 1 && row.clicks >= config.minRebidClicks && cpc > 0 && ideal > 0) {
163
+ const rawRatio = ideal / cpc; // >1 underpaying (raise), <1 overpaying (cut)
164
+ const delta = Math.abs(rawRatio - 1);
165
+ if (delta >= config.rebidDeadband) {
166
+ const up = ideal > cpc;
167
+ if (up && lowInv) {
168
+ held.push({
169
+ term: row.searchTerm,
170
+ campaign: row.campaign,
171
+ action: 'raise-suppressed',
172
+ reason: `bid-up justified (ACOS ${pct(acos)}%) but campaign flagged low inventory — hold`,
173
+ });
174
+ continue;
175
+ }
176
+ let target = ideal;
177
+ let capped = false;
178
+ if (up) {
179
+ const ceiling = cpc * (1 + config.maxRaise);
180
+ if (target > ceiling) { target = ceiling; capped = true; }
181
+ } else {
182
+ const floorByCut = cpc * (1 - config.maxCut);
183
+ if (target < floorByCut) { target = floorByCut; capped = true; }
184
+ }
185
+ const newBid = Math.max(config.bidFloor, round2(target));
186
+ const oldBid = round2(cpc);
187
+ const overBreakEven = config.breakEvenAcos != null && acos > config.breakEvenAcos;
188
+ rebids.push({
189
+ term: row.searchTerm,
190
+ campaign: row.campaign,
191
+ matchType: row.matchType || (isExactMatch(row) ? 'exact' : ''),
192
+ clicks: row.clicks,
193
+ orders: row.orders,
194
+ acos: pct(acos),
195
+ currentCpc: oldBid,
196
+ oldBid,
197
+ newBid,
198
+ direction: up ? 'up' : 'down',
199
+ changePct: pct((newBid - oldBid) / oldBid),
200
+ capped,
201
+ why:
202
+ `ACOS ${pct(acos)}% vs ${pct(config.targetAcos)}% target; ideal bid $${round2(ideal)} ` +
203
+ `${up ? 'above' : 'below'} current $${oldBid} -> ${up ? 'raise' : 'cut'} to $${newBid}` +
204
+ (capped ? ` (capped at ${up ? '+' : '-'}${pct(up ? config.maxRaise : config.maxCut)}%)` : '') +
205
+ (overBreakEven ? ` [losing: ACOS above ${pct(config.breakEvenAcos)}% break-even]` : ''),
206
+ });
207
+ continue;
208
+ }
209
+ }
210
+
211
+ // Otherwise: hold. Not enough signal, or already on target.
212
+ held.push({
213
+ term: row.searchTerm,
214
+ campaign: row.campaign,
215
+ action: 'hold',
216
+ reason: row.orders === 0
217
+ ? `${row.clicks} clicks, 0 orders — below the ${config.negClickThreshold}-click / $${config.negSpendThreshold} negate bar (watch)`
218
+ : `on target or insufficient click signal (${row.clicks} clicks)`,
219
+ });
220
+ }
221
+
222
+ // Deterministic ordering: biggest lever first, term as stable tie-breaker.
223
+ negatives.sort((a, b) => b.spendStopped - a.spendStopped || a.term.localeCompare(b.term));
224
+ promotions.sort((a, b) => b.sales - a.sales || a.term.localeCompare(b.term));
225
+ rebids.sort((a, b) => Math.abs(b.changePct) - Math.abs(a.changePct) || a.term.localeCompare(b.term));
226
+ held.sort((a, b) => a.term.localeCompare(b.term));
227
+
228
+ const accountAcos = totals.sales > 0 ? totals.spend / totals.sales : Infinity;
229
+ const estSpendStopped = round2(negatives.reduce((s, n) => s + n.spendStopped, 0));
230
+
231
+ return {
232
+ config: {
233
+ targetAcos: pct(config.targetAcos),
234
+ margin: config.margin != null ? pct(config.margin) : null,
235
+ breakEvenAcos: config.breakEvenAcos != null ? pct(config.breakEvenAcos) : null,
236
+ negClickThreshold: config.negClickThreshold,
237
+ negSpendThreshold: config.negSpendThreshold,
238
+ minPromoteOrders: config.minPromoteOrders,
239
+ minPromoteClicks: config.minPromoteClicks,
240
+ lowInventoryCampaigns: config.lowInventoryCampaigns,
241
+ },
242
+ totals: {
243
+ ...totals,
244
+ spend: round2(totals.spend),
245
+ sales: round2(totals.sales),
246
+ acos: totals.sales > 0 ? pct(accountAcos) : null,
247
+ },
248
+ summary: {
249
+ negatives: negatives.length,
250
+ estSpendStopped,
251
+ promotions: promotions.length,
252
+ rebids: rebids.length,
253
+ rebidsUp: rebids.filter((r) => r.direction === 'up').length,
254
+ rebidsDown: rebids.filter((r) => r.direction === 'down').length,
255
+ held: held.length,
256
+ headline: buildHeadline({ negatives, promotions, rebids, estSpendStopped }),
257
+ },
258
+ negatives,
259
+ promotions,
260
+ rebids,
261
+ held,
262
+ };
263
+ }
264
+
265
+ function buildHeadline({ negatives, promotions, rebids, estSpendStopped }) {
266
+ const parts = [];
267
+ if (negatives.length) parts.push(`${negatives.length} negatives (~$${estSpendStopped} waste stopped)`);
268
+ if (promotions.length) parts.push(`${promotions.length} winners to promote`);
269
+ if (rebids.length) parts.push(`${rebids.length} re-bids`);
270
+ if (parts.length === 0) return 'No proposed changes — every term is on target or below signal thresholds.';
271
+ return `PROPOSE-ONLY change sheet: ${parts.join(', ')}.`;
272
+ }