weftens 0.1.4 → 0.1.5
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/ARCHITECTURE.md +66 -0
- package/CHANGELOG.md +63 -0
- package/README.md +12 -4
- package/agents/growth-marketing.md +1 -14
- package/agents/security-writer.md +1 -2
- package/agents/seo-content.md +1 -1
- package/agents/seo-technical.md +1 -1
- package/bin/weftens.js +8 -1
- package/package.json +6 -3
- package/src/format.js +77 -0
package/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Weftens — Architecture
|
|
2
|
+
|
|
3
|
+
How the code is organized. For what Weftens *is* and how it's positioned, see
|
|
4
|
+
the positioning doc kept outside this repo; this file is the technical map.
|
|
5
|
+
|
|
6
|
+
## The shape
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
weftens send "<request>"
|
|
10
|
+
│
|
|
11
|
+
▼
|
|
12
|
+
agent-router.js ── deterministic keyword routing → one agent (+ why)
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
15
|
+
invoke.js ── runs the chosen agent by one of three paths:
|
|
16
|
+
├── core-in-the-loop a deterministic agent-cores package runs on an exported
|
|
17
|
+
│ report and returns a checkable change sheet. No model.
|
|
18
|
+
├── content-pipeline the `content` lead runs the content-org media pipeline.
|
|
19
|
+
└── model-produce a seat's .md is the system prompt, run through the user's
|
|
20
|
+
own AI CLI (provider.js) — their subscription, no API key.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Richy and PIO have a second, older path of their own (`weftens ask`/`chat`/`route`): a
|
|
24
|
+
Claude-driven tool loop and a deterministic synthesizer (`agent.js`, `weftens.js`,
|
|
25
|
+
`router.js`, `synthesize.js`) that join representation + operations into one answer. The
|
|
26
|
+
front door routes to them; they also run standalone.
|
|
27
|
+
|
|
28
|
+
## Files
|
|
29
|
+
|
|
30
|
+
| File | Role |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `src/registry.js` | The agent directory — one entry per agent (tier, team, core, seat, routeHints). Source of truth for which agents exist. |
|
|
33
|
+
| `src/agent-router.js` | The **front-door** router. Deterministic, explainable keyword match over the registry. |
|
|
34
|
+
| `src/invoke.js` | Runs a routed agent (core CLI / content pipeline / model-produce). Degrades to a plain note; never fabricates. |
|
|
35
|
+
| `src/provider.js` | Bring-your-own-model. Shells the user's chosen AI CLI (config, not code). |
|
|
36
|
+
| `src/router.js` | The **Richy/PIO intent** classifier (representation vs operations). A sub-router, not the front door. |
|
|
37
|
+
| `src/weftens.js` | The Richy+PIO orchestrator — gathers from both over their wire contracts and synthesizes one answer. |
|
|
38
|
+
| `src/agent.js` | The Claude-driven Richy+PIO conversational agent (SDK tool loop). |
|
|
39
|
+
| `src/synthesize.js` | Deterministic join of representation + operations. |
|
|
40
|
+
| `src/richy-client.js`, `src/pio-client.js` | HTTP clients for the live Richy / PIO services (with fixtures for offline runs). |
|
|
41
|
+
| `bin/weftens.js` | The CLI: `list`, `send`, `--route`, and the older `ask`/`chat`/`route`. |
|
|
42
|
+
|
|
43
|
+
## Design rules
|
|
44
|
+
|
|
45
|
+
- **Routing is deterministic and explainable.** A request matches an agent by declared keywords; the
|
|
46
|
+
CLI shows which matched. No model call to route — the routing layer runs with no API key.
|
|
47
|
+
- **Bring-your-own-model.** Model-backed agents run on the user's own AI subscription via their CLI.
|
|
48
|
+
No key is stored, nobody is billed through Weftens.
|
|
49
|
+
- **Propose-only where it matters.** The ad/SEO cores propose changes and write nothing to a live account.
|
|
50
|
+
- **Portable.** Paths resolve relative to the install (`WEFTENS_WORKSPACE`, `WEFTENS_AGENTS_DIR`), not
|
|
51
|
+
to any one machine.
|
|
52
|
+
- **Honest degradation.** When a step can't run (no input, missing provider CLI), invoke returns a plain
|
|
53
|
+
reason — never a fabricated result.
|
|
54
|
+
|
|
55
|
+
## Invariants (do not break)
|
|
56
|
+
|
|
57
|
+
- Each agent stays independently runnable; the front door routes to them, it does not absorb them.
|
|
58
|
+
- Richy ↔ PIO integration is a PII-free wire contract, never repo coupling.
|
|
59
|
+
- PIO's wire-shape (`grantengine_session` cookie, `x-grantengine-*` headers) is frozen.
|
|
60
|
+
- The privacy spine — sanitized exports, human review gates, receipts — is shared across all names.
|
|
61
|
+
|
|
62
|
+
## Naming
|
|
63
|
+
|
|
64
|
+
Weftens is the company; **Richy** and **PIO** are named agents under it (not merged into one
|
|
65
|
+
orchestrator). The naming history and business strategy live in the memory files and `WEFTENS_PICTURE.md`,
|
|
66
|
+
not here. The repo is public and the package is on npm (2026-07-19). This file is the technical map, not a gate.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
Applied an outside code review. Four defects, each reproduced before it was fixed.
|
|
6
|
+
|
|
7
|
+
- **The SDK and the CLI disagreed.** Specialist handling — resolving which organization a request
|
|
8
|
+
is about, mapping it to an intent, calling the orchestrator — was implemented in the CLI only.
|
|
9
|
+
`weftens("audit our listings")` from the published package returned a note saying the specialist
|
|
10
|
+
couldn't be invoked, while the same request through the CLI did the real work. Every test drove
|
|
11
|
+
the CLI, so nothing caught it. That logic moved into `invoke()`, client construction moved to
|
|
12
|
+
`src/specialists.js`, and `index.js` now exports the renderer. `test/sdk.test.js` drives the
|
|
13
|
+
entry point that actually ships.
|
|
14
|
+
- **Every execution failure exited 0.** A missing input file, an absent provider CLI, a specialist
|
|
15
|
+
with no organization — all reported success. The tool wasn't scriptable, and the CI step meant to
|
|
16
|
+
prove a fresh clone works couldn't fail: with `agent-cores/` missing entirely it stayed green.
|
|
17
|
+
- **The renderer threw on malformed input**, in the one place in the stack that must not — every
|
|
18
|
+
layer upstream fails soft with a plain note. Now guarded, and unit-tested as the pure function it
|
|
19
|
+
is (19 tests, 22ms, versus four subprocesses to assert on a string builder). It also hardcoded a
|
|
20
|
+
dollar sign while parsing reports denominated in the account's currency; currency now comes from
|
|
21
|
+
the report, or the amount prints bare.
|
|
22
|
+
- **The routing confidence threshold was tuned rather than derived.** The constant meant a request
|
|
23
|
+
silently needed three weak signals while the comment claimed two, and it refused *"review the
|
|
24
|
+
site"* — an ordinary sentence at a door whose whole pitch is plain language. Five entries in the
|
|
25
|
+
weak-signal list matched nothing in the registry at all. A new registry-invariants suite makes
|
|
26
|
+
both impossible to reintroduce, and it immediately found a real one: `instagram` was claimed by
|
|
27
|
+
both the paid-social and organic-social seats, a permanent tie decided by scoring accident.
|
|
28
|
+
|
|
29
|
+
Also: `engines` relaxed to Node >=20 (nothing required 22; the core-test runner is now portable
|
|
30
|
+
instead of depending on a Node 21 glob), `exports` gained `./package.json`, the bin got its exec
|
|
31
|
+
bit, and CI grew to eight jobs — Ubuntu and Windows across Node 20/22/24, plus a job that installs
|
|
32
|
+
the actual packed tarball, because a git clone and an `npm install` are different artifacts and
|
|
33
|
+
only one is what users get.
|
|
34
|
+
|
|
35
|
+
207 tests.
|
|
36
|
+
|
|
37
|
+
## 0.1.1
|
|
38
|
+
|
|
39
|
+
Rewrote the front page for someone landing on it cold, and made the output on it real.
|
|
40
|
+
|
|
41
|
+
Change sheets were emitted as raw JSON — correct for a program, useless to the person deciding
|
|
42
|
+
whether to act. `src/format.js` renders one as plain text: rank, what to change, where, why, what
|
|
43
|
+
it's worth, and what could go wrong. `--json` still returns the raw object for piping.
|
|
44
|
+
|
|
45
|
+
Two honesty fixes, now enforced by tests. "Recoverable" became "flagged": the tool measures spend
|
|
46
|
+
against a threshold, it does not establish that the money comes back — and the top-ranked line is
|
|
47
|
+
a keyword that *converted*, so the old wording contradicted its own risk note on the same screen.
|
|
48
|
+
"Never touches your ad account" was true of the seven deterministic auditors and stated as a
|
|
49
|
+
property of all 27 seats; it is now scoped where it belongs, alongside what is finished, what is
|
|
50
|
+
model-backed, what is a client for a service not in this repo, and what is still a placeholder.
|
|
51
|
+
|
|
52
|
+
## 0.1.0
|
|
53
|
+
|
|
54
|
+
First working release. A deterministic router over 27 agent seats, seven of them deterministic
|
|
55
|
+
auditors that read an exported report and produce a ranked, propose-only change sheet with no
|
|
56
|
+
model involved. Model-backed seats run on an AI CLI the user already has and is logged into — no
|
|
57
|
+
API key, nobody billed through Weftens.
|
|
58
|
+
|
|
59
|
+
Packaging fixes in the same release: the end-to-end test resolved `agent-cores/` from the repo's
|
|
60
|
+
*parent*, so the suite passed only on a machine with a sibling checkout and failed in every fresh
|
|
61
|
+
clone. The test runner also globbed the `node --test` core suites and reported nine phantom
|
|
62
|
+
failures. Added the MIT license — a "free tool" with no license is unusable inside a company — a
|
|
63
|
+
`files` allowlist, and a README whose claims match what was actually run.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ wrong if you're wrong.
|
|
|
23
23
|
That is the actual output of the sample export included in this repo, produced by:
|
|
24
24
|
|
|
25
25
|
npm i weftens
|
|
26
|
-
npx weftens send paid-search audit --input agent-cores/paid-search/test/fixtures/search-terms.csv
|
|
26
|
+
npx weftens send paid-search audit --input node_modules/weftens/agent-cores/paid-search/test/fixtures/search-terms.csv
|
|
27
27
|
|
|
28
28
|
**Same file in, same change sheet out, every time.** The ad and SEO auditors are arithmetic, not
|
|
29
29
|
a model — nothing is generated, so nothing can be invented, and two runs can be diffed to prove
|
|
@@ -57,7 +57,7 @@ Free, MIT licensed, no account, no billing.
|
|
|
57
57
|
## Install
|
|
58
58
|
|
|
59
59
|
```
|
|
60
|
-
git clone
|
|
60
|
+
git clone https://github.com/Weftens/weftens.git
|
|
61
61
|
cd weftens
|
|
62
62
|
npm install
|
|
63
63
|
npm test
|
|
@@ -140,7 +140,7 @@ CLI prints which words matched, so you always see why a request went where it di
|
|
|
140
140
|
## Honest status
|
|
141
141
|
|
|
142
142
|
- **Verified:** deterministic routing, all seven auditor cores end-to-end, both specialist legs
|
|
143
|
-
(Richy/PIO) through the door, and BYOM model-produce via `claude`. **
|
|
143
|
+
(Richy/PIO) through the door, and BYOM model-produce via `claude`. **217 tests pass — 113 unit/CLI
|
|
144
144
|
(vitest) + 104 core (`node --test`)**, confirmed in a fresh clone at an unrelated path, not just in
|
|
145
145
|
the author's workspace.
|
|
146
146
|
- **Richy and PIO are interfaces here, not implementations — read this before you install.**
|
|
@@ -153,10 +153,18 @@ CLI prints which words matched, so you always see why a request went where it di
|
|
|
153
153
|
never mistake it for a finding about a real business. Everything else in this repo — all seven
|
|
154
154
|
deterministic auditors, the router, the CLI, the SDK — runs entirely on what you just cloned.
|
|
155
155
|
`weftens send richy --org "Acme Co" "audit our listings"` shows you exactly this.
|
|
156
|
+
- **A file it cannot read is never reported as clean.** Hand an auditor the wrong export and it
|
|
157
|
+
says so — *"could not read this as the report this agent expects… this is NOT a finding that your
|
|
158
|
+
account is clean"* — and exits non-zero. Six of the seven cores are covered by this; `content-ops`
|
|
159
|
+
reports item counts with no monetary values, so the generic check cannot distinguish a wrong file
|
|
160
|
+
from a real inventory. Treat a `content-ops` run on an unexpected file with suspicion.
|
|
161
|
+
- **`ask` and `chat` are the exception to "no API key".** Those two commands drive a conversational
|
|
162
|
+
orchestrator and need `ANTHROPIC_API_KEY`. Everything else — all routing, every deterministic
|
|
163
|
+
auditor, and every model-backed seat via your own CLI — needs no key.
|
|
156
164
|
- **The router refuses rather than guesses.** Generic words alone ("site", "listing", "people") no
|
|
157
165
|
longer dispatch — a confident wrong route in front of a customer costs more than a reprompt.
|
|
158
166
|
- **Licensed MIT.** Free to use, including commercially.
|
|
159
|
-
- **Published:** `npm i weftens` installs the real thing (`weftens@0.1.
|
|
167
|
+
- **Published:** `npm i weftens` installs the real thing (`weftens@0.1.5`, MIT). Verified from the
|
|
160
168
|
registry by a clean third-party install — `npx weftens list` prints the registry, and a
|
|
161
169
|
deterministic core runs a real audit from inside `node_modules`. (`0.0.1` was an empty name-holder;
|
|
162
170
|
ignore it.)
|
|
@@ -16,7 +16,6 @@ Experience" / "Ecommerce Marketing Account Director" (GH 5160651007 / 5118461007
|
|
|
16
16
|
own acquisition channels, plan and run campaign tests, manage budget pacing, lifecycle/email
|
|
17
17
|
programs, CRO, report against CAC/LTV/pipeline.
|
|
18
18
|
|
|
19
|
-
|
|
20
19
|
## What this agent does when run
|
|
21
20
|
1. **Growth planning for real assets** — channel strategy, campaign concepts, test roadmaps, and
|
|
22
21
|
funnel diagnostics for Machines Rule, client properties, or PIO's tenant work. Output is a plan
|
|
@@ -24,11 +23,7 @@ programs, CRO, report against CAC/LTV/pipeline.
|
|
|
24
23
|
2. **Drill the ladder** — Tier-1 crash-week coaching (cert order, practice-campaign design for the
|
|
25
24
|
$50 runs, platform walk-throughs), and Tier-2 concept drills (pacing scenarios, "ROAS dropped
|
|
26
25
|
40% overnight — what do you check first" style).
|
|
27
|
-
|
|
28
|
-
bullets, build the 2–3 campaign stories WITH numbers that hold the room, rehearse the probes
|
|
29
|
-
(tools hands-on, budget scale, what was your number). Truth rule: stories are real; numbers are
|
|
30
|
-
real; "certified" only when certified. No invented quota history — that claim family is probed
|
|
31
|
-
and reference-checked.
|
|
26
|
+
|
|
32
27
|
4. **Audit-and-propose on live channels** — reads exports/analytics and proposes changes. Composes
|
|
33
28
|
with the audit seats: paid-search (Google Ads), amazon-ppc, supply-verify. PROPOSE-ONLY: a
|
|
34
29
|
human applies changes in any live account; no spend moved by the agent.
|
|
@@ -42,12 +37,4 @@ programs, CRO, report against CAC/LTV/pipeline.
|
|
|
42
37
|
|
|
43
38
|
## Boundaries
|
|
44
39
|
1. Propose-only on anything live: ad accounts, budgets, email sends to real lists.
|
|
45
|
-
2. Job-hunt outputs follow the HONEST_CLAIMS scope rule — move fast, but resume/interview claims
|
|
46
|
-
stay true (held = provable; the forward-looking framing lives in the skills file, not on the
|
|
47
|
-
resume).
|
|
48
|
-
3. This seat drills and prepares the owner for the paid seat; interviews and the job are performed by
|
|
49
|
-
her.
|
|
50
40
|
|
|
51
|
-
Good output: a channel plan a hiring manager would recognize as the job, or a drill that leaves
|
|
52
|
-
her able to answer a real JD bullet with a true story and a number. Bad output: generic growth
|
|
53
|
-
listicles, invented metrics, or plans with no hypothesis.
|
|
@@ -55,8 +55,7 @@ this seat exists partly to build them.
|
|
|
55
55
|
notes — the writing surface of security engineering.
|
|
56
56
|
|
|
57
57
|
## Boundaries
|
|
58
|
-
|
|
59
|
-
agent prepares and drills her, it does not do the gig for her.
|
|
58
|
+
|
|
60
59
|
- Defensive and educational scope only: evaluation, explanation, documentation, and defensive
|
|
61
60
|
patterns. No offensive tooling beyond what understanding published material requires.
|
|
62
61
|
|
package/agents/seo-content.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: seo-content
|
|
3
|
-
description: Content & entity SEO seat — the strategy-and-authority half of heavy SEO. Run on keyword/GSC/Semrush/Ahrefs data, a competitor set, or a topic to decide what a site should rank and be cited for, then turn that into briefs the content team produces. Owns keyword and entity strategy, answer-surface visibility, and rank/traffic/citation measurement. Consumes Richy's resolution audit and the technical seat's clean site; produces gated briefs, never publishes.
|
|
3
|
+
description: Content & entity SEO seat — the strategy-and-authority half of heavy SEO. Run on keyword/GSC/Semrush/Ahrefs data, a competitor set, or a topic to decide what a site should rank and be cited for, then turn that into briefs the content team produces. Owns keyword and entity strategy, answer-surface visibility, and rank/traffic/citation measurement. Consumes Richy's resolution audit and the technical seat's clean site; produces gated briefs, never publishes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
You are the content and entity SEO seat — you decide what the site should win and hand the content
|
package/agents/seo-technical.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: seo-technical
|
|
3
|
-
description: Technical SEO seat — the site-plumbing half of heavy SEO. Run on a crawl, GSC export, log-file sample, or a live URL to find what stops machines from crawling, indexing, and rendering a site correctly, and to write schema/structured data that earns rich results and AI-answer grounding. Diagnoses and proposes; site and DNS changes are the owner's/developer's to apply.
|
|
3
|
+
description: Technical SEO seat — the site-plumbing half of heavy SEO. Run on a crawl, GSC export, log-file sample, or a live URL to find what stops machines from crawling, indexing, and rendering a site correctly, and to write schema/structured data that earns rich results and AI-answer grounding. Diagnoses and proposes; site and DNS changes are the owner's/developer's to apply.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
You are the technical SEO seat — the plumbing under the search surface. Richy tells you how the
|
package/bin/weftens.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
16
16
|
import readline from "node:readline";
|
|
17
17
|
import { orchestrate } from "../src/weftens.js";
|
|
18
|
-
import { isChangeSheet, formatChangeSheet } from "../src/format.js";
|
|
18
|
+
import { isChangeSheet, formatChangeSheet, looksUnparsed, unparsedNotice } from "../src/format.js";
|
|
19
19
|
import { askWeftens } from "../src/agent.js";
|
|
20
20
|
import { buildSpecialistClients } from "../src/specialists.js";
|
|
21
21
|
import { route } from "../src/agent-router.js";
|
|
@@ -153,6 +153,13 @@ async function cmdSend(args, { routeOnly } = {}) {
|
|
|
153
153
|
if (outcome.result !== undefined) {
|
|
154
154
|
// A change sheet renders for a human by default — the whole point of one is that a
|
|
155
155
|
// person can read a line and decide. `--json` returns the raw object for piping.
|
|
156
|
+
if (!asJson && looksUnparsed(outcome.result)) {
|
|
157
|
+
process.stdout.write(`
|
|
158
|
+
${unparsedNotice(outcome.result)}
|
|
159
|
+
`);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
156
163
|
if (!asJson && isChangeSheet(outcome.result)) {
|
|
157
164
|
process.stdout.write(`\n${formatChangeSheet(outcome.result)}\n`);
|
|
158
165
|
} else {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weftens",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Weftens
|
|
5
|
+
"description": "Weftens — one front door that routes a marketing/representation request to the right agent and runs it on your own AI subscription (bring-your-own-model). Usable as a CLI or an SDK.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
"providers.json.example",
|
|
24
24
|
"README.md",
|
|
25
25
|
"LICENSE",
|
|
26
|
+
"CHANGELOG.md",
|
|
27
|
+
"ARCHITECTURE.md",
|
|
26
28
|
"!**/.preflight",
|
|
27
29
|
"!**/.preflight/**"
|
|
28
30
|
],
|
|
@@ -39,7 +41,8 @@
|
|
|
39
41
|
"vitest": "^2.0.0"
|
|
40
42
|
},
|
|
41
43
|
"dependencies": {
|
|
42
|
-
"@anthropic-ai/sdk": "^0.110.0"
|
|
44
|
+
"@anthropic-ai/sdk": "^0.110.0",
|
|
45
|
+
"weftens": "^0.1.4"
|
|
43
46
|
},
|
|
44
47
|
"repository": {
|
|
45
48
|
"type": "git",
|
package/src/format.js
CHANGED
|
@@ -35,6 +35,80 @@ export function isChangeSheet(result) {
|
|
|
35
35
|
return Boolean(result && typeof result === "object" && Array.isArray(result.changes));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
|
|
39
|
+
// A core that was handed the WRONG report does not usually error — it parses the rows, finds none of
|
|
40
|
+
// the columns it wants, and returns a well-formed sheet with zero changes. Rendered normally that
|
|
41
|
+
// reads as "your account is clean", which is the most expensive lie this tool could tell: a user
|
|
42
|
+
// exports the wrong file and is told there is nothing to fix. Five of seven cores did exactly this.
|
|
43
|
+
//
|
|
44
|
+
// The signal is that rows were read but every money/volume figure came back zero or null. A real
|
|
45
|
+
// account under audit always has spend. An account with genuinely zero activity is indistinguishable
|
|
46
|
+
// — and for that one, "this doesn't look like the right export, or there was no activity" is also
|
|
47
|
+
// the truthful answer.
|
|
48
|
+
// Two different kinds of number, and conflating them is what let four cores slip past the first two
|
|
49
|
+
// versions of this check. ROW fields count what was READ — a non-zero row count proves the file was
|
|
50
|
+
// opened, not that it was understood. VALUE fields are what the report is FOR. The signal for "wrong
|
|
51
|
+
// file" is: rows were read, and every value came back zero.
|
|
52
|
+
const ROW_FIELDS = ["rows", "terms", "placements", "urls", "pages", "items", "searchTermRows", "campaignRows"];
|
|
53
|
+
const VALUE_FIELDS = [
|
|
54
|
+
"totalCost", "totalSpend", "totalClicks", "totalImpressions", "totalConversions",
|
|
55
|
+
"totalResults", "spend", "cost", "clicks", "impressions", "conversions", "sales", "orders",
|
|
56
|
+
"flaggedSpend", "reclaimableSpend", "totalViews", "views",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
export function looksUnparsed(result) {
|
|
60
|
+
if (!result || typeof result !== "object") return false;
|
|
61
|
+
if (Array.isArray(result.changes) && result.changes.length > 0) return false;
|
|
62
|
+
// Cores don't share one result shape: some report totals under `account`, others under `summary`,
|
|
63
|
+
// others at the top level. Check wherever the volume figures actually live rather than assuming a
|
|
64
|
+
// shape — assuming `account` is why four of the five cores slipped past the first version of this.
|
|
65
|
+
// Every core names its totals block differently — `account`, `summary`, `totals` — and the first
|
|
66
|
+
// two versions of this guard each missed the cores they didn't happen to name. Scan any one-level
|
|
67
|
+
// block that carries volume fields instead of guessing at keys; a new core is covered for free.
|
|
68
|
+
const blocks = [result, ...Object.values(result).filter((v) => v && typeof v === "object" && !Array.isArray(v))];
|
|
69
|
+
const zero = (v) => v === 0 || v === null || v === undefined;
|
|
70
|
+
|
|
71
|
+
// Strongest signal first: a core that reports it read ZERO rows read nothing at all. Whatever the
|
|
72
|
+
// file was, it was not this report.
|
|
73
|
+
for (const b of blocks) {
|
|
74
|
+
for (const f of ROW_FIELDS) if (b[f] === 0) return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const b of blocks) {
|
|
78
|
+
const values = VALUE_FIELDS.filter((f) => f in b && typeof b[f] !== "object");
|
|
79
|
+
if (values.length === 0) continue;
|
|
80
|
+
if (!values.every((f) => zero(b[f]))) continue;
|
|
81
|
+
// Every value is zero. If rows were read, the file parsed as text but not as this report.
|
|
82
|
+
// If no row count is reported either, an all-zero result is still not a clean bill of health.
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Row count from wherever a core reports it — used only to make the refusal message concrete. */
|
|
89
|
+
function rowsRead(result) {
|
|
90
|
+
if (!result || typeof result !== "object") return null;
|
|
91
|
+
const blocks = [result, ...Object.values(result).filter((v) => v && typeof v === "object" && !Array.isArray(v))];
|
|
92
|
+
for (const b of blocks) {
|
|
93
|
+
for (const f of ROW_FIELDS) if (typeof b[f] === "number" && b[f] > 0) return b[f];
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
/** The one message for "this file did not parse", shared by every core shape. */
|
|
100
|
+
export function unparsedNotice(result) {
|
|
101
|
+
const rows = rowsRead(result);
|
|
102
|
+
return [
|
|
103
|
+
"Could not read this as the report this agent expects.",
|
|
104
|
+
typeof rows === "number" && rows > 0
|
|
105
|
+
? ` ${rows} row(s) were read, but none of the expected columns (spend, clicks, results) had values.`
|
|
106
|
+
: " No rows with the expected columns were found.",
|
|
107
|
+
" This is NOT a finding that your account is clean — it means the file did not parse.",
|
|
108
|
+
" Check you exported the right report for this agent, then run it again.",
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
38
112
|
/**
|
|
39
113
|
* Render a change sheet as plain text. Never invents a field: anything missing is simply
|
|
40
114
|
* not printed, so a core that doesn't produce risk/evidence still renders cleanly.
|
|
@@ -50,6 +124,9 @@ export function formatChangeSheet(result) {
|
|
|
50
124
|
const changes = result.changes.filter((c) => c && typeof c === "object");
|
|
51
125
|
const dropped = result.changes.length - changes.length;
|
|
52
126
|
|
|
127
|
+
// Refuse to present an unreadable file as a clean bill of health.
|
|
128
|
+
if (looksUnparsed(result)) return unparsedNotice(result);
|
|
129
|
+
|
|
53
130
|
const flagged = money(result.wastedSpendRecoverable, currency);
|
|
54
131
|
const header = [`${changes.length} proposed change${changes.length === 1 ? "" : "s"}`];
|
|
55
132
|
// "flagged", not "recoverable": the tool measured spend against a threshold, it did not
|