weftens 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,18 +1,57 @@
1
1
  # Weftens
2
2
 
3
- One front door for marketing and representation work. You say what you need; Weftens routes it
4
- to the right agent and runs it on **your own AI subscription**, no API key.
5
-
6
- The name comes from **weft** — in weaving, the cross-thread pulled through the warp that binds loose
7
- strands into one cloth. Weftens binds a set of marketing, SEO, ads, and representation agents behind
8
- one command.
9
-
10
- ## Bring your own model
11
-
12
- Weftens is a free tool. No account, no billing, no API key — nobody is billed through Weftens. The
13
- model-backed agents run through whatever AI CLI you already have and are logged into (**Claude**,
14
- **Gemini**, **Codex/GPT**, or **Grok** your choice). Many agents need no model at all: the ad and
15
- SEO auditors are deterministic and run on your exported reports.
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 termrestricting 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.
16
55
 
17
56
  ## Install
18
57
 
@@ -116,9 +155,10 @@ CLI prints which words matched, so you always see why a request went where it di
116
155
  - **The router refuses rather than guesses.** Generic words alone ("site", "listing", "people") no
117
156
  longer dispatch — a confident wrong route in front of a customer costs more than a reprompt.
118
157
  - **Licensed MIT.** Free to use, including commercially.
119
- - **On npm as a name-holder, not yet as the tool:** `weftens@0.0.1` reserves the package name and
120
- contains no code. Clone the repo to run it. The working release follows; `"private": true` in
121
- package.json is a deliberate catch so the real publish is a decision, not an accident.
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.)
122
162
  - **Two providers verified, not one:** `claude` and `codex` both produce end-to-end through the
123
163
  user's own subscription (codex confirmed 2026-07-19). `gemini` and `grok` remain config-only — the
124
164
  shape is right, but neither CLI is installed here, so neither has been run. A missing or broken
package/bin/weftens.js CHANGED
@@ -1,226 +1,187 @@
1
- #!/usr/bin/env node
2
- // Weftens CLI — the agent front door.
3
- //
4
- // weftens ask "how do we look online and who came to our events?" --org "Acme" [--website https://…]
5
- // weftens chat --org "Acme" # interactive conversation
6
- // weftens route request.json # deterministic router/synthesizer (no LLM, no key)
7
- // echo '{"org":{...},"question":"..."}' | weftens route -
8
- //
9
- // ask/chat need ANTHROPIC_API_KEY (or an `ant auth login` profile). Specialists:
10
- // WEFTENS_RICHY_URL + WEFTENS_RICHY_KEY → live Richy audit service
11
- // WEFTENS_PIO_URL + WEFTENS_PIO_MEMBER + WEFTENS_PIO_PASSWORD → live PIO (member session)
12
- // WEFTENS_PIO_TENANT → tenant slug (hosted PIO only)
13
- // Without them the specialists run as clearly-flagged fixtures so the agent always works.
14
-
15
- import { readFileSync } from "node:fs";
16
- import readline from "node:readline";
17
- import { orchestrate } from "../src/weftens.js";
18
- import { askWeftens } from "../src/agent.js";
19
- import { RichyClient, FixtureRichyClient } from "../src/richy-client.js";
20
- import { PioClient, FixturePioClient } from "../src/pio-client.js";
21
- import { route } from "../src/agent-router.js";
22
- import { invoke } from "../src/invoke.js";
23
- import { REGISTRY, dispatchableAgents } from "../src/registry.js";
24
-
25
- // A specialist is live only when its FULL env group is set. A partial group is a
26
- // hard error, not a silent fixture fallback an operator who typo'd one variable
27
- // would otherwise get sample data while believing they're looking at their org.
28
- function envGroup(label, names) {
29
- const set = names.filter((n) => process.env[n]);
30
- if (set.length === 0) return null;
31
- if (set.length < names.length) {
32
- const missing = names.filter((n) => !process.env[n]).join(", ");
33
- throw new Error(`${label} is partially configured — set ${missing} (or unset ${set.join(", ")} to run with sample data)`);
34
- }
35
- return Object.fromEntries(names.map((n) => [n, process.env[n]]));
36
- }
37
-
38
- function buildAgents() {
39
- const richyEnv = envGroup("Richy", ["WEFTENS_RICHY_URL", "WEFTENS_RICHY_KEY"]);
40
- const pioEnv = envGroup("PIO", ["WEFTENS_PIO_URL", "WEFTENS_PIO_MEMBER", "WEFTENS_PIO_PASSWORD"]);
41
- const richy = richyEnv
42
- ? new RichyClient({ baseUrl: richyEnv.WEFTENS_RICHY_URL, key: richyEnv.WEFTENS_RICHY_KEY })
43
- : new FixtureRichyClient();
44
- const pio = pioEnv
45
- ? new PioClient({
46
- baseUrl: pioEnv.WEFTENS_PIO_URL,
47
- member: pioEnv.WEFTENS_PIO_MEMBER,
48
- password: pioEnv.WEFTENS_PIO_PASSWORD,
49
- tenant: process.env.WEFTENS_PIO_TENANT ?? null,
50
- })
51
- : new FixturePioClient();
52
- const fixtures = [!richyEnv && "Richy", !pioEnv && "PIO"].filter(Boolean);
53
- if (fixtures.length > 0) {
54
- process.stderr.write(`weftens: ${fixtures.join(" and ")} not configured — using clearly-flagged sample data\n`);
55
- }
56
- return { richy, pio };
57
- }
58
-
59
- function parseFlags(args) {
60
- const flags = {};
61
- const rest = [];
62
- for (let i = 0; i < args.length; i += 1) {
63
- if (args[i] === "--org") flags.org = args[++i];
64
- else if (args[i] === "--website") flags.website = args[++i];
65
- else rest.push(args[i]);
66
- }
67
- return { flags, rest };
68
- }
69
-
70
- function orgPreamble(flags) {
71
- if (!flags.org) return "";
72
- const site = flags.website ? ` (website: ${flags.website})` : "";
73
- return `The organization we're discussing is "${flags.org}"${site}. `;
74
- }
75
-
76
- async function cmdAsk(args) {
77
- const { flags, rest } = parseFlags(args);
78
- const question = rest.join(" ").trim();
79
- if (!question) {
80
- process.stderr.write('usage: weftens ask "<question>" [--org NAME] [--website URL]\n');
81
- process.exitCode = 1;
82
- return;
83
- }
84
- const { text } = await askWeftens({ question: orgPreamble(flags) + question, agents: buildAgents() });
85
- process.stdout.write(`${text}\n`);
86
- }
87
-
88
- async function cmdChat(args) {
89
- const { flags } = parseFlags(args);
90
- const agents = buildAgents();
91
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
- let history = [];
93
- let first = true;
94
- process.stdout.write("Weftens. Ask about how your organization looks online or how it's running. Ctrl+C to exit.\n");
95
- for (;;) {
96
- const line = await new Promise((resolve) => rl.question("> ", resolve));
97
- const question = line.trim();
98
- if (!question) continue;
99
- if (question === "exit" || question === "quit") break;
100
- try {
101
- const { text, messages } = await askWeftens({
102
- question: first ? orgPreamble(flags) + question : question,
103
- history,
104
- agents,
105
- });
106
- history = messages;
107
- first = false;
108
- process.stdout.write(`${text}\n`);
109
- } catch (error) {
110
- process.stderr.write(`weftens: ${error?.message ?? error}\n`);
111
- }
112
- }
113
- rl.close();
114
- }
115
-
116
- async function cmdRoute(arg) {
117
- const raw = !arg || arg === "-" ? readFileSync(0, "utf8") : readFileSync(arg, "utf8");
118
- const result = await orchestrate(JSON.parse(raw), buildAgents());
119
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
120
- }
121
-
122
- // --- Registry front door: route across the whole roster, not just Richy/PIO ---
123
- // `list` shows the directory (and prints the live count); `send` routes a request
124
- // (auto or direct-addressed) and
125
- // invokes the chosen agent. Deterministic routing needs no key; see src/invoke.js for
126
- // what producing needs. This sits alongside ask/chat (the Richy+PIO conversational
127
- // surface) those two agents are also reachable here through the registry.
128
-
129
- function cmdList() {
130
- const pad = (s, n) => String(s).padEnd(n);
131
- process.stdout.write(`Weftens registry — ${REGISTRY.length} agents (${dispatchableAgents().length} dispatchable at the door)\n\n`);
132
- process.stdout.write(`${pad("AGENT", 18)}${pad("TIER", 6)}${pad("TEAM", 12)}${pad("CORE", 6)}OWNED-BY\n`);
133
- for (const a of REGISTRY) {
134
- const owned = a.ownedBy ?? (a.leads ? `(leads ${a.leads})` : "-");
135
- process.stdout.write(`${pad(a.name, 18)}${pad(a.tier, 6)}${pad(a.team, 12)}${pad(a.core ? "yes" : "-", 6)}${owned}\n`);
136
- }
137
- }
138
-
139
- async function cmdSend(args, { routeOnly } = {}) {
140
- const rest = [];
141
- let inputFile = null, provider = null, org = null, website = null;
142
- for (let i = 0; i < args.length; i += 1) {
143
- if (args[i] === "--input") inputFile = args[++i] ?? null;
144
- else if (args[i] === "--provider") provider = args[++i] ?? null;
145
- else if (args[i] === "--org") org = args[++i] ?? null;
146
- else if (args[i] === "--website") website = args[++i] ?? null;
147
- else rest.push(args[i]);
148
- }
149
- const firstIsAgent = rest.length >= 1 && REGISTRY.some((a) => a.name === rest[0].toLowerCase());
150
- const request = firstIsAgent
151
- ? { agent: rest[0], text: rest.slice(1).join(" ") }
152
- : { text: rest.join(" ") };
153
- if (!request.text && !request.agent) {
154
- process.stderr.write('usage: weftens send "<request>" | weftens send <agent> "<request>"\n');
155
- process.exitCode = 1;
156
- return;
157
- }
158
- const decision = route(request);
159
- if (!decision.agent) {
160
- process.stdout.write(`→ (no route) — ${decision.reason}\n`);
161
- process.exitCode = 1;
162
- return;
163
- }
164
- process.stdout.write(`→ ${decision.agent.name} [${decision.reason}]${decision.confident ? "" : " (low confidence)"}\n`);
165
- if (decision.alternatives?.length) {
166
- process.stdout.write(` also considered: ${decision.alternatives.map((x) => `${x.name}(${x.score})`).join(", ")}\n`);
167
- }
168
- if (routeOnly) return;
169
-
170
- // Richy and PIO are specialist legs: they answer about an ORGANIZATION, not about a
171
- // bare sentence, so they run through orchestrate() rather than as leaf agents. They
172
- // used to refuse here with a note about "wire contracts" true internally, useless
173
- // to the person typing, and wrong in effect: the legs are reachable (with fixture
174
- // clients when unconfigured), so refusing categorically made the two headline seats
175
- // look dead. Now `send richy --org NAME "<question>"` does the real thing.
176
- if (decision.agent.tier === 1 && decision.agent.wire) {
177
- // An org is genuinely required Richy audits a specific organization, PIO reports on one —
178
- // but demanding a FLAG for it is a door that doesn't open for a plain sentence. Whoever is
179
- // running this almost always means the same org every time, so it's config, not an argument:
180
- // set WEFTENS_ORG once and `send richy "audit our listings"` just works.
181
- org = org ?? process.env.WEFTENS_ORG ?? null;
182
- website = website ?? process.env.WEFTENS_WEBSITE ?? null;
183
- if (!org) {
184
- process.stdout.write(
185
- `\n[orchestrator] ${decision.agent.name} answers about a specific organization — which one?\n` +
186
- ` once: weftens send ${decision.agent.name} --org "Acme Co" [--website acme.com] "${request.text || "your question"}"\n` +
187
- ` always: set WEFTENS_ORG="Acme Co" (and optionally WEFTENS_WEBSITE) — then this command works as typed\n`
188
- );
189
- process.exitCode = 1;
190
- return;
191
- }
192
- const intent = decision.agent.name === "richy" ? "representation" : "operations";
193
- const result = await orchestrate(
194
- { org: { name: org, ...(website ? { website } : {}) }, intent, question: request.text || undefined },
195
- buildAgents()
196
- );
197
- process.stdout.write(`\n[orchestrator] ran ${decision.agent.name} over ${org} (intent: ${intent})\n`);
198
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
199
- return;
200
- }
201
-
202
- const outcome = await invoke(decision.agent, { text: request.text, inputFile, provider });
203
- process.stdout.write(`\n[${outcome.path}] ${outcome.note}\n`);
204
- if (outcome.result !== undefined) {
205
- process.stdout.write(`\n--- result ---\n${typeof outcome.result === "string" ? outcome.result : JSON.stringify(outcome.result, null, 2)}\n`);
206
- }
207
- }
208
-
209
- async function main() {
210
- const [command, ...args] = process.argv.slice(2);
211
- if (command === "ask") return cmdAsk(args);
212
- if (command === "chat") return cmdChat(args);
213
- if (command === "list" || command === "--list") return cmdList();
214
- if (command === "send") return cmdSend(args);
215
- if (command === "--route") return cmdSend(args, { routeOnly: true });
216
- if (command === "route" || (command && (command === "-" || command.endsWith(".json")))) {
217
- return cmdRoute(command === "route" ? args[0] : command);
218
- }
219
- process.stderr.write("usage: weftens ask|chat|list|send|--route|route …\n");
220
- process.exitCode = 1;
221
- }
222
-
223
- main().catch((error) => {
224
- process.stderr.write(`weftens: ${error?.message ?? error}\n`);
225
- process.exitCode = 1;
226
- });
1
+ #!/usr/bin/env node
2
+ // Weftens CLI — the agent front door.
3
+ //
4
+ // weftens ask "how do we look online and who came to our events?" --org "Acme" [--website https://…]
5
+ // weftens chat --org "Acme" # interactive conversation
6
+ // weftens route request.json # deterministic router/synthesizer (no LLM, no key)
7
+ // echo '{"org":{...},"question":"..."}' | weftens route -
8
+ //
9
+ // ask/chat need ANTHROPIC_API_KEY (or an `ant auth login` profile). Specialists:
10
+ // WEFTENS_RICHY_URL + WEFTENS_RICHY_KEY → live Richy audit service
11
+ // WEFTENS_PIO_URL + WEFTENS_PIO_MEMBER + WEFTENS_PIO_PASSWORD → live PIO (member session)
12
+ // WEFTENS_PIO_TENANT → tenant slug (hosted PIO only)
13
+ // Without them the specialists run as clearly-flagged fixtures so the agent always works.
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import readline from "node:readline";
17
+ import { orchestrate } from "../src/weftens.js";
18
+ import { isChangeSheet, formatChangeSheet } from "../src/format.js";
19
+ import { askWeftens } from "../src/agent.js";
20
+ import { buildSpecialistClients } from "../src/specialists.js";
21
+ import { route } from "../src/agent-router.js";
22
+ import { invoke } from "../src/invoke.js";
23
+ import { REGISTRY, dispatchableAgents } from "../src/registry.js";
24
+
25
+ // Client construction lives in src/specialists.js so the CLI and the SDK build them the
26
+ // same way. The CLI adds one thing: a stderr warning when a specialist falls back to
27
+ // sample data, so the person watching knows before they read a number.
28
+ function buildAgents() {
29
+ return buildSpecialistClients({
30
+ onFixture: (names) =>
31
+ process.stderr.write(`weftens: ${names.join(" and ")} not configured — using clearly-flagged sample data
32
+ `),
33
+ });
34
+ }
35
+
36
+ function parseFlags(args) {
37
+ const flags = {};
38
+ const rest = [];
39
+ for (let i = 0; i < args.length; i += 1) {
40
+ if (args[i] === "--org") flags.org = args[++i];
41
+ else if (args[i] === "--website") flags.website = args[++i];
42
+ else rest.push(args[i]);
43
+ }
44
+ return { flags, rest };
45
+ }
46
+
47
+ function orgPreamble(flags) {
48
+ if (!flags.org) return "";
49
+ const site = flags.website ? ` (website: ${flags.website})` : "";
50
+ return `The organization we're discussing is "${flags.org}"${site}. `;
51
+ }
52
+
53
+ async function cmdAsk(args) {
54
+ const { flags, rest } = parseFlags(args);
55
+ const question = rest.join(" ").trim();
56
+ if (!question) {
57
+ process.stderr.write('usage: weftens ask "<question>" [--org NAME] [--website URL]\n');
58
+ process.exitCode = 1;
59
+ return;
60
+ }
61
+ const { text } = await askWeftens({ question: orgPreamble(flags) + question, agents: buildAgents() });
62
+ process.stdout.write(`${text}\n`);
63
+ }
64
+
65
+ async function cmdChat(args) {
66
+ const { flags } = parseFlags(args);
67
+ const agents = buildAgents();
68
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
+ let history = [];
70
+ let first = true;
71
+ process.stdout.write("Weftens. Ask about how your organization looks online or how it's running. Ctrl+C to exit.\n");
72
+ for (;;) {
73
+ const line = await new Promise((resolve) => rl.question("> ", resolve));
74
+ const question = line.trim();
75
+ if (!question) continue;
76
+ if (question === "exit" || question === "quit") break;
77
+ try {
78
+ const { text, messages } = await askWeftens({
79
+ question: first ? orgPreamble(flags) + question : question,
80
+ history,
81
+ agents,
82
+ });
83
+ history = messages;
84
+ first = false;
85
+ process.stdout.write(`${text}\n`);
86
+ } catch (error) {
87
+ process.stderr.write(`weftens: ${error?.message ?? error}\n`);
88
+ }
89
+ }
90
+ rl.close();
91
+ }
92
+
93
+ async function cmdRoute(arg) {
94
+ const raw = !arg || arg === "-" ? readFileSync(0, "utf8") : readFileSync(arg, "utf8");
95
+ const result = await orchestrate(JSON.parse(raw), buildAgents());
96
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
97
+ }
98
+
99
+ // --- Registry front door: route across the whole roster, not just Richy/PIO ---
100
+ // `list` shows the directory (and prints the live count); `send` routes a request
101
+ // (auto or direct-addressed) and
102
+ // invokes the chosen agent. Deterministic routing needs no key; see src/invoke.js for
103
+ // what producing needs. This sits alongside ask/chat (the Richy+PIO conversational
104
+ // surface) — those two agents are also reachable here through the registry.
105
+
106
+ function cmdList() {
107
+ const pad = (s, n) => String(s).padEnd(n);
108
+ process.stdout.write(`Weftens registry — ${REGISTRY.length} agents (${dispatchableAgents().length} dispatchable at the door)\n\n`);
109
+ process.stdout.write(`${pad("AGENT", 18)}${pad("TIER", 6)}${pad("TEAM", 12)}${pad("CORE", 6)}OWNED-BY\n`);
110
+ for (const a of REGISTRY) {
111
+ const owned = a.ownedBy ?? (a.leads ? `(leads ${a.leads})` : "-");
112
+ process.stdout.write(`${pad(a.name, 18)}${pad(a.tier, 6)}${pad(a.team, 12)}${pad(a.core ? "yes" : "-", 6)}${owned}\n`);
113
+ }
114
+ }
115
+
116
+ async function cmdSend(args, { routeOnly } = {}) {
117
+ const rest = [];
118
+ let inputFile = null, provider = null, org = null, website = null, asJson = false;
119
+ for (let i = 0; i < args.length; i += 1) {
120
+ if (args[i] === "--input") inputFile = args[++i] ?? null;
121
+ else if (args[i] === "--provider") provider = args[++i] ?? null;
122
+ else if (args[i] === "--json") asJson = true;
123
+ else if (args[i] === "--org") org = args[++i] ?? null;
124
+ else if (args[i] === "--website") website = args[++i] ?? null;
125
+ else rest.push(args[i]);
126
+ }
127
+ const firstIsAgent = rest.length >= 1 && REGISTRY.some((a) => a.name === rest[0].toLowerCase());
128
+ const request = firstIsAgent
129
+ ? { agent: rest[0], text: rest.slice(1).join(" ") }
130
+ : { text: rest.join(" ") };
131
+ if (!request.text && !request.agent) {
132
+ process.stderr.write('usage: weftens send "<request>" | weftens send <agent> "<request>"\n');
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ const decision = route(request);
137
+ if (!decision.agent) {
138
+ process.stdout.write(`→ (no route) — ${decision.reason}\n`);
139
+ process.exitCode = 1;
140
+ return;
141
+ }
142
+ process.stdout.write(`→ ${decision.agent.name} [${decision.reason}]${decision.confident ? "" : " (low confidence)"}\n`);
143
+ if (decision.alternatives?.length) {
144
+ process.stdout.write(` also considered: ${decision.alternatives.map((x) => `${x.name}(${x.score})`).join(", ")}\n`);
145
+ }
146
+ if (routeOnly) return;
147
+
148
+ // Specialist handling (org resolution, intent mapping, orchestrate) lives in invoke() so
149
+ // the CLI and the SDK cannot disagree about what invoking richy does. This used to be a
150
+ // second copy here, and the SDK regressed against it.
151
+ const outcome = await invoke(decision.agent, { text: request.text, inputFile, provider, org, website });
152
+ process.stdout.write(`\n[${outcome.path}] ${outcome.note}\n`);
153
+ if (outcome.result !== undefined) {
154
+ // A change sheet renders for a human by default — the whole point of one is that a
155
+ // person can read a line and decide. `--json` returns the raw object for piping.
156
+ if (!asJson && isChangeSheet(outcome.result)) {
157
+ process.stdout.write(`\n${formatChangeSheet(outcome.result)}\n`);
158
+ } else {
159
+ process.stdout.write(`\n--- result ---\n${typeof outcome.result === "string" ? outcome.result : JSON.stringify(outcome.result, null, 2)}\n`);
160
+ }
161
+ return;
162
+ }
163
+
164
+ // No result means the run produced nothing usable — a missing input file, a provider CLI
165
+ // that isn't installed, a specialist with no org. All of those exited 0 before, which made
166
+ // the tool unscriptable and made the fresh-clone CI step incapable of failing.
167
+ process.exitCode = 1;
168
+ }
169
+
170
+ async function main() {
171
+ const [command, ...args] = process.argv.slice(2);
172
+ if (command === "ask") return cmdAsk(args);
173
+ if (command === "chat") return cmdChat(args);
174
+ if (command === "list" || command === "--list") return cmdList();
175
+ if (command === "send") return cmdSend(args);
176
+ if (command === "--route") return cmdSend(args, { routeOnly: true });
177
+ if (command === "route" || (command && (command === "-" || command.endsWith(".json")))) {
178
+ return cmdRoute(command === "route" ? args[0] : command);
179
+ }
180
+ process.stderr.write("usage: weftens ask|chat|list|send|--route|route …\n");
181
+ process.exitCode = 1;
182
+ }
183
+
184
+ main().catch((error) => {
185
+ process.stderr.write(`weftens: ${error?.message ?? error}\n`);
186
+ process.exitCode = 1;
187
+ });
package/index.js CHANGED
@@ -1,32 +1,40 @@
1
- // Weftens SDK — the programmatic front door. The same deterministic routing and invoke
2
- // the CLI uses, exposed as an importable API so Weftens can be embedded in other code.
3
- //
4
- // import { weftens, route, invoke, REGISTRY } from "weftens";
5
- //
6
- // // one call: route a request and run the chosen agent
7
- // const { decision, outcome } = await weftens("audit our facebook ads", { inputFile: "meta.csv" });
8
- //
9
- // // or drive the pieces yourself
10
- // const decision = route({ text: "who are the funders for this grant" });
11
- // const outcome = await invoke(decision.agent, { text: "..." });
12
-
13
- import { route } from "./src/agent-router.js";
14
- import { invoke } from "./src/invoke.js";
15
- import { REGISTRY, agentByName, dispatchableAgents } from "./src/registry.js";
16
- import { complete, loadProviders } from "./src/provider.js";
17
-
18
- export { route, invoke, REGISTRY, agentByName, dispatchableAgents, complete, loadProviders };
19
-
20
- /**
21
- * Route a request and run the chosen agent — the one-call front door.
22
- * @param request a string (free text) or { text?, agent? } (agent = direct address)
23
- * @param opts { inputFile?, provider?, dry? } passed through to invoke
24
- * @returns { decision, outcome } outcome is null when nothing routed
25
- */
26
- export async function weftens(request, opts = {}) {
27
- const req = typeof request === "string" ? { text: request } : request;
28
- const decision = route(req);
29
- if (!decision.agent) return { decision, outcome: null };
30
- const outcome = await invoke(decision.agent, { text: req.text, ...opts });
31
- return { decision, outcome };
32
- }
1
+ // Weftens SDK — the programmatic front door. The same deterministic routing and invoke
2
+ // the CLI uses, exposed as an importable API so Weftens can be embedded in other code.
3
+ //
4
+ // import { weftens, route, invoke, REGISTRY } from "weftens";
5
+ //
6
+ // // one call: route a request and run the chosen agent
7
+ // const { decision, outcome } = await weftens("audit our facebook ads", { inputFile: "meta.csv" });
8
+ //
9
+ // // or drive the pieces yourself
10
+ // const decision = route({ text: "who are the funders for this grant" });
11
+ // const outcome = await invoke(decision.agent, { text: "..." });
12
+
13
+ import { route } from "./src/agent-router.js";
14
+ import { invoke } from "./src/invoke.js";
15
+ import { REGISTRY, agentByName, dispatchableAgents } from "./src/registry.js";
16
+ import { complete, loadProviders } from "./src/provider.js";
17
+ import { isChangeSheet, formatChangeSheet } from "./src/format.js";
18
+ import { buildSpecialistClients } from "./src/specialists.js";
19
+
20
+ export {
21
+ route, invoke, REGISTRY, agentByName, dispatchableAgents, complete, loadProviders,
22
+ // The renderer is part of the product, not a CLI detail: an SDK consumer gets the same
23
+ // human-readable change sheet the terminal shows, instead of having to build one.
24
+ isChangeSheet, formatChangeSheet,
25
+ buildSpecialistClients,
26
+ };
27
+
28
+ /**
29
+ * Route a request and run the chosen agent the one-call front door.
30
+ * @param request a string (free text) or { text?, agent? } (agent = direct address)
31
+ * @param opts { inputFile?, provider?, dry?, org?, website? } — passed through to invoke
32
+ * @returns { decision, outcome } outcome is null when nothing routed
33
+ */
34
+ export async function weftens(request, opts = {}) {
35
+ const req = typeof request === "string" ? { text: request } : request;
36
+ const decision = route(req);
37
+ if (!decision.agent) return { decision, outcome: null };
38
+ const outcome = await invoke(decision.agent, { text: req.text, ...opts });
39
+ return { decision, outcome };
40
+ }
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "weftens",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Weftens \u2014 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",
9
- "./cli": "./bin/weftens.js"
9
+ "./cli": "./bin/weftens.js",
10
+ "./package.json": "./package.json"
10
11
  },
11
12
  "bin": {
12
13
  "weftens": "bin/weftens.js"
13
14
  },
14
15
  "license": "MIT",
15
16
  "files": [
17
+ "scripts",
16
18
  "bin",
17
19
  "src",
18
20
  "agents",
@@ -26,11 +28,11 @@
26
28
  "scripts": {
27
29
  "test": "npm run test:unit && npm run test:cores",
28
30
  "test:unit": "vitest run",
29
- "test:cores": "node --test \"agent-cores/**/test/*.test.mjs\" \"agent-cores/**/test/*.test.js\"",
31
+ "test:cores": "node scripts/run-core-tests.mjs",
30
32
  "start": "node bin/weftens.js"
31
33
  },
32
34
  "engines": {
33
- "node": ">=22"
35
+ "node": ">=20"
34
36
  },
35
37
  "devDependencies": {
36
38
  "vitest": "^2.0.0"
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // Runs the agent-cores test files under Node's built-in runner.
3
+ //
4
+ // Why this exists rather than `node --test "agent-cores/**/test/*.test.mjs"`: glob expansion
5
+ // inside `node --test` needs Node 21+, and this package supports Node 20. Passing an explicit
6
+ // file list works everywhere, so the supported range in package.json is a claim CI can
7
+ // actually check rather than an aspiration.
8
+ //
9
+ // The cores are deliberately zero-dependency and run under `node --test` (not vitest) so a
10
+ // single auditor can be handed to someone who has nothing but Node installed. Keep it that way.
11
+
12
+ import { readdirSync } from "node:fs";
13
+ import { join, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { spawnSync } from "node:child_process";
16
+
17
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
18
+ const CORES = join(ROOT, "agent-cores");
19
+
20
+ function findTests(dir, found = []) {
21
+ let entries;
22
+ try {
23
+ entries = readdirSync(dir, { withFileTypes: true });
24
+ } catch {
25
+ return found; // a missing agent-cores/ is a packaging problem, reported below, not a crash here
26
+ }
27
+ for (const e of entries) {
28
+ const full = join(dir, e.name);
29
+ if (e.isDirectory()) {
30
+ if (e.name === "node_modules") continue;
31
+ findTests(full, found);
32
+ } else if (/\.test\.m?js$/.test(e.name)) {
33
+ found.push(full);
34
+ }
35
+ }
36
+ return found;
37
+ }
38
+
39
+ const files = findTests(CORES).sort();
40
+
41
+ if (files.length === 0) {
42
+ // Silence here would be indistinguishable from "all core tests passed" — exactly the failure
43
+ // this repo already shipped once: a green check over a step that could not fail.
44
+ console.error(`No core test files found under ${CORES}. Expected at least one.`);
45
+ process.exit(1);
46
+ }
47
+
48
+ console.log(`Running ${files.length} core test files under node --test`);
49
+ const result = spawnSync(process.execPath, ["--test", ...files], { stdio: "inherit", cwd: ROOT });
50
+ process.exit(result.status ?? 1);
@@ -41,14 +41,21 @@ function hintRegex(hint) {
41
41
  // "we need people ops" -> pio (matched: people)
42
42
  // They still count, at a third of a point, so a request with real signal alongside them
43
43
  // routes as before — but a lone weak hit can no longer clear the confidence floor.
44
+ // Only words that (a) are actually declared as a hint by some agent and (b) carry almost no
45
+ // meaning alone in ordinary English. Five entries here previously matched nothing in the
46
+ // registry at all — proof the list had been written from intuition about English rather than
47
+ // from the data it operates on. `registry-invariants.test.js` now fails if that recurs.
44
48
  const WEAK_HINTS = new Set([
45
49
  "site", "online", "listing", "people", "post", "content", "brief", "topic", "entity",
46
- "profile", "record", "page", "search", "report", "data", "review",
47
50
  ]);
48
51
 
49
- // A route must earn at least this much to be dispatched confidently. One ordinary hint
50
- // (1) clears it; one weak hint (0.34) does not; two weak hints do.
52
+ // A route must earn at least this much to dispatch. One ordinary hint (1) clears it; a lone
53
+ // weak hint (0.5) does not; two weak hits (1.0) do. The old value was 0.34, which meant it
54
+ // silently took THREE weak hits — and the comment claimed two. The constant was tuned until
55
+ // two adversarial strings failed rather than derived from the rule, and it cost real requests:
56
+ // "review the site" was refused.
51
57
  const CONFIDENCE_FLOOR = 1;
58
+ const WEAK_WEIGHT = 0.5;
52
59
 
53
60
  // Longer (multi-word) hints weigh more — "made for advertising" is a far stronger
54
61
  // signal than a bare "seo" — so specific phrases beat generic single words.
@@ -59,7 +66,7 @@ function scoreAgent(agent, text) {
59
66
  for (const hint of agent.routeHints) {
60
67
  if (hintRegex(hint).test(text)) {
61
68
  if (hint.includes(" ")) { matched.push(hint); score += 3; }
62
- else if (WEAK_HINTS.has(hint)) { weak.push(hint); score += 0.34; }
69
+ else if (WEAK_HINTS.has(hint)) { weak.push(hint); score += WEAK_WEIGHT; }
63
70
  else { matched.push(hint); score += 1; }
64
71
  }
65
72
  }
@@ -121,4 +128,4 @@ export function route(request = {}) {
121
128
  };
122
129
  }
123
130
 
124
- export { REGISTRY };
131
+ export { REGISTRY, WEAK_HINTS };
package/src/format.js ADDED
@@ -0,0 +1,90 @@
1
+ // Human-readable rendering of an auditor's change sheet.
2
+ //
3
+ // The auditors always emitted correct JSON, and JSON is the right thing for a program to
4
+ // consume — but it is the wrong thing to put in front of the person deciding whether to
5
+ // act. The whole value of a change sheet is that a human can read one line and know what
6
+ // to do, why, and what it costs to be wrong; a wall of braces hides exactly that. So the
7
+ // CLI renders by default and `--json` returns the raw object for piping.
8
+ //
9
+ // Deliberately plain text: no colour, no box-drawing, no spinners. This output gets pasted
10
+ // into an email to a client or a ticket, and it has to survive being pasted.
11
+
12
+ // Currency comes from the report, not from an assumption. Ad exports are denominated in
13
+ // whatever the account uses; hardcoding "$" renders a GBP account as dollars, which is a
14
+ // wrong number on a page whose whole job is numbers. With no currency declared, print the
15
+ // bare amount rather than assert one.
16
+ const money = (n, currency) => {
17
+ if (typeof n !== "number" || !Number.isFinite(n)) return null;
18
+ const opts = currency
19
+ ? { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }
20
+ : { minimumFractionDigits: 2, maximumFractionDigits: 2 };
21
+ try {
22
+ return n.toLocaleString("en-US", opts);
23
+ } catch {
24
+ // An unrecognised currency code must not take down the render.
25
+ return n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
26
+ }
27
+ };
28
+
29
+ /**
30
+ * True when a result looks like an auditor change sheet (rather than, say, a model answer
31
+ * or an orchestrator payload). Kept structural rather than name-based so a new core gets
32
+ * the rendering for free.
33
+ */
34
+ export function isChangeSheet(result) {
35
+ return Boolean(result && typeof result === "object" && Array.isArray(result.changes));
36
+ }
37
+
38
+ /**
39
+ * Render a change sheet as plain text. Never invents a field: anything missing is simply
40
+ * not printed, so a core that doesn't produce risk/evidence still renders cleanly.
41
+ */
42
+ export function formatChangeSheet(result) {
43
+ // Defensive by design: this is the last thing before the user's terminal, and everything
44
+ // upstream of it (tryCore, the clients, complete) fails soft with a plain note. A
45
+ // malformed core payload must degrade, never throw a stack trace at someone auditing an
46
+ // ad account.
47
+ if (!isChangeSheet(result)) return "";
48
+ const out = [];
49
+ const currency = typeof result.currency === "string" ? result.currency : null;
50
+ const changes = result.changes.filter((c) => c && typeof c === "object");
51
+ const dropped = result.changes.length - changes.length;
52
+
53
+ const flagged = money(result.wastedSpendRecoverable, currency);
54
+ const header = [`${changes.length} proposed change${changes.length === 1 ? "" : "s"}`];
55
+ // "flagged", not "recoverable": the tool measured spend against a threshold, it did not
56
+ // establish that the money comes back. Some flagged terms convert — see the risk line.
57
+ if (flagged) header.push(`${flagged} of spend flagged`);
58
+ out.push(header.join(" | "));
59
+
60
+ const a = result.account && typeof result.account === "object" ? result.account : null;
61
+ if (a) {
62
+ const bits = [];
63
+ if (typeof a.totalCost === "number") bits.push(`spend ${money(a.totalCost, currency)}`);
64
+ if (typeof a.totalClicks === "number") bits.push(`${a.totalClicks} clicks`);
65
+ if (typeof a.totalConversions === "number") bits.push(`${a.totalConversions} conversions`);
66
+ if (typeof a.accountCPA === "number") bits.push(`CPA ${money(a.accountCPA, currency)}`);
67
+ if (bits.length) out.push(`account: ${bits.join(", ")}`);
68
+ }
69
+ out.push("");
70
+
71
+ for (const c of changes) {
72
+ const amount = money(c.dollars, currency);
73
+ // A core that doesn't describe its change has nothing worth rendering as a headline;
74
+ // say so rather than printing "[object Object]".
75
+ const label = typeof c.change === "string" && c.change.trim() ? c.change : "(change not described)";
76
+ out.push(`${c.rank ?? "-"}. ${label}${amount ? ` ${amount}` : ""}`);
77
+ const line = (tag, v) => { if (typeof v === "string" && v.trim()) out.push(` ${tag} ${v}`); };
78
+ line("where ", c.where);
79
+ line("why ", c.why);
80
+ line("effect", c.expectedEffect);
81
+ line("risk ", c.risk);
82
+ out.push("");
83
+ }
84
+
85
+ if (dropped > 0) out.push(`(${dropped} malformed entr${dropped === 1 ? "y" : "ies"} skipped)`, "");
86
+ if (result.proposeOnly) {
87
+ out.push("Proposals only — nothing was sent to your ad account. Apply what you agree with.");
88
+ }
89
+ return out.join("\n");
90
+ }
package/src/invoke.js CHANGED
@@ -60,10 +60,35 @@ async function tryCore(agent, inputFile) {
60
60
  export async function invoke(agent, opts = {}) {
61
61
  if (!agent) return { agent: null, path: "none", note: "no agent to invoke" };
62
62
 
63
+ // Specialist legs (richy, pio) answer about an ORGANIZATION, so they run through
64
+ // orchestrate() rather than as leaf agents. This used to return a note saying so and
65
+ // stop — which meant the CLI (which had its own copy of this logic) did the real work
66
+ // while the SDK returned a dead end for the same call. One implementation, both doors.
63
67
  if (agent.tier === 1 && agent.wire) {
68
+ const org = opts.org ?? process.env.WEFTENS_ORG ?? null;
69
+ const website = opts.website ?? process.env.WEFTENS_WEBSITE ?? null;
70
+ if (!org) {
71
+ return {
72
+ agent: agent.name, path: "orchestrator", needs: "org",
73
+ note: `${agent.name} answers about a specific organization — pass an org.
74
+ ` +
75
+ ` once: weftens send ${agent.name} --org "Acme Co" [--website acme.com] "<question>"
76
+ ` +
77
+ ` always: set WEFTENS_ORG="Acme Co" (and optionally WEFTENS_WEBSITE)
78
+ ` +
79
+ ` SDK: weftens("<question>", { org: "Acme Co" })`,
80
+ };
81
+ }
82
+ const { orchestrate } = await import("./weftens.js");
83
+ const { buildSpecialistClients } = await import("./specialists.js");
84
+ const intent = agent.name === "richy" ? "representation" : "operations";
85
+ const result = await orchestrate(
86
+ { org: { name: org, ...(website ? { website } : {}) }, intent, question: opts.text || undefined },
87
+ opts.agents ?? buildSpecialistClients()
88
+ );
64
89
  return {
65
- agent: agent.name, path: "orchestrator",
66
- note: `${agent.name} runs through the Weftens orchestrator over its wire contract (use the orchestrate() path); not invoked as a leaf`,
90
+ agent: agent.name, path: "orchestrator", result,
91
+ note: `ran ${agent.name} over ${org} (intent: ${intent})`,
67
92
  };
68
93
  }
69
94
 
package/src/registry.js CHANGED
@@ -1,160 +1,163 @@
1
- // The Weftens agent registry — the single source of truth for which agents exist and
2
- // how a request finds one.
3
- //
4
- // Routing is deterministic and explainable (see agent-router.js): a request matches an
5
- // agent by its declared routeHints, and you can always see which hints matched. No
6
- // model call is needed to route — the model only runs when an agent is invoked to
7
- // produce, so the routing layer is verifiable with no API key.
8
- //
9
- // Fields:
10
- // tier 1 = lead that owns sub-agents (richy, pio, content); 2 = leaf. invoke()
11
- // branches on this.
12
- // core path to a deterministic agent-cores package, or absent.
13
- // coreBin, how to shell that core's CLI (it does parse+analyze+format). Present only
14
- // coreInputFlag where wired; absent -> invoke reports the core isn't wired yet.
15
- // leads tier-1 content only: the pipeline it delegates to.
16
- // wire tier-1 richy/pio only: they answer through the existing orchestrator, not here.
17
- // ownedBy content-team seats owned by the Content lead — not dispatched by the door.
18
- // seatFile the .md seat used as the system prompt when a seat produces via the model.
19
- // routeHints keyword/phrase signals for the router.
20
- //
21
- // Note: seo-technical+seo-content and amazon-ppc+paid-search map to single real job
22
- // postings, so they are merge candidates — but the merge is deferred until the roster
23
- // actually needs consolidating. Merging early only adds structure nothing reads yet.
24
-
25
- // One home: the agent seats live in this repo (weftens/agents). Override with
26
- // WEFTENS_AGENTS_DIR. No external directory and no fallback — one canonical location.
27
- import { join, dirname } from "node:path";
28
- import { fileURLToPath } from "node:url";
29
-
30
- const AGENTS_DIR = process.env.WEFTENS_AGENTS_DIR || join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
31
- const seat = (name) => join(AGENTS_DIR, `${name}.md`);
32
-
33
- export const REGISTRY = [
34
- // ---- Tier 1: lead agents (own sub-agents / their own engines) ----
35
- {
36
- name: "richy", tier: 1, team: "weftens", wire: "representationCheck", seatFile: seat("richy"),
37
- routeHints: ["represent", "representation", "reputation", "online", "website", "site",
38
- "review", "listing", "search", "seo", "visibility", "audit", "appear",
39
- "how do we look", "what do people see", "brand", "knowledge panel", "ai answer"],
40
- },
41
- {
42
- name: "pio", tier: 1, team: "weftens", wire: "operationsSummary", seatFile: seat("pio"),
43
- routeHints: ["event", "attendee", "participant", "check-in", "checkin", "roster",
44
- "grant", "funding", "funder", "program", "people", "follow-up", "followup", "donor",
45
- "prospect", "attendance", "registration", "operation", "who should i meet", "dossier",
46
- // Bare "people" is too generic to dispatch on (it's a weak hint), but these PHRASES are
47
- // unambiguous asks that a customer actually types. Without them, "we need people ops"
48
- // matched only the weak "people" and fell through to no-route.
49
- "people ops", "people operations", "ops help", "back office", "development ops",
50
- "who funds", "funding pipeline", "grant research", "prospect research"],
51
- },
52
- {
53
- name: "content", tier: 1, team: "content", leads: "content-org", seatFile: null,
54
- routeHints: ["make content", "content", "script", "reel", "video", "caption", "post",
55
- "series", "episode", "produce", "storyboard", "shot list"],
56
- },
57
-
58
- // ---- Tier 2: marketing/SEO leaves with deterministic agent-cores ----
59
- {
60
- name: "seo-technical", tier: 2, team: "seo", core: "agent-cores/seo-technical", seatFile: seat("seo-technical"),
61
- routeHints: ["crawl", "index", "indexing", "render", "schema", "structured data",
62
- "robots", "sitemap", "canonical", "core web vitals", "technical seo", "rich result"],
63
- },
64
- {
65
- name: "seo-content", tier: 2, team: "seo", core: "agent-cores/seo-content", seatFile: seat("seo-content"),
66
- routeHints: ["keyword", "topic", "entity", "content strategy", "brief",
67
- "serp", "rankings", "answer surface", "citation", "gsc", "search console"],
68
- },
69
- {
70
- // paid-search is the one core whose CLI takes a flag (--search-terms <csv>) rather
71
- // than a positional file, so it overrides the positional default in invoke.
72
- name: "paid-search", tier: 2, team: "marketing", core: "agent-cores/paid-search",
73
- coreInputFlag: "--search-terms", seatFile: seat("paid-search"),
74
- routeHints: ["google ads", "paid search", "ppc", "search terms", "negatives",
75
- "bid", "budget", "impression share", "quality score", "change sheet"],
76
- },
77
- {
78
- name: "amazon-ppc", tier: 2, team: "marketing", core: "agent-cores/amazon-ppc", seatFile: seat("amazon-ppc"),
79
- routeHints: ["amazon", "amazon ads", "sponsored products", "acos", "tacos",
80
- "harvest", "negate", "amazon search term", "sponsored brand"],
81
- },
82
- {
83
- name: "meta-ads", tier: 2, team: "marketing", core: "agent-cores/meta-ads", seatFile: seat("meta-ads"),
84
- routeHints: ["meta", "facebook", "instagram", "meta ads", "facebook ads", "instagram ads",
85
- "ad set", "adset", "creative fatigue", "frequency", "roas", "ads manager"],
86
- },
87
- {
88
- name: "supply-verify", tier: 2, team: "marketing", core: "agent-cores/supply-verify", seatFile: seat("supply-verify"),
89
- routeHints: ["placement", "mfa", "made for advertising", "ivt", "invalid traffic",
90
- "supply path", "log-level", "programmatic", "ad spend waste", "exclusion"],
91
- },
92
- {
93
- name: "content-ops", tier: 2, team: "content", core: "agent-cores/content-ops", seatFile: seat("content-ops"),
94
- routeHints: ["content inventory", "cms export", "keep update retire", "migration",
95
- "content audit", "tagging", "metadata cleanup", "template conform"],
96
- },
97
-
98
- // ---- Tier 2: marketing/PR leaves (no deterministic core — model-only) ----
99
- {
100
- name: "social-voice", tier: 2, team: "marketing", seatFile: seat("social-voice"),
101
- routeHints: ["social", "instagram", "tiktok", "linkedin", "reply", "engagement",
102
- "calendar", "per-platform", "voice-locked", "community management"],
103
- },
104
- {
105
- name: "claims-content", tier: 2, team: "content", seatFile: seat("claims-content"),
106
- routeHints: ["health", "pharma", "finance", "supplement", "regulated", "claim",
107
- "medical review", "legal review", "compliance", "substantiation"],
108
- },
109
- {
110
- name: "growth-marketing", tier: 2, team: "marketing", seatFile: seat("growth-marketing"),
111
- // "cro"/"funnel"/"activation" are the words a practitioner uses; the words a CUSTOMER
112
- // uses for the same problem are "converts badly", "signups", "checkout". Without those
113
- // the seat was unreachable by anyone describing their own problem in their own words.
114
- routeHints: ["growth", "acquisition", "channel", "funnel", "cro", "lifecycle",
115
- "campaign design", "experiment", "retention", "activation",
116
- "convert", "converts", "conversion", "converting", "signup", "sign-up",
117
- "checkout", "landing page", "drop off", "drop-off", "abandon", "onboarding"],
118
- },
119
- {
120
- name: "security-writer", tier: 2, team: "security", seatFile: seat("security-writer"),
121
- routeHints: ["security content", "technical writing", "security explainer",
122
- "threat", "vulnerability writeup", "security training", "devsecops doc"],
123
- },
124
-
125
- // ---- Tier 2: evaluation leaves (agents whose job is judgment — normal pool) ----
126
- {
127
- name: "hilbert", tier: 2, team: "eval", seatFile: seat("hilbert"),
128
- routeHints: ["headline", "tagline", "name", "slogan", "will this survive",
129
- "memetic", "does this stop someone", "positioning line", "pitch line", "repeated"],
130
- },
131
- {
132
- name: "lippmann", tier: 2, team: "eval", seatFile: seat("lippmann"),
133
- routeHints: ["perception", "what do people believe", "public sources", "reputation read",
134
- "how is x seen", "sentiment", "frame provenance", "before committing"],
135
- },
136
- {
137
- name: "agent2060", tier: 2, team: "eval", seatFile: seat("agent2060"),
138
- routeHints: ["foresight", "future", "durable", "will this last", "trend", "horizon",
139
- "bet", "does this survive", "strategic call", "category"],
140
- },
141
-
142
- // ---- Tier 2: content-team seats, owned by the Content lead (not dispatched by the door) ----
143
- ...["content-strategy", "ideator", "researcher", "writer", "script-editor",
144
- "showrunner", "video-editor", "feed-optimizer", "disclosure-qc", "growth-analyst"]
145
- .map((name) => ({
146
- name, tier: 2, team: "content", seatFile: seat(name), ownedBy: "content",
147
- routeHints: [name.replace(/-/g, " ")],
148
- })),
149
- ];
150
-
151
- export function agentByName(name) {
152
- const key = String(name).toLowerCase().trim();
153
- return REGISTRY.find((a) => a.name === key) ?? null;
154
- }
155
-
156
- // Agents the front door auto-dispatches over — everything except the content-team
157
- // seats the Content lead owns (the door routes to `content`, which owns those).
158
- export function dispatchableAgents() {
159
- return REGISTRY.filter((a) => !a.ownedBy);
160
- }
1
+ // The Weftens agent registry — the single source of truth for which agents exist and
2
+ // how a request finds one.
3
+ //
4
+ // Routing is deterministic and explainable (see agent-router.js): a request matches an
5
+ // agent by its declared routeHints, and you can always see which hints matched. No
6
+ // model call is needed to route — the model only runs when an agent is invoked to
7
+ // produce, so the routing layer is verifiable with no API key.
8
+ //
9
+ // Fields:
10
+ // tier 1 = lead that owns sub-agents (richy, pio, content); 2 = leaf. invoke()
11
+ // branches on this.
12
+ // core path to a deterministic agent-cores package, or absent.
13
+ // coreBin, how to shell that core's CLI (it does parse+analyze+format). Present only
14
+ // coreInputFlag where wired; absent -> invoke reports the core isn't wired yet.
15
+ // leads tier-1 content only: the pipeline it delegates to.
16
+ // wire tier-1 richy/pio only: they answer through the existing orchestrator, not here.
17
+ // ownedBy content-team seats owned by the Content lead — not dispatched by the door.
18
+ // seatFile the .md seat used as the system prompt when a seat produces via the model.
19
+ // routeHints keyword/phrase signals for the router.
20
+ //
21
+ // Note: seo-technical+seo-content and amazon-ppc+paid-search map to single real job
22
+ // postings, so they are merge candidates — but the merge is deferred until the roster
23
+ // actually needs consolidating. Merging early only adds structure nothing reads yet.
24
+
25
+ // One home: the agent seats live in this repo (weftens/agents). Override with
26
+ // WEFTENS_AGENTS_DIR. No external directory and no fallback — one canonical location.
27
+ import { join, dirname } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const AGENTS_DIR = process.env.WEFTENS_AGENTS_DIR || join(dirname(fileURLToPath(import.meta.url)), "..", "agents");
31
+ const seat = (name) => join(AGENTS_DIR, `${name}.md`);
32
+
33
+ export const REGISTRY = [
34
+ // ---- Tier 1: lead agents (own sub-agents / their own engines) ----
35
+ {
36
+ name: "richy", tier: 1, team: "weftens", wire: "representationCheck", seatFile: seat("richy"),
37
+ routeHints: ["represent", "representation", "reputation", "online", "website", "site",
38
+ "review", "listing", "search", "seo", "visibility", "audit", "appear",
39
+ "how do we look", "what do people see", "brand", "knowledge panel", "ai answer"],
40
+ },
41
+ {
42
+ name: "pio", tier: 1, team: "weftens", wire: "operationsSummary", seatFile: seat("pio"),
43
+ routeHints: ["event", "attendee", "participant", "check-in", "checkin", "roster",
44
+ "grant", "funding", "funder", "program", "people", "follow-up", "followup", "donor",
45
+ "prospect", "attendance", "registration", "operation", "who should i meet", "dossier",
46
+ // Bare "people" is too generic to dispatch on (it's a weak hint), but these PHRASES are
47
+ // unambiguous asks that a customer actually types. Without them, "we need people ops"
48
+ // matched only the weak "people" and fell through to no-route.
49
+ "people ops", "people operations", "ops help", "back office", "development ops",
50
+ "who funds", "funding pipeline", "grant research", "prospect research"],
51
+ },
52
+ {
53
+ name: "content", tier: 1, team: "content", leads: "content-org", seatFile: null,
54
+ routeHints: ["make content", "content", "script", "reel", "video", "caption", "post",
55
+ "series", "episode", "produce", "storyboard", "shot list"],
56
+ },
57
+
58
+ // ---- Tier 2: marketing/SEO leaves with deterministic agent-cores ----
59
+ {
60
+ name: "seo-technical", tier: 2, team: "seo", core: "agent-cores/seo-technical", seatFile: seat("seo-technical"),
61
+ routeHints: ["crawl", "index", "indexing", "render", "schema", "structured data",
62
+ "robots", "sitemap", "canonical", "core web vitals", "technical seo", "rich result"],
63
+ },
64
+ {
65
+ name: "seo-content", tier: 2, team: "seo", core: "agent-cores/seo-content", seatFile: seat("seo-content"),
66
+ routeHints: ["keyword", "topic", "entity", "content strategy", "brief",
67
+ "serp", "rankings", "answer surface", "citation", "gsc", "search console"],
68
+ },
69
+ {
70
+ // paid-search is the one core whose CLI takes a flag (--search-terms <csv>) rather
71
+ // than a positional file, so it overrides the positional default in invoke.
72
+ name: "paid-search", tier: 2, team: "marketing", core: "agent-cores/paid-search",
73
+ coreInputFlag: "--search-terms", seatFile: seat("paid-search"),
74
+ routeHints: ["google ads", "paid search", "ppc", "search terms", "negatives",
75
+ "bid", "budget", "impression share", "quality score", "change sheet"],
76
+ },
77
+ {
78
+ name: "amazon-ppc", tier: 2, team: "marketing", core: "agent-cores/amazon-ppc", seatFile: seat("amazon-ppc"),
79
+ routeHints: ["amazon", "amazon ads", "sponsored products", "acos", "tacos",
80
+ "harvest", "negate", "amazon search term", "sponsored brand"],
81
+ },
82
+ {
83
+ name: "meta-ads", tier: 2, team: "marketing", core: "agent-cores/meta-ads", seatFile: seat("meta-ads"),
84
+ // No bare "instagram": the platform name is not an intent, and social-voice (organic)
85
+ // wants the same word. A permanent tie routes by scoring accident, so both sides carry
86
+ // the phrase that says which job is being asked for.
87
+ routeHints: ["meta", "facebook", "meta ads", "facebook ads", "instagram ads",
88
+ "ad set", "adset", "creative fatigue", "frequency", "roas", "ads manager"],
89
+ },
90
+ {
91
+ name: "supply-verify", tier: 2, team: "marketing", core: "agent-cores/supply-verify", seatFile: seat("supply-verify"),
92
+ routeHints: ["placement", "mfa", "made for advertising", "ivt", "invalid traffic",
93
+ "supply path", "log-level", "programmatic", "ad spend waste", "exclusion"],
94
+ },
95
+ {
96
+ name: "content-ops", tier: 2, team: "content", core: "agent-cores/content-ops", seatFile: seat("content-ops"),
97
+ routeHints: ["content inventory", "cms export", "keep update retire", "migration",
98
+ "content audit", "tagging", "metadata cleanup", "template conform"],
99
+ },
100
+
101
+ // ---- Tier 2: marketing/PR leaves (no deterministic core — model-only) ----
102
+ {
103
+ name: "social-voice", tier: 2, team: "marketing", seatFile: seat("social-voice"),
104
+ routeHints: ["social", "instagram post", "post on instagram", "tiktok", "linkedin", "reply", "engagement",
105
+ "calendar", "per-platform", "voice-locked", "community management"],
106
+ },
107
+ {
108
+ name: "claims-content", tier: 2, team: "content", seatFile: seat("claims-content"),
109
+ routeHints: ["health", "pharma", "finance", "supplement", "regulated", "claim",
110
+ "medical review", "legal review", "compliance", "substantiation"],
111
+ },
112
+ {
113
+ name: "growth-marketing", tier: 2, team: "marketing", seatFile: seat("growth-marketing"),
114
+ // "cro"/"funnel"/"activation" are the words a practitioner uses; the words a CUSTOMER
115
+ // uses for the same problem are "converts badly", "signups", "checkout". Without those
116
+ // the seat was unreachable by anyone describing their own problem in their own words.
117
+ routeHints: ["growth", "acquisition", "channel", "funnel", "cro", "lifecycle",
118
+ "campaign design", "experiment", "retention", "activation",
119
+ "convert", "converts", "conversion", "converting", "signup", "sign-up",
120
+ "checkout", "landing page", "drop off", "drop-off", "abandon", "onboarding"],
121
+ },
122
+ {
123
+ name: "security-writer", tier: 2, team: "security", seatFile: seat("security-writer"),
124
+ routeHints: ["security content", "technical writing", "security explainer",
125
+ "threat", "vulnerability writeup", "security training", "devsecops doc"],
126
+ },
127
+
128
+ // ---- Tier 2: evaluation leaves (agents whose job is judgment — normal pool) ----
129
+ {
130
+ name: "hilbert", tier: 2, team: "eval", seatFile: seat("hilbert"),
131
+ routeHints: ["headline", "tagline", "name", "slogan", "will this survive",
132
+ "memetic", "does this stop someone", "positioning line", "pitch line", "repeated"],
133
+ },
134
+ {
135
+ name: "lippmann", tier: 2, team: "eval", seatFile: seat("lippmann"),
136
+ routeHints: ["perception", "what do people believe", "public sources", "reputation read",
137
+ "how is x seen", "sentiment", "frame provenance", "before committing"],
138
+ },
139
+ {
140
+ name: "agent2060", tier: 2, team: "eval", seatFile: seat("agent2060"),
141
+ routeHints: ["foresight", "future", "durable", "will this last", "trend", "horizon",
142
+ "bet", "does this survive", "strategic call", "category"],
143
+ },
144
+
145
+ // ---- Tier 2: content-team seats, owned by the Content lead (not dispatched by the door) ----
146
+ ...["content-strategy", "ideator", "researcher", "writer", "script-editor",
147
+ "showrunner", "video-editor", "feed-optimizer", "disclosure-qc", "growth-analyst"]
148
+ .map((name) => ({
149
+ name, tier: 2, team: "content", seatFile: seat(name), ownedBy: "content",
150
+ routeHints: [name.replace(/-/g, " ")],
151
+ })),
152
+ ];
153
+
154
+ export function agentByName(name) {
155
+ const key = String(name).toLowerCase().trim();
156
+ return REGISTRY.find((a) => a.name === key) ?? null;
157
+ }
158
+
159
+ // Agents the front door auto-dispatches over — everything except the content-team
160
+ // seats the Content lead owns (the door routes to `content`, which owns those).
161
+ export function dispatchableAgents() {
162
+ return REGISTRY.filter((a) => !a.ownedBy);
163
+ }
@@ -0,0 +1,50 @@
1
+ // Building the specialist clients (Richy, PIO) from the environment.
2
+ //
3
+ // This lived in bin/weftens.js, which meant the CLI could reach live specialist services
4
+ // and the SDK could not — the same call did different things depending on which door you
5
+ // came through. It belongs in src/ so both surfaces share one implementation.
6
+ //
7
+ // A specialist is live only when its FULL env group is set. A partial group is a hard
8
+ // error, not a silent fixture fallback: an operator who typo'd one variable would
9
+ // otherwise get sample data while believing they were looking at their own organization.
10
+
11
+ import { RichyClient, FixtureRichyClient } from "./richy-client.js";
12
+ import { PioClient, FixturePioClient } from "./pio-client.js";
13
+
14
+ function envGroup(label, names, env) {
15
+ const set = names.filter((n) => env[n]);
16
+ if (set.length === 0) return null;
17
+ if (set.length < names.length) {
18
+ const missing = names.filter((n) => !env[n]).join(", ");
19
+ throw new Error(
20
+ `${label} is partially configured — set ${missing} (or unset ${set.join(", ")} to run with sample data)`
21
+ );
22
+ }
23
+ return Object.fromEntries(names.map((n) => [n, env[n]]));
24
+ }
25
+
26
+ /**
27
+ * @param opts { env?, onFixture? } — onFixture is called with the names of the specialists
28
+ * falling back to sample data, so a CLI can warn on stderr and a library can stay quiet.
29
+ * @returns { richy, pio } clients — real when configured, clearly-flagged fixtures otherwise.
30
+ */
31
+ export function buildSpecialistClients({ env = process.env, onFixture } = {}) {
32
+ const richyEnv = envGroup("Richy", ["WEFTENS_RICHY_URL", "WEFTENS_RICHY_KEY"], env);
33
+ const pioEnv = envGroup("PIO", ["WEFTENS_PIO_URL", "WEFTENS_PIO_MEMBER", "WEFTENS_PIO_PASSWORD"], env);
34
+
35
+ const richy = richyEnv
36
+ ? new RichyClient({ baseUrl: richyEnv.WEFTENS_RICHY_URL, key: richyEnv.WEFTENS_RICHY_KEY })
37
+ : new FixtureRichyClient();
38
+ const pio = pioEnv
39
+ ? new PioClient({
40
+ baseUrl: pioEnv.WEFTENS_PIO_URL,
41
+ member: pioEnv.WEFTENS_PIO_MEMBER,
42
+ password: pioEnv.WEFTENS_PIO_PASSWORD,
43
+ tenant: env.WEFTENS_PIO_TENANT ?? null,
44
+ })
45
+ : new FixturePioClient();
46
+
47
+ const fixtures = [!richyEnv && "Richy", !pioEnv && "PIO"].filter(Boolean);
48
+ if (fixtures.length > 0 && typeof onFixture === "function") onFixture(fixtures);
49
+ return { richy, pio };
50
+ }