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.
- package/README.md +138 -12
- package/agent-cores/amazon-ppc/package.json +17 -0
- package/agent-cores/amazon-ppc/src/cli.mjs +66 -0
- package/agent-cores/amazon-ppc/src/optimize.mjs +272 -0
- package/agent-cores/amazon-ppc/src/parse.mjs +153 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.csv +8 -0
- package/agent-cores/amazon-ppc/test/fixtures/search-term-report.json +5 -0
- package/agent-cores/amazon-ppc/test/optimize.test.mjs +161 -0
- package/agent-cores/content-ops/package.json +17 -0
- package/agent-cores/content-ops/src/cli.mjs +51 -0
- package/agent-cores/content-ops/src/inventory.mjs +343 -0
- package/agent-cores/content-ops/src/parse.mjs +121 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.csv +13 -0
- package/agent-cores/content-ops/test/fixtures/cms-export.json +31 -0
- package/agent-cores/content-ops/test/inventory.test.mjs +177 -0
- package/agent-cores/meta-ads/package.json +16 -0
- package/agent-cores/meta-ads/src/analyze.mjs +166 -0
- package/agent-cores/meta-ads/src/cli.mjs +39 -0
- package/agent-cores/meta-ads/src/parse.mjs +123 -0
- package/agent-cores/meta-ads/test/analyze.test.js +72 -0
- package/agent-cores/meta-ads/test/fixtures/meta-ads.csv +6 -0
- package/agent-cores/paid-search/package.json +17 -0
- package/agent-cores/paid-search/src/analyze.mjs +303 -0
- package/agent-cores/paid-search/src/cli.mjs +57 -0
- package/agent-cores/paid-search/src/parse.mjs +158 -0
- package/agent-cores/paid-search/test/analyze.test.mjs +120 -0
- package/agent-cores/paid-search/test/fixtures/campaigns.csv +7 -0
- package/agent-cores/paid-search/test/fixtures/sample.json +9 -0
- package/agent-cores/paid-search/test/fixtures/search-terms.csv +11 -0
- package/agent-cores/paid-search/test/parse.test.mjs +56 -0
- package/agent-cores/seo-content/package.json +17 -0
- package/agent-cores/seo-content/src/cli.mjs +41 -0
- package/agent-cores/seo-content/src/parse.mjs +141 -0
- package/agent-cores/seo-content/src/strategy.mjs +266 -0
- package/agent-cores/seo-content/test/fixtures/gsc-sample.csv +11 -0
- package/agent-cores/seo-content/test/parse.test.mjs +59 -0
- package/agent-cores/seo-content/test/strategy.test.mjs +108 -0
- package/agent-cores/seo-technical/package.json +17 -0
- package/agent-cores/seo-technical/src/audit.mjs +233 -0
- package/agent-cores/seo-technical/src/cli.mjs +48 -0
- package/agent-cores/seo-technical/src/fetch.mjs +51 -0
- package/agent-cores/seo-technical/test/audit.test.mjs +73 -0
- package/agent-cores/seo-technical/test/fixtures/sample.html +22 -0
- package/agent-cores/supply-verify/package.json +17 -0
- package/agent-cores/supply-verify/src/cli.mjs +44 -0
- package/agent-cores/supply-verify/src/verify.mjs +303 -0
- package/agent-cores/supply-verify/test/fixtures/placements.csv +8 -0
- package/agent-cores/supply-verify/test/fixtures/placements.json +4 -0
- package/agent-cores/supply-verify/test/verify.test.mjs +148 -0
- package/agents/agent2060.md +99 -0
- package/agents/amazon-ppc.md +42 -0
- package/agents/claims-content.md +35 -0
- package/agents/content-ops.md +46 -0
- package/agents/content-strategy.md +13 -0
- package/agents/disclosure-qc.md +13 -0
- package/agents/feed-optimizer.md +13 -0
- package/agents/growth-analyst.md +24 -0
- package/agents/hilbert.md +55 -0
- package/agents/ideator.md +13 -0
- package/agents/lippmann.md +68 -0
- package/agents/paid-search.md +35 -0
- package/agents/pio.md +34 -0
- package/agents/researcher.md +13 -0
- package/agents/richy.md +72 -0
- package/agents/script-editor.md +13 -0
- package/agents/seo-content.md +57 -0
- package/agents/seo-technical.md +53 -0
- package/agents/showrunner.md +13 -0
- package/agents/social-voice.md +38 -0
- package/agents/supply-verify.md +38 -0
- package/agents/video-editor.md +13 -0
- package/agents/writer.md +45 -0
- package/bin/weftens.js +226 -0
- package/content-org/.preflight/telemetry.jsonl +6 -0
- package/content-org/briefs/BRIEFS.md +47 -0
- package/content-org/config.mjs +78 -0
- package/content-org/cost.mjs +36 -0
- package/content-org/lead.js +64 -0
- package/content-org/metrics.mjs +166 -0
- package/content-org/orchestrator.mjs +246 -0
- package/content-org/package.json +14 -0
- package/content-org/producer.mjs +59 -0
- package/content-org/publish.mjs +34 -0
- package/content-org/review.mjs +32 -0
- package/content-org/studio/README.md +27 -0
- package/content-org/studio/assemble.sh +48 -0
- package/content-org/test.mjs +110 -0
- package/index.js +32 -0
- package/package.json +49 -10
- package/providers.json.example +9 -0
- package/src/agent-router.js +124 -0
- package/src/agent.js +123 -0
- package/src/invoke.js +129 -0
- package/src/pio-client.js +142 -0
- package/src/provider.js +81 -0
- package/src/registry.js +160 -0
- package/src/richy-client.js +216 -0
- package/src/router.js +34 -0
- package/src/synthesize.js +111 -0
- package/src/weftens.js +61 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// content-org — no-dependency test script (plain node, no vitest).
|
|
2
|
+
// Run: npm test
|
|
3
|
+
// Covers: (1) seat schema validation for all 10 seats in dry-run,
|
|
4
|
+
// (2) single-seat CLI mode, (3) review/publish queue round-trip.
|
|
5
|
+
// The queue round-trip runs in a scratch dir so it never touches the real queue/.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync, mkdtempSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { SEATS, stub } from "./orchestrator.mjs";
|
|
13
|
+
import { SEAT_MODEL, MODELS } from "./config.mjs";
|
|
14
|
+
|
|
15
|
+
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
let pass = 0, fail = 0;
|
|
17
|
+
function check(name, ok, detail = "") {
|
|
18
|
+
if (ok) { pass++; console.log(` ok ${name}`); }
|
|
19
|
+
else { fail++; console.error(`FAIL ${name}${detail ? " — " + detail : ""}`); }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Minimal validator for the subset of JSON Schema the seats use.
|
|
23
|
+
function validate(value, schema, path = "$") {
|
|
24
|
+
const errs = [];
|
|
25
|
+
if (schema.type === "string") { if (typeof value !== "string") errs.push(`${path}: not a string`); return errs; }
|
|
26
|
+
if (schema.type === "boolean") { if (typeof value !== "boolean") errs.push(`${path}: not a boolean`); return errs; }
|
|
27
|
+
if (schema.type === "number") { if (typeof value !== "number") errs.push(`${path}: not a number`); return errs; }
|
|
28
|
+
if (schema.type === "array") {
|
|
29
|
+
if (!Array.isArray(value)) { errs.push(`${path}: not an array`); return errs; }
|
|
30
|
+
value.forEach((v, i) => errs.push(...validate(v, schema.items, `${path}[${i}]`)));
|
|
31
|
+
return errs;
|
|
32
|
+
}
|
|
33
|
+
if (schema.type === "object") {
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) { errs.push(`${path}: not an object`); return errs; }
|
|
35
|
+
for (const req of schema.required || []) if (!(req in value)) errs.push(`${path}.${req}: missing (required)`);
|
|
36
|
+
for (const [k, v] of Object.entries(value)) {
|
|
37
|
+
if (!schema.properties[k]) { if (schema.additionalProperties === false) errs.push(`${path}.${k}: unexpected property`); continue; }
|
|
38
|
+
errs.push(...validate(v, schema.properties[k], `${path}.${k}`));
|
|
39
|
+
}
|
|
40
|
+
return errs;
|
|
41
|
+
}
|
|
42
|
+
errs.push(`${path}: unknown schema type ${schema.type}`);
|
|
43
|
+
return errs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- 1) All 9 seats: dry-run stub output conforms to each seat's own schema ------------
|
|
47
|
+
console.log("\n[1] seat schemas (dry-run stubs) — 10 seats");
|
|
48
|
+
const EXPECTED_SEATS = ["showrunner", "strategy", "ideator", "research", "script", "editor", "cut", "feed", "qc", "growth"];
|
|
49
|
+
check("all 10 seats registered", EXPECTED_SEATS.every((k) => SEATS[k]) && Object.keys(SEATS).length === 10,
|
|
50
|
+
`have: ${Object.keys(SEATS).join(",")}`);
|
|
51
|
+
for (const key of EXPECTED_SEATS) {
|
|
52
|
+
const seat = SEATS[key];
|
|
53
|
+
if (!seat) { check(`seat ${key}`, false, "missing"); continue; }
|
|
54
|
+
const out = stub(seat.schema, key);
|
|
55
|
+
const errs = validate(out, seat.schema);
|
|
56
|
+
check(`stub(${key}) matches schema`, errs.length === 0, errs.join("; "));
|
|
57
|
+
check(`${key} routes to a real model`, !!MODELS[SEAT_MODEL[key]], `SEAT_MODEL.${key}=${SEAT_MODEL[key]}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- 2) Single-seat CLI mode (dry-run, no API key needed) ------------------------------
|
|
61
|
+
console.log("\n[2] single-seat CLI mode");
|
|
62
|
+
for (const key of ["showrunner", "qc"]) {
|
|
63
|
+
let out, parsed = null, errs = ["did not run"];
|
|
64
|
+
try {
|
|
65
|
+
out = execFileSync(process.execPath, ["orchestrator.mjs", "--seat", key, "--input", '{"format":"scam-reveal"}', "--dry-run"],
|
|
66
|
+
{ cwd: ROOT, encoding: "utf8" });
|
|
67
|
+
parsed = JSON.parse(out);
|
|
68
|
+
errs = validate(parsed, SEATS[key].schema);
|
|
69
|
+
} catch (e) { errs = [e.message]; }
|
|
70
|
+
check(`--seat ${key} --dry-run emits schema-valid JSON`, parsed !== null && errs.length === 0, errs.join("; "));
|
|
71
|
+
}
|
|
72
|
+
// unknown seat must exit non-zero
|
|
73
|
+
let badExit = false;
|
|
74
|
+
try { execFileSync(process.execPath, ["orchestrator.mjs", "--seat", "nope", "--input", "x", "--dry-run"], { cwd: ROOT, stdio: "pipe" }); }
|
|
75
|
+
catch { badExit = true; }
|
|
76
|
+
check("--seat with unknown key exits non-zero", badExit);
|
|
77
|
+
|
|
78
|
+
// --- 3) review/publish queue round-trip (scratch dir; real queue/ untouched) -----------
|
|
79
|
+
console.log("\n[3] review/publish queue round-trip");
|
|
80
|
+
const scratch = mkdtempSync(join(tmpdir(), "content-org-test-"));
|
|
81
|
+
try {
|
|
82
|
+
const slug = "test-piece";
|
|
83
|
+
mkdirSync(join(scratch, "queue", slug), { recursive: true });
|
|
84
|
+
const manifest = {
|
|
85
|
+
slug, format: "nostalgia", client: "testco", tier: "starter",
|
|
86
|
+
approval: "auto", reel: `${slug}.mp4`, status: "ready-for-approval",
|
|
87
|
+
qc: { blockers: [] }, feed: { caption: "test caption" },
|
|
88
|
+
};
|
|
89
|
+
writeFileSync(join(scratch, "queue", slug, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
90
|
+
|
|
91
|
+
const reviewOut = execFileSync(process.execPath, [join(ROOT, "review.mjs")], { cwd: scratch, encoding: "utf8" });
|
|
92
|
+
check("review.mjs lists the queued piece", reviewOut.includes(slug) && reviewOut.includes("1 in queue"));
|
|
93
|
+
check("review.mjs shows auto approval", /auto/.test(reviewOut));
|
|
94
|
+
|
|
95
|
+
execFileSync(process.execPath, [join(ROOT, "publish.mjs"), slug], { cwd: scratch, encoding: "utf8" });
|
|
96
|
+
const log = readFileSync(join(scratch, "published", "log.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
|
97
|
+
check("publish.mjs appends to published/log.jsonl", log.length === 1 && log[0].slug === slug);
|
|
98
|
+
check("published entry carries caption + reel", log[0].caption === "test caption" && log[0].reel === `${slug}.mp4`);
|
|
99
|
+
check("published entry marks Meta post as not wired", /not wired/.test(log[0].note || ""));
|
|
100
|
+
|
|
101
|
+
// 'auto' mode publishes only clean auto-approved pieces
|
|
102
|
+
execFileSync(process.execPath, [join(ROOT, "publish.mjs"), "auto"], { cwd: scratch, encoding: "utf8" });
|
|
103
|
+
const log2 = readFileSync(join(scratch, "published", "log.jsonl"), "utf8").trim().split("\n");
|
|
104
|
+
check("publish.mjs auto mode picks up the auto-approved piece", log2.length === 2);
|
|
105
|
+
} finally {
|
|
106
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
110
|
+
process.exit(fail ? 1 : 0);
|
package/index.js
ADDED
|
@@ -0,0 +1,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
|
+
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,49 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "weftens",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "weftens",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./cli": "./bin/weftens.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"weftens": "bin/weftens.js"
|
|
13
|
+
},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"agents",
|
|
19
|
+
"agent-cores",
|
|
20
|
+
"content-org",
|
|
21
|
+
"index.js",
|
|
22
|
+
"providers.json.example",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "npm run test:unit && npm run test:cores",
|
|
28
|
+
"test:unit": "vitest run",
|
|
29
|
+
"test:cores": "node --test \"agent-cores/**/test/*.test.mjs\" \"agent-cores/**/test/*.test.js\"",
|
|
30
|
+
"start": "node bin/weftens.js"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"vitest": "^2.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@anthropic-ai/sdk": "^0.110.0"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/Weftens/weftens.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/Weftens/weftens#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/Weftens/weftens/issues"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Copy this to providers.json and edit. Each entry maps a provider name to the CLI that runs it (a tool you already have installed and are logged into — your own subscription). Pick the active one with the WEFTENS_PROVIDER env var; default is 'claude'. The prompt is piped to the CLI on stdin, so 'args' holds only flags.",
|
|
3
|
+
"providers": {
|
|
4
|
+
"claude": { "cmd": "claude", "args": ["-p"] },
|
|
5
|
+
"gemini": { "cmd": "gemini", "args": ["-p"] },
|
|
6
|
+
"codex": { "cmd": "codex", "args": ["exec"] },
|
|
7
|
+
"grok": { "cmd": "grok", "args": [] }
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Registry routing — the front door's decision of WHICH agent answers a request.
|
|
2
|
+
// This scales router.js's two-domain keyword classifier to the whole registry, and
|
|
3
|
+
// keeps its core commitment: routing is deterministic and EXPLAINABLE. A request
|
|
4
|
+
// matches an agent by how many of that agent's declared routeHints appear in it;
|
|
5
|
+
// the reason is always the concrete hints that matched, so a customer (or the operator)
|
|
6
|
+
// can see why it went where it did. No model call to route.
|
|
7
|
+
//
|
|
8
|
+
// Two ways in:
|
|
9
|
+
// - Direct address: the caller names an agent -> that agent, reason "named".
|
|
10
|
+
// Works for ANY agent, including the evaluation seats.
|
|
11
|
+
// - Auto-dispatch: score the free-text request across all dispatchable agents,
|
|
12
|
+
// pick the highest; ties and no-match are surfaced, never silently guessed.
|
|
13
|
+
|
|
14
|
+
import { REGISTRY, agentByName, dispatchableAgents } from "./registry.js";
|
|
15
|
+
|
|
16
|
+
// A hint matches only as a whole word/phrase, bounded by non-alphanumerics — NOT a raw
|
|
17
|
+
// substring. A substring test routes "research" to a `search` hint and "cooperation" to
|
|
18
|
+
// `operation`, then prints those as the explanation; since routing is meant to be
|
|
19
|
+
// explainable, a confident-wrong reason is worse than no match. The [^a-z0-9] boundary
|
|
20
|
+
// still lets "check-in"/"follow-up" match across the hyphen.
|
|
21
|
+
// The `(?:s|es)?` allows the plain plural ("funder" matches "funders") without
|
|
22
|
+
// opening the door back to arbitrary substrings — "search" still can't match inside
|
|
23
|
+
// "research", because the leading boundary requires a non-alphanumeric before it.
|
|
24
|
+
const HINT_RE = new Map();
|
|
25
|
+
function hintRegex(hint) {
|
|
26
|
+
let re = HINT_RE.get(hint);
|
|
27
|
+
if (!re) {
|
|
28
|
+
const escaped = hint.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
re = new RegExp(`(^|[^a-z0-9])${escaped}(?:s|es)?([^a-z0-9]|$)`, "i");
|
|
30
|
+
HINT_RE.set(hint, re);
|
|
31
|
+
}
|
|
32
|
+
return re;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Some single-word hints are legitimate signals for their agent but carry almost no
|
|
36
|
+
// meaning on their own in ordinary English. On a lone hit they produced confident,
|
|
37
|
+
// explainable, WRONG routes — the worst failure a "say what you need in plain language"
|
|
38
|
+
// door can have, because it happens in front of someone:
|
|
39
|
+
// "opposite site" -> richy (matched: site)
|
|
40
|
+
// "grantham road listing" -> richy (matched: listing)
|
|
41
|
+
// "we need people ops" -> pio (matched: people)
|
|
42
|
+
// They still count, at a third of a point, so a request with real signal alongside them
|
|
43
|
+
// routes as before — but a lone weak hit can no longer clear the confidence floor.
|
|
44
|
+
const WEAK_HINTS = new Set([
|
|
45
|
+
"site", "online", "listing", "people", "post", "content", "brief", "topic", "entity",
|
|
46
|
+
"profile", "record", "page", "search", "report", "data", "review",
|
|
47
|
+
]);
|
|
48
|
+
|
|
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.
|
|
51
|
+
const CONFIDENCE_FLOOR = 1;
|
|
52
|
+
|
|
53
|
+
// Longer (multi-word) hints weigh more — "made for advertising" is a far stronger
|
|
54
|
+
// signal than a bare "seo" — so specific phrases beat generic single words.
|
|
55
|
+
function scoreAgent(agent, text) {
|
|
56
|
+
const matched = [];
|
|
57
|
+
const weak = [];
|
|
58
|
+
let score = 0;
|
|
59
|
+
for (const hint of agent.routeHints) {
|
|
60
|
+
if (hintRegex(hint).test(text)) {
|
|
61
|
+
if (hint.includes(" ")) { matched.push(hint); score += 3; }
|
|
62
|
+
else if (WEAK_HINTS.has(hint)) { weak.push(hint); score += 0.34; }
|
|
63
|
+
else { matched.push(hint); score += 1; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { score: Math.round(score * 100) / 100, matched, weak };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Route a request to one agent.
|
|
71
|
+
* @param request { text: string, agent?: string } // agent = explicit name (direct address)
|
|
72
|
+
* @returns { agent, reason, confident, matched, alternatives }
|
|
73
|
+
* agent the chosen registry entry, or null if nothing matched
|
|
74
|
+
* reason "named" | "matched: <hints>" | "no confident match"
|
|
75
|
+
* confident true unless nothing matched or the top two tied
|
|
76
|
+
* alternatives other agents that also scored (for the ambiguous case)
|
|
77
|
+
*/
|
|
78
|
+
export function route(request = {}) {
|
|
79
|
+
const text = String(request.text ?? "").toLowerCase();
|
|
80
|
+
|
|
81
|
+
// Direct address always wins — an explicit choice is never overridden by inference.
|
|
82
|
+
if (request.agent) {
|
|
83
|
+
const named = agentByName(request.agent);
|
|
84
|
+
if (!named) return { agent: null, reason: `unknown agent: ${request.agent}`, confident: false, matched: [], alternatives: [] };
|
|
85
|
+
return { agent: named, reason: "named", confident: true, matched: [], alternatives: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const scored = dispatchableAgents()
|
|
89
|
+
.map((agent) => ({ agent, ...scoreAgent(agent, text) }))
|
|
90
|
+
.filter((r) => r.score > 0)
|
|
91
|
+
.sort((a, b) => b.score - a.score);
|
|
92
|
+
|
|
93
|
+
if (scored.length === 0) {
|
|
94
|
+
return { agent: null, reason: "no confident match — name an agent or rephrase", confident: false, matched: [], alternatives: [] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const top = scored[0];
|
|
98
|
+
|
|
99
|
+
// Only weak signal in the whole request: say so and stop. Refusing to guess is the
|
|
100
|
+
// point — a wrong confident answer in front of a stranger costs more than a reprompt.
|
|
101
|
+
if (top.score < CONFIDENCE_FLOOR) {
|
|
102
|
+
const seen = [...new Set(scored.flatMap((r) => r.weak))].join(", ");
|
|
103
|
+
return {
|
|
104
|
+
agent: null,
|
|
105
|
+
reason: `only weak signal (${seen}) — name an agent or say more about what you need`,
|
|
106
|
+
confident: false,
|
|
107
|
+
matched: [],
|
|
108
|
+
alternatives: scored.slice(0, 3).map((r) => ({ name: r.agent.name, score: r.score, matched: [...r.matched, ...r.weak] })),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const tied = scored.filter((r) => r.score === top.score);
|
|
113
|
+
const confident = tied.length === 1;
|
|
114
|
+
return {
|
|
115
|
+
agent: top.agent,
|
|
116
|
+
reason: `matched: ${[...top.matched, ...top.weak].join(", ")}`,
|
|
117
|
+
confident,
|
|
118
|
+
matched: top.matched,
|
|
119
|
+
// When tied, surface the co-leaders so the ambiguity is visible, not hidden.
|
|
120
|
+
alternatives: (confident ? scored.slice(1, 4) : tied.slice(1)).map((r) => ({ name: r.agent.name, score: r.score, matched: r.matched })),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export { REGISTRY };
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Weftens — the real agent. A Claude-driven loop where the model converses with
|
|
2
|
+
// the operator and decides when to consult the two specialists, exposed as tools:
|
|
3
|
+
// representation_check (Richy) and operations_summary (PIO). The deterministic
|
|
4
|
+
// synthesizer stays available to the model as grounding, so numeric claims trace
|
|
5
|
+
// to signals rather than generation. The SDK's tool runner drives the loop.
|
|
6
|
+
|
|
7
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
8
|
+
import { betaTool } from "@anthropic-ai/sdk/helpers/beta/json-schema";
|
|
9
|
+
import { synthesize } from "./synthesize.js";
|
|
10
|
+
|
|
11
|
+
export const WEFTENS_MODEL = "claude-opus-4-8";
|
|
12
|
+
|
|
13
|
+
// Stable system prompt — kept frozen so prompt caching holds across turns.
|
|
14
|
+
export const WEFTENS_SYSTEM = `You are Weftens. You help an organization's operator understand and improve two things that form one loop: how the organization LOOKS online (its representation — what a funder, partner, or customer sees when they search it) and how it RUNS (its operations — programs, events, participants, follow-ups).
|
|
15
|
+
|
|
16
|
+
You have two specialists you can consult as tools:
|
|
17
|
+
- representation_check — Richy, a read-only audit of the org's public website and presence lanes. Call it when the question touches how the org appears online, its website, reviews, listings, search, or reputation.
|
|
18
|
+
- operations_summary — PIO, the org's operations workspace. Call it when the question touches events, participants, attendance, programs, grants, or follow-ups. It returns counts and status only — participant personal data never crosses this wire, and you must never ask for or speculate about individuals' personal details.
|
|
19
|
+
|
|
20
|
+
Working rules:
|
|
21
|
+
- Consult a specialist before making claims in its domain; never invent scores or counts. If a tool is unavailable or returns sample data, say so plainly — a sample is an illustration, never a finding about the real org.
|
|
22
|
+
- The valuable answer usually JOINS the two domains: attention generated (events, participants) versus conversion captured (representation quality, follow-ups). When you have both sides, say what the relationship implies and give one concrete next step.
|
|
23
|
+
- Speak in plain operator language. No internal jargon (tenant, scopeId, artifact, audit-envelope). Be direct and honest; if the data is thin, say what's missing rather than padding.
|
|
24
|
+
- Keep answers short: the read, the one insight, the one recommendation.
|
|
25
|
+
|
|
26
|
+
Voice — write like a sharp colleague, not a consultant deck:
|
|
27
|
+
- Plain paragraphs. No bold section headers, no bullet-listicle formatting, no "Here's the honest read" style framing lines.
|
|
28
|
+
- No coined slogans or aphorisms ("attention created, conversion leaking"). Say the plain thing instead.
|
|
29
|
+
- Do not end with an offer-question ("Want me to…?"). End on the recommendation. If a follow-up genuinely needs the operator's input, ask it as a real question mid-answer, once.`;
|
|
30
|
+
|
|
31
|
+
export function buildAgentTools(agents = {}) {
|
|
32
|
+
const tools = [];
|
|
33
|
+
|
|
34
|
+
if (agents.richy) {
|
|
35
|
+
tools.push(
|
|
36
|
+
betaTool({
|
|
37
|
+
name: "representation_check",
|
|
38
|
+
description:
|
|
39
|
+
"Run a read-only audit of how the organization appears online: website readiness score, top fixes, and which presence lanes (reviews, business profile, local listings, AI answers, social) are covered. Call this whenever the operator asks how the org looks, its website quality, reputation, search presence, or what a funder would see.",
|
|
40
|
+
inputSchema: {
|
|
41
|
+
type: "object",
|
|
42
|
+
properties: {
|
|
43
|
+
name: { type: "string", description: "Organization display name" },
|
|
44
|
+
website: { type: "string", description: "The org's public website URL, if known" },
|
|
45
|
+
},
|
|
46
|
+
required: ["name"],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
},
|
|
49
|
+
run: async (input) => JSON.stringify(await agents.richy.representationCheck(input)),
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (agents.pio) {
|
|
55
|
+
tools.push(
|
|
56
|
+
betaTool({
|
|
57
|
+
name: "operations_summary",
|
|
58
|
+
description:
|
|
59
|
+
"Fetch the organization's operations summary from its workspace: counts of programs, events, participants, attendance, and follow-ups (sanitized — no personal data). Call this whenever the operator asks about events, attendance, participants, programs, grants, or follow-up status.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: {
|
|
63
|
+
name: { type: "string", description: "Organization display name" },
|
|
64
|
+
},
|
|
65
|
+
required: ["name"],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
run: async (input) => JSON.stringify(await agents.pio.operationsSummary(input)),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
tools.push(
|
|
74
|
+
betaTool({
|
|
75
|
+
name: "join_domains",
|
|
76
|
+
description:
|
|
77
|
+
"Deterministically join a representation result and an operations result into a grounded cross-domain read (headline, insight, recommendation). Use it after you have BOTH a representation_check and an operations_summary for the same org, to ground your joined answer in the signals.",
|
|
78
|
+
inputSchema: {
|
|
79
|
+
type: "object",
|
|
80
|
+
properties: {
|
|
81
|
+
representation: { type: "object", description: "The representation_check result" },
|
|
82
|
+
operations: { type: "object", description: "The operations_summary result" },
|
|
83
|
+
},
|
|
84
|
+
required: ["representation", "operations"],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
run: async ({ representation, operations }) => JSON.stringify(synthesize({ representation, operations })),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return tools;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One agent turn (or a continued conversation): Claude decides which specialists
|
|
96
|
+
* to consult and answers in operator language. Returns { text, messages } so a
|
|
97
|
+
* caller can hold a multi-turn conversation by passing messages back in.
|
|
98
|
+
*/
|
|
99
|
+
export async function askWeftens({ question, history = [], agents = {}, client = null, maxTokens = 16000 }) {
|
|
100
|
+
const anthropic = client ?? new Anthropic();
|
|
101
|
+
const messages = [...history, { role: "user", content: question }];
|
|
102
|
+
|
|
103
|
+
const runner = anthropic.beta.messages.toolRunner({
|
|
104
|
+
model: WEFTENS_MODEL,
|
|
105
|
+
max_tokens: maxTokens,
|
|
106
|
+
thinking: { type: "adaptive" },
|
|
107
|
+
system: [{ type: "text", text: WEFTENS_SYSTEM, cache_control: { type: "ephemeral" } }],
|
|
108
|
+
tools: buildAgentTools(agents),
|
|
109
|
+
messages,
|
|
110
|
+
max_iterations: 8,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const finalMessage = await runner;
|
|
114
|
+
const text = finalMessage.content
|
|
115
|
+
.filter((block) => block.type === "text")
|
|
116
|
+
.map((block) => block.text)
|
|
117
|
+
.join("\n");
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
text,
|
|
121
|
+
messages: [...messages, { role: "assistant", content: finalMessage.content }],
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/invoke.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Invoke — run one routed agent and return its result. Three paths:
|
|
2
|
+
//
|
|
3
|
+
// core-in-the-loop : the agent has a deterministic agent-cores package; that code runs
|
|
4
|
+
// on the input and produces a checkable result. No model, no key.
|
|
5
|
+
// content-pipeline : the Content lead runs the content-org pipeline as one node.
|
|
6
|
+
// model-produce : a seat with no core produces via the model — its .md is the system
|
|
7
|
+
// prompt, run through whichever CLI the user chose (their own
|
|
8
|
+
// subscription, no API key). See provider.js.
|
|
9
|
+
//
|
|
10
|
+
// richy/pio (tier 1) are NOT invoked here — they answer through the existing orchestrator
|
|
11
|
+
// (weftens.js) over their frozen wire contracts. When a step can't run (no input for a
|
|
12
|
+
// core, provider CLI missing), invoke returns a plain note — it never fabricates output.
|
|
13
|
+
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { dirname, join, resolve } from "node:path";
|
|
19
|
+
import { complete } from "./provider.js";
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
// One home: the cores (agent-cores/) and content-org/ live inside this repo. No sibling
|
|
24
|
+
// checkout, no fallback — one canonical copy of everything. Override the base with WEFTENS_HOME.
|
|
25
|
+
const REPO = process.env.WEFTENS_HOME || resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
|
+
|
|
27
|
+
// Run a core by shelling its real CLI — the CLI already does the core's own
|
|
28
|
+
// parse+analyze+format, so we don't reimplement any core logic here. Every agent-cores
|
|
29
|
+
// package exposes src/cli.mjs and takes its input as a positional arg (a file path, or
|
|
30
|
+
// a URL for seo-technical); the one exception, paid-search, takes --search-terms <csv>,
|
|
31
|
+
// so an entry may set coreInputFlag to override the positional default.
|
|
32
|
+
async function runCoreCli(agent, input) {
|
|
33
|
+
const bin = join(REPO, ...agent.core.split("/"), ...(agent.coreBin ?? "src/cli.mjs").split("/"));
|
|
34
|
+
const args = agent.coreInputFlag ? [bin, agent.coreInputFlag, input, "--pretty"] : [bin, input, "--pretty"];
|
|
35
|
+
const { stdout } = await execFileP("node", args, { maxBuffer: 8_000_000 });
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(stdout);
|
|
38
|
+
} catch {
|
|
39
|
+
return stdout.trim();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A missing file, a non-zero core exit, or a core that isn't on disk should degrade
|
|
44
|
+
// to a plain note, not throw raw out of the front door — the rest of this stack
|
|
45
|
+
// (richy-client, weftens) fails soft, and the router must match that.
|
|
46
|
+
async function tryCore(agent, inputFile) {
|
|
47
|
+
try {
|
|
48
|
+
return { ok: true, result: await runCoreCli(agent, inputFile) };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const reason = error?.code === "ENOENT" ? "core CLI not found on disk" : (error?.stderr?.trim() || error?.message || "core failed");
|
|
51
|
+
return { ok: false, reason };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param agent registry entry (from route().agent)
|
|
57
|
+
* @param opts { text?: string, inputFile?: string }
|
|
58
|
+
* @returns { agent, path, result?, note }
|
|
59
|
+
*/
|
|
60
|
+
export async function invoke(agent, opts = {}) {
|
|
61
|
+
if (!agent) return { agent: null, path: "none", note: "no agent to invoke" };
|
|
62
|
+
|
|
63
|
+
if (agent.tier === 1 && agent.wire) {
|
|
64
|
+
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`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Content lead: owns the 10-seat pipeline. Delegate to content-org/lead.js. The
|
|
71
|
+
// pipeline runs by FORMAT (scam-reveal, …) + count, chosen with --format/--count —
|
|
72
|
+
// it does not yet take a free-text brief, so a request's prose is not used to steer
|
|
73
|
+
// it. Say that plainly rather than implying the request drove the run. Defaults to
|
|
74
|
+
// dry-run: a real run is gated on the owner's open content decisions.
|
|
75
|
+
if (agent.tier === 1 && agent.leads === "content-org") {
|
|
76
|
+
const leadPath = join(REPO, "content-org", "lead.js");
|
|
77
|
+
if (!existsSync(leadPath)) {
|
|
78
|
+
return { agent: agent.name, path: "content-pipeline", note: "content pipeline not installed — clone content-org beside this repo to enable it" };
|
|
79
|
+
}
|
|
80
|
+
const mod = await import(pathToFileURL(leadPath).href);
|
|
81
|
+
const dry = opts.dry !== false; // real run only when explicitly asked
|
|
82
|
+
const out = await mod.runContent({ dry });
|
|
83
|
+
return {
|
|
84
|
+
agent: agent.name, path: "content-pipeline", result: out.result,
|
|
85
|
+
note: `ran content-org pipeline (${out.format} x${out.count}, ${dry ? "dry-run — no model, Phase-0 gated" : "live"}) — ${out.result.length} packet(s)`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (agent.core) {
|
|
90
|
+
// seo-technical takes a URL and live-fetches; the rest take a file path. Either
|
|
91
|
+
// arrives as opts.input. Without it, there's nothing to run — say so.
|
|
92
|
+
const input = opts.input ?? opts.inputFile;
|
|
93
|
+
if (!input) {
|
|
94
|
+
const what = agent.name === "seo-technical" ? "<url>" : "<file>";
|
|
95
|
+
return {
|
|
96
|
+
agent: agent.name, path: "core",
|
|
97
|
+
note: `deterministic core available (${agent.core}) — run with --input ${what} to execute it`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const run = await tryCore(agent, input);
|
|
101
|
+
if (!run.ok) {
|
|
102
|
+
return { agent: agent.name, path: "core", note: `core ${agent.core} could not run on ${input}: ${run.reason}` };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
agent: agent.name, path: "core", result: run.result,
|
|
106
|
+
note: `ran ${agent.core} on ${input} — deterministic, no model`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// model-produce path — bring-your-own-model. The seat .md is the system prompt; the
|
|
111
|
+
// request is the user turn. Run it through whichever CLI the user chose (their own
|
|
112
|
+
// subscription, no API key). Degrades to a plain note if the seat can't be read or the
|
|
113
|
+
// provider CLI isn't there — never a fabricated answer.
|
|
114
|
+
let seatText;
|
|
115
|
+
try {
|
|
116
|
+
seatText = readFileSync(agent.seatFile, "utf8");
|
|
117
|
+
} catch {
|
|
118
|
+
return { agent: agent.name, path: "model", note: `seat file not found (${agent.seatFile}); can't produce` };
|
|
119
|
+
}
|
|
120
|
+
const prompt = `${seatText}\n\n---\nRequest: ${opts.text ?? ""}`;
|
|
121
|
+
const res = await complete(prompt, { provider: opts.provider });
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
return { agent: agent.name, path: "model", note: `couldn't produce via ${res.provider}: ${res.reason}` };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
agent: agent.name, path: "model", result: res.text,
|
|
127
|
+
note: `produced via ${res.provider} — the user's own subscription, no API key`,
|
|
128
|
+
};
|
|
129
|
+
}
|