weftens 0.0.1 → 0.1.0

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 (100) hide show
  1. package/README.md +138 -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 +226 -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/invoke.js +129 -0
  94. package/src/pio-client.js +142 -0
  95. package/src/provider.js +81 -0
  96. package/src/registry.js +160 -0
  97. package/src/richy-client.js +216 -0
  98. package/src/router.js +34 -0
  99. package/src/synthesize.js +111 -0
  100. package/src/weftens.js +61 -0
@@ -0,0 +1,121 @@
1
+ // Dependency-free parsing for CMS exports. Two input shapes:
2
+ // - CSV (RFC-4180-ish: quoted fields, escaped quotes "", commas/newlines inside quotes)
3
+ // - JSON (an array of row objects, or { rows: [...] } / { items: [...] })
4
+ //
5
+ // Both normalize to an array of raw row objects keyed by the export's own headers.
6
+ // Field-name normalization to the canonical schema happens in inventory.mjs.
7
+
8
+ // --- CSV ---
9
+
10
+ // State-machine parser. Handles quoted fields, "" escapes, and CR/LF inside quotes.
11
+ // Returns an array of arrays (rows of string cells).
12
+ export function parseCsv(text) {
13
+ const rows = [];
14
+ let row = [];
15
+ let field = '';
16
+ let inQuotes = false;
17
+ let i = 0;
18
+ const n = text.length;
19
+ let started = false; // did we see any char on this line?
20
+
21
+ const pushField = () => {
22
+ row.push(field);
23
+ field = '';
24
+ };
25
+ const pushRow = () => {
26
+ row.push(field);
27
+ field = '';
28
+ rows.push(row);
29
+ row = [];
30
+ started = false;
31
+ };
32
+
33
+ while (i < n) {
34
+ const c = text[i];
35
+
36
+ if (inQuotes) {
37
+ if (c === '"') {
38
+ if (text[i + 1] === '"') {
39
+ field += '"';
40
+ i += 2;
41
+ continue;
42
+ }
43
+ inQuotes = false;
44
+ i += 1;
45
+ continue;
46
+ }
47
+ field += c;
48
+ i += 1;
49
+ continue;
50
+ }
51
+
52
+ if (c === '"') {
53
+ inQuotes = true;
54
+ started = true;
55
+ i += 1;
56
+ continue;
57
+ }
58
+ if (c === ',') {
59
+ pushField();
60
+ started = true;
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (c === '\r') {
65
+ // swallow CR; handle the LF (or lone CR) as the line break
66
+ if (text[i + 1] === '\n') i += 1;
67
+ pushRow();
68
+ i += 1;
69
+ continue;
70
+ }
71
+ if (c === '\n') {
72
+ pushRow();
73
+ i += 1;
74
+ continue;
75
+ }
76
+ field += c;
77
+ started = true;
78
+ i += 1;
79
+ }
80
+
81
+ // flush trailing field/row if the file doesn't end in a newline
82
+ if (field.length > 0 || row.length > 0 || started) pushRow();
83
+
84
+ return rows;
85
+ }
86
+
87
+ // CSV text -> array of row objects keyed by header. Blank lines are dropped.
88
+ export function csvToRecords(text) {
89
+ const rows = parseCsv(text).filter((r) => !(r.length === 1 && r[0].trim() === ''));
90
+ if (rows.length === 0) return [];
91
+ const headers = rows[0].map((h) => h.trim());
92
+ const records = [];
93
+ for (let r = 1; r < rows.length; r += 1) {
94
+ const cells = rows[r];
95
+ const obj = {};
96
+ for (let c = 0; c < headers.length; c += 1) {
97
+ obj[headers[c]] = cells[c] !== undefined ? cells[c] : '';
98
+ }
99
+ records.push(obj);
100
+ }
101
+ return records;
102
+ }
103
+
104
+ // --- dispatch ---
105
+
106
+ // Auto-detect JSON vs CSV from the text and return raw row objects.
107
+ export function parseExport(text, hint = null) {
108
+ const trimmed = text.replace(/^/, '').trimStart(); // strip BOM
109
+ const looksJson = hint === 'json' || (hint !== 'csv' && (trimmed.startsWith('[') || trimmed.startsWith('{')));
110
+
111
+ if (looksJson) {
112
+ const data = JSON.parse(trimmed);
113
+ if (Array.isArray(data)) return data;
114
+ if (data && Array.isArray(data.rows)) return data.rows;
115
+ if (data && Array.isArray(data.items)) return data.items;
116
+ if (data && Array.isArray(data.records)) return data.records;
117
+ throw new Error('JSON export must be an array, or an object with a rows/items/records array.');
118
+ }
119
+
120
+ return csvToRecords(text);
121
+ }
@@ -0,0 +1,13 @@
1
+ url,title,published,last-updated,word count,pageviews,meta description,author
2
+ https://acme.example/old-news-2019,Old News From 2019,2019-01-10,2019-01-15,800,12,An announcement from early 2019 that has not been touched since.,J. Smith
3
+ https://acme.example/tags/misc,Miscellaneous Tag Archive Page,2025-12-20,2026-01-01,90,8,,A. Bot
4
+ https://acme.example/pricing-guide,Pricing Guide,2022-06-01,2024-03-01,2400,4200,"This is a deliberately overlong meta description written to exceed the recommended one hundred and sixty character ceiling so the engine flags it as too long for a snippet.",M. Lee
5
+ https://acme.example/,Acme — Home,2020-01-01,2026-06-15,650,9000,"The homepage of Acme, updated recently and carrying the most traffic on the whole site.",Team
6
+ https://acme.example/blog/mid-article,A Mid-Traffic Article Worth Refreshing,2021-04-01,2025-05-01,1200,30,A long-form article with modest traffic that has aged past a year without a refresh.,J. Smith
7
+ https://acme.example/blog/new-post,A Freshly Published Post,2026-06-18,2026-06-20,900,20,A brand new post that has not accumulated traffic yet but is current.,A. Bot
8
+ https://acme.example/docs/reference,The Complete and Exhaustive Reference Documentation for Every Single Public API Endpoint,2019-05-01,2023-01-01,5000,200,Long-lived reference doc with steady moderate traffic that has decayed past two years.,M. Lee
9
+ https://acme.example/guide,Complete Guide,2024-01-01,2026-03-01,1800,300,A moderately trafficked guide kept current within the last few months of the reporting window.,Team
10
+ https://acme.example/reports/quarterly,Quarterly Report Archive,2025-01-01,2025-01-01,700,,An archived report where the traffic column was never populated by the analytics export.,J. Smith
11
+ https://acme.example/team/jane-doe,Jane Doe,2026-04-01,2026-05-01,400,600,,Team
12
+ https://acme.example/guide-legacy,Complete Guide,2023-10-01,2026-02-01,1500,150,A second page sharing the exact same title as the primary guide — a taxonomy collision.,Team
13
+ https://acme.example/orphan,Orphaned Page With No Dates,,,200,5,A page whose export row is missing both the published and the last-updated date fields.,A. Bot
@@ -0,0 +1,31 @@
1
+ {
2
+ "rows": [
3
+ {
4
+ "url": "https://acme.example/legacy-faq",
5
+ "title": "Legacy FAQ",
6
+ "published": "2018-03-01",
7
+ "lastUpdated": "2018-09-01",
8
+ "wordCount": 1100,
9
+ "pageviews": 7,
10
+ "metaDescription": "An old FAQ nobody has updated in years and almost nobody reads."
11
+ },
12
+ {
13
+ "url": "https://acme.example/flagship",
14
+ "title": "Flagship Product Overview",
15
+ "published": "2021-01-01",
16
+ "lastUpdated": "2023-06-01",
17
+ "wordCount": 2200,
18
+ "pageviews": 8800,
19
+ "metaDescription": "The overview page for the flagship product, high traffic but overdue for a refresh."
20
+ },
21
+ {
22
+ "url": "https://acme.example/whats-new",
23
+ "title": "What's New",
24
+ "published": "2026-05-01",
25
+ "lastUpdated": "2026-05-15",
26
+ "wordCount": 500,
27
+ "pageviews": 640,
28
+ "metaDescription": "The most recent changelog entry with strong traffic and a current date."
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,177 @@
1
+ // Offline tests — run with `node --test`, zero install. Asserts the engine makes
2
+ // the specific keep/update/retire decisions the seeded fixture demands, flags the
3
+ // seeded metadata gaps, and is deterministic. If these pass, the core is doing real
4
+ // arithmetic on real data — not echoing a prompt.
5
+
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { dirname, join } from 'node:path';
11
+ import { inventoryFromText, buildInventory, DEFAULT_THRESHOLDS } from '../src/inventory.mjs';
12
+ import { parseCsv, csvToRecords } from '../src/parse.mjs';
13
+
14
+ const __dirname = dirname(fileURLToPath(import.meta.url));
15
+ const csvText = readFileSync(join(__dirname, 'fixtures', 'cms-export.csv'), 'utf8');
16
+ const jsonText = readFileSync(join(__dirname, 'fixtures', 'cms-export.json'), 'utf8');
17
+
18
+ // Pin asOf so hand-computed staleness matches the seeded expectations.
19
+ const ASOF = '2026-07-01';
20
+ const run = () => inventoryFromText(csvText, 'csv', { asOf: ASOF });
21
+ const byUrl = (r, u) => r.items.find((it) => it.url === u);
22
+
23
+ test('CSV parser handles quoted fields with embedded commas', () => {
24
+ const rows = parseCsv('a,b\n"x, y",z\n');
25
+ assert.deepEqual(rows, [['a', 'b'], ['x, y', 'z']]);
26
+ // the seeded homepage row has a comma inside a quoted meta description
27
+ const recs = csvToRecords(csvText);
28
+ const home = recs.find((r) => r.url === 'https://acme.example/');
29
+ assert.equal(home.author, 'Team', 'quoted comma must not shift the author column');
30
+ assert.match(home['meta description'], /Acme, updated recently/);
31
+ });
32
+
33
+ test('retires stale + low-traffic content with a redirect requirement', () => {
34
+ const r = run();
35
+ const it = byUrl(r, 'https://acme.example/old-news-2019');
36
+ assert.equal(it.decision, 'retire');
37
+ assert.match(it.reasons.join(' '), /stale.*low traffic|low traffic.*stale/);
38
+ assert.equal(it.redirectTarget, null, 'redirect target is an unresolved decision, not omitted');
39
+ });
40
+
41
+ test('retires thin + low-traffic content as ROT even when recent', () => {
42
+ const r = run();
43
+ const it = byUrl(r, 'https://acme.example/tags/misc');
44
+ assert.equal(it.decision, 'retire');
45
+ assert.match(it.reasons.join(' '), /thin content/);
46
+ });
47
+
48
+ test('updates high-traffic pages that have gone stale (never retires them)', () => {
49
+ const r = run();
50
+ const it = byUrl(r, 'https://acme.example/pricing-guide');
51
+ assert.equal(it.decision, 'update');
52
+ assert.match(it.reasons.join(' '), /high traffic/);
53
+ });
54
+
55
+ test('keeps high-traffic fresh content', () => {
56
+ const r = run();
57
+ const it = byUrl(r, 'https://acme.example/');
58
+ assert.equal(it.decision, 'keep');
59
+ });
60
+
61
+ test('updates a low-but-aging article that is not yet ROT', () => {
62
+ const r = run();
63
+ const it = byUrl(r, 'https://acme.example/blog/mid-article');
64
+ assert.equal(it.decision, 'update');
65
+ });
66
+
67
+ test('updates decayed moderate-traffic reference docs', () => {
68
+ const r = run();
69
+ const it = byUrl(r, 'https://acme.example/docs/reference');
70
+ assert.equal(it.decision, 'update');
71
+ });
72
+
73
+ test('routes rows with insufficient data to review, never a guessed decision', () => {
74
+ const r = run();
75
+ const noTraffic = byUrl(r, 'https://acme.example/reports/quarterly');
76
+ assert.equal(noTraffic.decision, 'review');
77
+ assert.match(noTraffic.reasons.join(' '), /pageviews/);
78
+
79
+ const noDate = byUrl(r, 'https://acme.example/orphan');
80
+ assert.equal(noDate.decision, 'review');
81
+ assert.equal(noDate.confidence, 'low');
82
+ });
83
+
84
+ test('decision counts match the seeded fixture exactly', () => {
85
+ const r = run();
86
+ assert.deepEqual(r.counts, { total: 12, keep: 5, update: 3, retire: 2, review: 2 });
87
+ });
88
+
89
+ test('flags the seeded metadata gaps (missing desc, short/long title, long desc)', () => {
90
+ const r = run();
91
+ const gapFor = (url, field) =>
92
+ r.metadataGaps.find((g) => g.url === url && g.field === field);
93
+
94
+ // tags/misc has an empty meta description
95
+ assert.ok(gapFor('https://acme.example/tags/misc', 'metaDescription'));
96
+ // jane-doe bio: title too short AND meta description missing (tier-1 page)
97
+ const jane = byUrl(r, 'https://acme.example/team/jane-doe');
98
+ assert.ok(jane.metadataGaps.some((g) => g.field === 'title' && /too short/.test(g.issue)));
99
+ assert.ok(jane.metadataGaps.some((g) => g.field === 'metaDescription' && g.issue === 'missing'));
100
+ // pricing-guide meta description exceeds 160 chars
101
+ const pricing = byUrl(r, 'https://acme.example/pricing-guide');
102
+ assert.ok(pricing.metadataGaps.some((g) => g.field === 'metaDescription' && /too long/.test(g.issue)));
103
+ // reference doc title exceeds 65 chars
104
+ const ref = byUrl(r, 'https://acme.example/docs/reference');
105
+ assert.ok(ref.metadataGaps.some((g) => g.field === 'title' && /too long/.test(g.issue)));
106
+ });
107
+
108
+ test('a missing date is a blocking (fail) gap', () => {
109
+ const r = run();
110
+ const orphan = byUrl(r, 'https://acme.example/orphan');
111
+ assert.ok(orphan.metadataGaps.some((g) => g.field === 'date' && g.severity === 'fail'));
112
+ assert.ok(r.migrationReadiness.blockingGaps >= 1);
113
+ });
114
+
115
+ test('detects duplicate titles across URLs (taxonomy collision)', () => {
116
+ const r = run();
117
+ const dup = r.duplicates.titles.find((d) => d.title === 'complete guide');
118
+ assert.ok(dup, 'the two "Complete Guide" pages are flagged');
119
+ assert.equal(dup.count, 2);
120
+ });
121
+
122
+ test('surfaces source fields with no canonical destination', () => {
123
+ const r = run();
124
+ assert.ok(r.unmappedFields.includes('author'), '"author" has no mapped destination');
125
+ });
126
+
127
+ test('migration readiness: redirect ledger, blockers, and a bounded score', () => {
128
+ const r = run();
129
+ const m = r.migrationReadiness;
130
+ assert.equal(m.redirectsRequired, 2);
131
+ assert.equal(m.redirectLedger.length, 2);
132
+ assert.ok(m.redirectLedger.every((e) => e.to === null && e.resolved === false && e.status === 301));
133
+ assert.ok(m.blockers.some((b) => /redirect target/.test(b)));
134
+ assert.ok(m.readinessScore >= 0 && m.readinessScore <= 100);
135
+ assert.equal(typeof m.fieldCoverage.pageviews.pct, 'number');
136
+ });
137
+
138
+ test('same input -> byte-identical output (deterministic, no wall clock)', () => {
139
+ const a = run();
140
+ const b = run();
141
+ assert.equal(JSON.stringify(a), JSON.stringify(b));
142
+ });
143
+
144
+ test('default asOf is the latest date in the export, not Date.now()', () => {
145
+ const r = inventoryFromText(csvText, 'csv'); // no asOf passed
146
+ assert.equal(r.asOf, '2026-06-20', 'latest last-updated in the CSV drives asOf');
147
+ // and it is still deterministic across runs
148
+ const r2 = inventoryFromText(csvText, 'csv');
149
+ assert.equal(JSON.stringify(r), JSON.stringify(r2));
150
+ });
151
+
152
+ test('JSON export path works and applies the same decision logic', () => {
153
+ const r = inventoryFromText(jsonText, 'json');
154
+ assert.equal(byUrl(r, 'https://acme.example/legacy-faq').decision, 'retire');
155
+ assert.equal(byUrl(r, 'https://acme.example/flagship').decision, 'update');
156
+ assert.equal(byUrl(r, 'https://acme.example/whats-new').decision, 'keep');
157
+ });
158
+
159
+ test('empty export yields a well-formed, non-crashing result', () => {
160
+ const r = buildInventory([]);
161
+ assert.equal(r.counts.total, 0);
162
+ assert.equal(r.migrationReadiness.readinessScore, 0);
163
+ assert.ok(r.migrationReadiness.blockers.length > 0);
164
+ });
165
+
166
+ test('thresholds are overridable and change decisions predictably', () => {
167
+ // Baseline: /guide is mid-traffic (300 views) and fresh (122 days) -> keep.
168
+ const base = run();
169
+ assert.equal(base.items.find((it) => it.url === 'https://acme.example/guide').decision, 'keep');
170
+ // Tighten veryStaleDays below 122: the same page now reads as decayed -> update.
171
+ const strict = inventoryFromText(csvText, 'csv', {
172
+ asOf: ASOF,
173
+ thresholds: { ...DEFAULT_THRESHOLDS, veryStaleDays: 100 },
174
+ });
175
+ const guide = strict.items.find((it) => it.url === 'https://acme.example/guide');
176
+ assert.equal(guide.decision, 'update');
177
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@agent-cores/meta-ads",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Meta (Facebook/Instagram) ads auditor. Deterministic: parse an Ads Manager export, compute a ranked PROPOSE-ONLY change sheet (pause wasted spend, creative fatigue, low ROAS, cost-per-result inefficiency). No LLM in the core.",
7
+ "bin": {
8
+ "meta-ads": "src/cli.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "node --test"
12
+ },
13
+ "engines": {
14
+ "node": ">=20"
15
+ }
16
+ }
@@ -0,0 +1,166 @@
1
+ // Deterministic Meta (Facebook/Instagram) ads analysis. No LLM. PROPOSE-ONLY.
2
+ //
3
+ // Input: parsed rows (canonical fields from parse.mjs), one per breakdown entity — an
4
+ // ad, an ad set, or a campaign. Output: a ranked change sheet; every row carries the
5
+ // evidence numbers that triggered it. The core never calls a model and never touches an
6
+ // account — a human applies the proposed changes.
7
+ //
8
+ // Four finding families, mirroring the Meta buyer's job:
9
+ // pause — spend with zero results (burning budget, nothing back)
10
+ // low-roas — purchases tracked but ROAS < 1 (losing money on every dollar)
11
+ // inefficient — cost per result far above the account average (expensive but not losing)
12
+ // fatigue — high frequency (the same people seeing it too often — refresh creative)
13
+
14
+ export const DEFAULT_THRESHOLDS = {
15
+ pauseMinSpend: 20, // spend where "0 results" is signal, not noise
16
+ lowRoasMax: 1, // ROAS < 1 loses money
17
+ lowRoasMinSpend: 30,
18
+ effCprMultiple: 3, // cost/result >= 3x the account average
19
+ effMinSpend: 30,
20
+ fatigueFrequency: 3.5, // avg impressions per person before fatigue bites
21
+ fatigueMinSpend: 20,
22
+ };
23
+
24
+ function sum(arr, f) {
25
+ let t = 0;
26
+ for (const x of arr) { const v = f(x); if (Number.isFinite(v)) t += v; }
27
+ return t;
28
+ }
29
+ function round(n, dp = 2) {
30
+ if (!Number.isFinite(n)) return n;
31
+ const m = 10 ** dp;
32
+ return Math.round(n * m) / m;
33
+ }
34
+ // Name an entity by its most granular identifier, and say which level that is.
35
+ function label(r) {
36
+ if (r.ad) return { name: r.ad, level: 'ad' };
37
+ if (r.adset) return { name: r.adset, level: 'ad set' };
38
+ return { name: r.campaign || '(unnamed)', level: 'campaign' };
39
+ }
40
+ function where(r) {
41
+ return [r.campaign, r.adset, r.ad].filter(Boolean).join(' / ') || 'account';
42
+ }
43
+
44
+ export function analyze(input, options = {}) {
45
+ const thresholds = { ...DEFAULT_THRESHOLDS, ...(options.thresholds || {}) };
46
+ const rows = Array.isArray(input) ? input : (Array.isArray(input?.rows) ? input.rows : []);
47
+
48
+ const totalSpend = sum(rows, (r) => r.spend);
49
+ const totalResults = sum(rows, (r) => r.results);
50
+ const totalValue = sum(rows, (r) => r.purchaseValue ?? (Number.isFinite(r.roas) && Number.isFinite(r.spend) ? r.roas * r.spend : 0));
51
+ const accountCPR = totalResults > 0 ? totalSpend / totalResults : null;
52
+ const accountROAS = totalSpend > 0 && totalValue > 0 ? totalValue / totalSpend : null;
53
+
54
+ const changes = [];
55
+
56
+ for (const r of rows) {
57
+ const spend = r.spend || 0;
58
+ const results = r.results || 0;
59
+ const roas = Number.isFinite(r.roas) ? r.roas : null;
60
+ const cpr = Number.isFinite(r.costPerResult) ? r.costPerResult : (results > 0 ? spend / results : null);
61
+ const { name, level } = label(r);
62
+
63
+ // 1. pause — spend, zero results
64
+ if (results === 0 && spend >= thresholds.pauseMinSpend) {
65
+ changes.push({
66
+ type: 'pause',
67
+ change: `Pause ${level} "${name}" — spend with zero results`,
68
+ where: where(r),
69
+ why: `$${round(spend)} spent, 0 results${r.linkClicks ? `, ${r.linkClicks} link clicks that never converted` : ''}`,
70
+ expectedEffect: `Recover ~$${round(spend)}/period of wasted spend`,
71
+ risk: 'If this is a new ad still in learning, give it a few days of results before killing it.',
72
+ dollars: round(spend),
73
+ evidence: { entity: name, level, spend: round(spend), results: 0, roas },
74
+ });
75
+ continue; // one primary call per entity
76
+ }
77
+
78
+ // 2. low ROAS — losing money on tracked purchases
79
+ if (roas != null && roas < thresholds.lowRoasMax && spend >= thresholds.lowRoasMinSpend) {
80
+ const value = round(spend * roas);
81
+ const loss = round(spend - value);
82
+ changes.push({
83
+ type: 'low-roas',
84
+ change: `Pause or rework ${level} "${name}" — ROAS ${round(roas, 2)} is below break-even`,
85
+ where: where(r),
86
+ why: `$${round(spend)} spent returned ~$${value} (ROAS ${round(roas, 2)}) — losing ~$${loss}`,
87
+ expectedEffect: `Stop ~$${loss}/period of net loss; reallocate to profitable ad sets`,
88
+ risk: 'ROAS can lag with a long purchase window; confirm the attribution window before pausing a recent ad.',
89
+ dollars: loss,
90
+ evidence: { entity: name, level, spend: round(spend), roas: round(roas, 2), value },
91
+ });
92
+ continue;
93
+ }
94
+
95
+ // 3. inefficient — cost per result far above account average (and not already low-roas)
96
+ if (accountCPR != null && cpr != null && results > 0 && spend >= thresholds.effMinSpend
97
+ && cpr >= thresholds.effCprMultiple * accountCPR) {
98
+ const overspend = round(spend - results * accountCPR);
99
+ changes.push({
100
+ type: 'inefficient',
101
+ change: `Reduce or restructure ${level} "${name}" — cost per result far above account average`,
102
+ where: where(r),
103
+ why: `$${round(cpr)}/result vs $${round(accountCPR)} account average (${round(cpr / accountCPR, 1)}x) on $${round(spend)} spend`,
104
+ expectedEffect: `Recover ~$${overspend}/period of over-average cost`,
105
+ risk: 'A small results count makes cost-per-result noisy; check it holds over more conversions before cutting.',
106
+ dollars: overspend,
107
+ evidence: { entity: name, level, spend: round(spend), costPerResult: round(cpr), accountCPR: round(accountCPR), results },
108
+ });
109
+ continue;
110
+ }
111
+
112
+ // 4. fatigue — high frequency (creative refresh). Not a "continue" gate: a healthy ad
113
+ // can still be fatiguing, but pause/low-roas/inefficient are more urgent when present.
114
+ if (Number.isFinite(r.frequency) && r.frequency >= thresholds.fatigueFrequency && spend >= thresholds.fatigueMinSpend) {
115
+ changes.push({
116
+ type: 'fatigue',
117
+ change: `Refresh creative on ${level} "${name}" — audience is seeing it too often`,
118
+ where: where(r),
119
+ why: `frequency ${round(r.frequency, 1)} (each person saw it ~${Math.round(r.frequency)}x) on $${round(spend)} spend`,
120
+ expectedEffect: 'New creative usually recovers CTR/CPM as fatigue resets; expand or refresh the audience too',
121
+ risk: 'High frequency on a small retargeting pool can be fine; judge against that audience size.',
122
+ dollars: round(spend * 0.25), // a quarter of spend treated as at-risk to fatigue, for ranking
123
+ evidence: { entity: name, level, frequency: round(r.frequency, 1), spend: round(spend) },
124
+ });
125
+ }
126
+ }
127
+
128
+ // rank by dollars; tiebreak by type order then a stable key
129
+ const typeRank = { pause: 0, 'low-roas': 1, inefficient: 2, fatigue: 3 };
130
+ const keyed = changes.map((c, i) => ({ ...c, _i: i }));
131
+ keyed.sort((a, b) => {
132
+ if ((b.dollars || 0) !== (a.dollars || 0)) return (b.dollars || 0) - (a.dollars || 0);
133
+ if (typeRank[a.type] !== typeRank[b.type]) return typeRank[a.type] - typeRank[b.type];
134
+ return a._i - b._i;
135
+ });
136
+ const ranked = keyed.map(({ _i, ...c }, idx) => ({ rank: idx + 1, ...c }));
137
+
138
+ const recoverable = round(sum(ranked.filter((c) => c.type === 'pause' || c.type === 'low-roas' || c.type === 'inefficient'), (c) => c.dollars), 2);
139
+
140
+ return {
141
+ proposeOnly: true,
142
+ account: {
143
+ rows: rows.length,
144
+ totalSpend: round(totalSpend),
145
+ totalResults: round(totalResults, 1),
146
+ accountCostPerResult: accountCPR == null ? null : round(accountCPR),
147
+ accountROAS: accountROAS == null ? null : round(accountROAS, 2),
148
+ },
149
+ counts: {
150
+ total: ranked.length,
151
+ pause: ranked.filter((c) => c.type === 'pause').length,
152
+ lowRoas: ranked.filter((c) => c.type === 'low-roas').length,
153
+ inefficient: ranked.filter((c) => c.type === 'inefficient').length,
154
+ fatigue: ranked.filter((c) => c.type === 'fatigue').length,
155
+ },
156
+ wastedSpendRecoverable: recoverable,
157
+ summary: buildSummary(ranked, recoverable),
158
+ changes: ranked,
159
+ };
160
+ }
161
+
162
+ function buildSummary(ranked, recoverable) {
163
+ if (ranked.length === 0) return 'No pause, ROAS, cost, or fatigue changes found in the exported report.';
164
+ return `${ranked.length} proposed change(s); ~$${recoverable} recoverable from wasted/losing/over-cost spend. `
165
+ + `Start with: ${ranked[0].change}.`;
166
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Real CLI. Reads a Meta Ads Manager export (CSV or JSON) from a file path, prints the
3
+ // PROPOSE-ONLY change sheet as JSON on stdout and a one-line summary on stderr. No
4
+ // account access.
5
+ // meta-ads <export.csv> [--pretty]
6
+ // meta-ads <export.json> (a JSON array of rows, or { "rows": [...] })
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import { analyze } from './analyze.mjs';
10
+ import { parseReport } from './parse.mjs';
11
+
12
+ function main() {
13
+ const argv = process.argv.slice(2);
14
+ const pretty = argv.includes('--pretty');
15
+ const file = argv.find((a) => !a.startsWith('--'));
16
+ if (!file) {
17
+ console.error('Usage: meta-ads <export.csv|export.json> [--pretty]');
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+ const text = readFileSync(file, 'utf8');
22
+ let rows;
23
+ if (file.endsWith('.json')) {
24
+ const parsed = JSON.parse(text);
25
+ rows = Array.isArray(parsed) ? parsed : (parsed.rows || []);
26
+ } else {
27
+ rows = parseReport(text);
28
+ }
29
+ const result = analyze(rows);
30
+ console.error(`meta-ads: ${result.summary}`);
31
+ console.log(JSON.stringify(result, null, pretty ? 2 : 0));
32
+ }
33
+
34
+ try {
35
+ main();
36
+ } catch (err) {
37
+ console.error(err instanceof Error ? err.message : String(err));
38
+ process.exitCode = 1;
39
+ }