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,142 @@
|
|
|
1
|
+
// PIO leg — wired for real. PIO's data plane is tenant-scoped behind a member
|
|
2
|
+
// session (grantengine_session cookie). Weftens authenticates as a member, reads
|
|
3
|
+
// PIO's sanitized /dashboard (which is PII-free by construction — counts and
|
|
4
|
+
// status only, never participant names/contacts), and returns an operations
|
|
5
|
+
// summary for the org. The summary is scoped to the PIO profile whose display name
|
|
6
|
+
// matches the org; if none matches, it reports "not found" plus the names that do
|
|
7
|
+
// exist — never another organization's counts under the requested name.
|
|
8
|
+
//
|
|
9
|
+
// Credentials belong to a real PIO member the operator provisions for Weftens —
|
|
10
|
+
// this reads on their behalf, it does not bootstrap tenants or hold the operator
|
|
11
|
+
// token. Local PIO needs no tenant; hosted PIO needs the tenant slug.
|
|
12
|
+
|
|
13
|
+
import { requireSafeBaseUrl } from "./richy-client.js";
|
|
14
|
+
|
|
15
|
+
export class PioError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "PioError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SESSION_COOKIE = "grantengine_session";
|
|
23
|
+
|
|
24
|
+
export class PioClient {
|
|
25
|
+
constructor({ baseUrl, member, password, tenant = null, fetchImpl = fetch } = {}) {
|
|
26
|
+
if (!baseUrl || !member || !password) throw new PioError("pio_not_configured");
|
|
27
|
+
// TLS required (loopback http allowed for local/test) — plain http to any other
|
|
28
|
+
// host would expose the member password and session cookie on the wire.
|
|
29
|
+
this.baseUrl = requireSafeBaseUrl(baseUrl, PioError);
|
|
30
|
+
this.member = member;
|
|
31
|
+
this.password = password;
|
|
32
|
+
this.tenant = tenant;
|
|
33
|
+
this.fetchImpl = fetchImpl;
|
|
34
|
+
this.cookie = null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A network-level failure (server down, DNS, reset) must surface as a PioError,
|
|
38
|
+
// not a raw TypeError — callers branch on PioError to render "PIO unavailable".
|
|
39
|
+
async _fetch(url, options) {
|
|
40
|
+
try {
|
|
41
|
+
return await this.fetchImpl(url, options);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new PioError("pio_unreachable");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async _login() {
|
|
48
|
+
const body = { member: this.member, password: this.password };
|
|
49
|
+
if (this.tenant) body.tenant = this.tenant;
|
|
50
|
+
const res = await this._fetch(`${this.baseUrl}/session`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "content-type": "application/json" },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
redirect: "manual", // never follow a 3xx — it could carry creds to an attacker host
|
|
55
|
+
});
|
|
56
|
+
if (res.status >= 300 && res.status < 400) throw new PioError("pio_unexpected_redirect");
|
|
57
|
+
if (!res.ok) throw new PioError(res.status === 401 ? "pio_auth_failed" : "pio_unavailable");
|
|
58
|
+
const setCookie = res.headers.get("set-cookie") ?? "";
|
|
59
|
+
const match = setCookie.match(new RegExp(`${SESSION_COOKIE}=([^;]+)`));
|
|
60
|
+
if (!match) throw new PioError("pio_no_session");
|
|
61
|
+
this.cookie = `${SESSION_COOKIE}=${match[1]}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async _get(path) {
|
|
65
|
+
if (!this.cookie) await this._login();
|
|
66
|
+
const headers = { cookie: this.cookie };
|
|
67
|
+
if (this.tenant) headers["x-grantengine-tenant"] = this.tenant;
|
|
68
|
+
const res = await this._fetch(`${this.baseUrl}${path}`, { headers, redirect: "manual" });
|
|
69
|
+
if (res.status === 401) {
|
|
70
|
+
// Session expired mid-run: re-auth once, then fail if it still won't take.
|
|
71
|
+
this.cookie = null;
|
|
72
|
+
await this._login();
|
|
73
|
+
const retry = await this._fetch(`${this.baseUrl}${path}`, {
|
|
74
|
+
headers: { cookie: this.cookie, ...(this.tenant ? { "x-grantengine-tenant": this.tenant } : {}) },
|
|
75
|
+
redirect: "manual",
|
|
76
|
+
});
|
|
77
|
+
if (retry.status >= 300 && retry.status < 400) throw new PioError("pio_unexpected_redirect");
|
|
78
|
+
if (!retry.ok) throw new PioError("pio_unavailable");
|
|
79
|
+
return retry.json();
|
|
80
|
+
}
|
|
81
|
+
if (res.status >= 300 && res.status < 400) throw new PioError("pio_unexpected_redirect");
|
|
82
|
+
if (!res.ok) throw new PioError("pio_unavailable");
|
|
83
|
+
return res.json();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async operationsSummary({ name }) {
|
|
87
|
+
const dash = await this._get("/dashboard");
|
|
88
|
+
const profiles = Array.isArray(dash.profiles) ? dash.profiles : [];
|
|
89
|
+
const profile = profiles.find(
|
|
90
|
+
(p) => String(p.displayName ?? "").toLowerCase() === String(name).toLowerCase(),
|
|
91
|
+
);
|
|
92
|
+
// No profile by that name → say so, with the names that DO exist (org display
|
|
93
|
+
// names are org-level, not participant PII). Returning workspace-wide counts
|
|
94
|
+
// here attributed them to an org PIO has never heard of — "you're running 2
|
|
95
|
+
// events" built from other organizations' numbers. Misattribution, not data.
|
|
96
|
+
if (!profile) {
|
|
97
|
+
return {
|
|
98
|
+
org: name,
|
|
99
|
+
matchedProfile: null,
|
|
100
|
+
available: false,
|
|
101
|
+
reason: `no organization named "${name}" in this PIO workspace`,
|
|
102
|
+
knownOrganizations: profiles.map((p) => String(p.displayName ?? "")).filter(Boolean),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const pid = profile.profileId;
|
|
106
|
+
const countFor = (arr) => (Array.isArray(arr) ? arr : []).filter((x) => x.profileId === pid).length;
|
|
107
|
+
return {
|
|
108
|
+
org: name,
|
|
109
|
+
matchedProfile: profile.displayName,
|
|
110
|
+
scope: "this organization",
|
|
111
|
+
programs: countFor(dash.programs),
|
|
112
|
+
events: countFor(dash.programEvents),
|
|
113
|
+
participants: countFor(dash.participants),
|
|
114
|
+
attendance: countFor(dash.attendanceRecords),
|
|
115
|
+
followUps: countFor(dash.followUps),
|
|
116
|
+
representationChecks: countFor(dash.representationChecks),
|
|
117
|
+
available: true,
|
|
118
|
+
source: "PIO /dashboard (sanitized — counts and status only, no participant PII)",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Deterministic, no-network. Realistic shape so tests exercise the merge, clearly
|
|
124
|
+
// flagged sample:true so a Weftens result is never mistaken for real PIO data.
|
|
125
|
+
export class FixturePioClient {
|
|
126
|
+
async operationsSummary({ name }) {
|
|
127
|
+
return {
|
|
128
|
+
org: name,
|
|
129
|
+
matchedProfile: name,
|
|
130
|
+
scope: "this organization",
|
|
131
|
+
programs: 2,
|
|
132
|
+
events: 3,
|
|
133
|
+
participants: 24,
|
|
134
|
+
attendance: 41,
|
|
135
|
+
followUps: 5,
|
|
136
|
+
representationChecks: 1,
|
|
137
|
+
available: true,
|
|
138
|
+
source: "sample",
|
|
139
|
+
sample: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Bring-your-own-model. A model-backed agent produces by shelling a CLI the user
|
|
2
|
+
// already has and is logged into — their own subscription — so no API key is needed
|
|
3
|
+
// and nobody is billed through Weftens. The user chooses which one.
|
|
4
|
+
//
|
|
5
|
+
// Providers are CONFIG, not code: a name -> { cmd, args } map. Adding or fixing one
|
|
6
|
+
// (grok, gemini, a different flag) is a line, never a new adapter. Defaults cover the
|
|
7
|
+
// known CLIs; override or extend them in a providers.json beside the repo, or point at
|
|
8
|
+
// one with WEFTENS_PROVIDERS_FILE. Pick the active provider with WEFTENS_PROVIDER
|
|
9
|
+
// (default: claude).
|
|
10
|
+
//
|
|
11
|
+
// The prompt is piped to the CLI's stdin (not passed as an argument): it avoids all
|
|
12
|
+
// shell-quoting/injection problems with long prompts, and it lets us run through a shell
|
|
13
|
+
// so a Windows npm shim (claude.cmd / .ps1) resolves — plain execFile can't call those.
|
|
14
|
+
//
|
|
15
|
+
// `claude` and `codex` are both VERIFIED end-to-end on this machine — a real prompt in, a
|
|
16
|
+
// real answer out, through the user's own subscription. `gemini` and `grok` remain honest
|
|
17
|
+
// defaults: correct in shape, unproven because those CLIs are not installed here. invoke.js
|
|
18
|
+
// degrades to a plain note if a provider's CLI is missing or errors; it never fabricates a
|
|
19
|
+
// model answer, so an unverified provider fails visibly rather than silently.
|
|
20
|
+
|
|
21
|
+
import { exec } from "node:child_process";
|
|
22
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
|
|
26
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_PROVIDERS = {
|
|
29
|
+
claude: { cmd: "claude", args: ["-p"] }, // Claude Code — Pro/Max sub. VERIFIED end-to-end.
|
|
30
|
+
codex: { cmd: "codex", args: ["exec"] }, // OpenAI Codex CLI — ChatGPT sub. VERIFIED 2026-07-19.
|
|
31
|
+
gemini: { cmd: "gemini", args: ["-p"] }, // Google Gemini CLI. Unverified — CLI not present here.
|
|
32
|
+
grok: { cmd: "grok", args: [] }, // xAI grok CLI. Unverified — CLI not present here.
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Merge defaults with a user providers.json ({ providers: { name: {cmd,args} } }).
|
|
36
|
+
export function loadProviders() {
|
|
37
|
+
const file = process.env.WEFTENS_PROVIDERS_FILE || join(HERE, "..", "providers.json");
|
|
38
|
+
if (!existsSync(file)) return { ...DEFAULT_PROVIDERS };
|
|
39
|
+
try {
|
|
40
|
+
const user = JSON.parse(readFileSync(file, "utf8"))?.providers ?? {};
|
|
41
|
+
return { ...DEFAULT_PROVIDERS, ...user };
|
|
42
|
+
} catch {
|
|
43
|
+
return { ...DEFAULT_PROVIDERS };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function activeProviderName() {
|
|
48
|
+
return process.env.WEFTENS_PROVIDER || "claude";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run the prompt through a provider's CLI and return its text.
|
|
53
|
+
* @param prompt the full prompt (seat instructions + request)
|
|
54
|
+
* @param opts { provider?, providers?, timeoutMs? }
|
|
55
|
+
* @returns Promise<{ ok, text?, provider, reason? }> — never throws; ok:false carries the reason
|
|
56
|
+
*/
|
|
57
|
+
export function complete(prompt, opts = {}) {
|
|
58
|
+
const providers = opts.providers || loadProviders();
|
|
59
|
+
const name = opts.provider || activeProviderName();
|
|
60
|
+
const cfg = providers[name];
|
|
61
|
+
if (!cfg) return Promise.resolve({ ok: false, provider: name, reason: `unknown provider "${name}"` });
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
// exec runs the command through a shell, so an npm shim (e.g. claude.cmd on Windows)
|
|
65
|
+
// resolves. cmd + args come from config we control (flags only), never user text; the
|
|
66
|
+
// prompt goes via stdin, so there is no injection surface from the request.
|
|
67
|
+
const command = [cfg.cmd, ...cfg.args].join(" ");
|
|
68
|
+
const child = exec(command, { maxBuffer: 16_000_000, timeout: opts.timeoutMs ?? 180_000 },
|
|
69
|
+
(err, stdout, stderr) => {
|
|
70
|
+
if (err) {
|
|
71
|
+
const missing = err.code === "ENOENT" || /not recognized|not found/i.test(stderr || "");
|
|
72
|
+
const reason = missing
|
|
73
|
+
? `the "${cfg.cmd}" CLI isn't installed / on PATH — install it and log in, or pick another provider`
|
|
74
|
+
: (String(stderr).trim().split("\n")[0] || err.message);
|
|
75
|
+
return resolve({ ok: false, provider: name, reason });
|
|
76
|
+
}
|
|
77
|
+
resolve({ ok: true, provider: name, text: String(stdout).trim() });
|
|
78
|
+
});
|
|
79
|
+
child.stdin.end(prompt);
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,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
|
+
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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Richy leg. Calls Richy's read-only audit service (POST /audit + POST /represent,
|
|
2
|
+
// gated by x-richy-key) and NORMALIZES the responses into the operator-relevant
|
|
3
|
+
// shape the synthesizer needs — an overall score, the top fixes, and which presence
|
|
4
|
+
// lanes are covered. It does not return raw audit JSON (that was the old lossy
|
|
5
|
+
// mistake); it extracts the few fields that mean something to an operator, with a
|
|
6
|
+
// request timeout and a response byte cap so a slow or oversized service can't hang
|
|
7
|
+
// or balloon the caller. Fail-soft: one endpoint down still yields a usable read.
|
|
8
|
+
|
|
9
|
+
export class RichyError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "RichyError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_RESPONSE_BYTES = 2_000_000;
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
18
|
+
|
|
19
|
+
// Read the body with a running byte counter and stop the moment it exceeds the cap,
|
|
20
|
+
// so an oversized (or malicious) response is aborted mid-stream instead of being
|
|
21
|
+
// fully buffered into memory before the length is checked. Falls back to a capped
|
|
22
|
+
// text() read only if the response exposes no stream reader.
|
|
23
|
+
async function readCapped(res, maxBytes) {
|
|
24
|
+
const reader = res.body?.getReader?.();
|
|
25
|
+
if (!reader) {
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
if (text.length > maxBytes) throw new RichyError("richy_response_too_large");
|
|
28
|
+
return text;
|
|
29
|
+
}
|
|
30
|
+
const decoder = new TextDecoder();
|
|
31
|
+
let total = 0;
|
|
32
|
+
let out = "";
|
|
33
|
+
for (;;) {
|
|
34
|
+
const { done, value } = await reader.read();
|
|
35
|
+
if (done) break;
|
|
36
|
+
total += value.byteLength;
|
|
37
|
+
if (total > maxBytes) {
|
|
38
|
+
await reader.cancel();
|
|
39
|
+
throw new RichyError("richy_response_too_large");
|
|
40
|
+
}
|
|
41
|
+
out += decoder.decode(value, { stream: true });
|
|
42
|
+
}
|
|
43
|
+
return out + decoder.decode();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clampScore(value) {
|
|
47
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
48
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The live service's /audit answers in the public-GET shape (kind:
|
|
52
|
+
// "richy_public_get_audit"), which carries evidence but NO score on the wire —
|
|
53
|
+
// pathScore only exists in Richy's DPA-packet builder, which the HTTP service
|
|
54
|
+
// never uses. Without this scorer, overallScore is always null against the real
|
|
55
|
+
// service and the cross-domain join (the reason Weftens exists) can never fire.
|
|
56
|
+
// The rubric below is a deliberate copy of PIO's mapPublicGetAudit (verified
|
|
57
|
+
// live 2026-07-08) so both consumers report the SAME number for the same audit —
|
|
58
|
+
// deterministic from documented evidence fields, never invented.
|
|
59
|
+
function scorePublicGetAudit(audit) {
|
|
60
|
+
const observed = audit.observed_evidence && typeof audit.observed_evidence === "object" ? audit.observed_evidence : {};
|
|
61
|
+
const crawl = observed.crawl && typeof observed.crawl === "object" ? observed.crawl : {};
|
|
62
|
+
const fetched = (Array.isArray(observed.fetched) ? observed.fetched : []).filter((x) => x && typeof x === "object");
|
|
63
|
+
const pagesFetched = typeof crawl.pages_fetched === "number" ? crawl.pages_fetched : 0;
|
|
64
|
+
// Nothing loaded → we scored nothing. A derived number here would fabricate a
|
|
65
|
+
// finding about a site we never reached; null is the honest answer.
|
|
66
|
+
if (pagesFetched === 0) return null;
|
|
67
|
+
|
|
68
|
+
const home = fetched.find((x) => x.kind === "page" && x.url === crawl.start_url) ?? fetched.find((x) => x.kind === "page") ?? {};
|
|
69
|
+
const metadata = home.metadata && typeof home.metadata === "object" ? home.metadata : {};
|
|
70
|
+
const jsonLd = metadata.json_ld && typeof metadata.json_ld === "object" ? metadata.json_ld : {};
|
|
71
|
+
const resourceOk = (kind) => fetched.some((x) => x.kind === kind && x.status_code === 200);
|
|
72
|
+
const hasText = (v) => typeof v === "string" && v.trim().length > 0;
|
|
73
|
+
|
|
74
|
+
const discovery =
|
|
75
|
+
40 + (resourceOk("robots") ? 15 : 0) + (resourceOk("sitemap") ? 15 : 0) +
|
|
76
|
+
(hasText(metadata.title) ? 15 : 0) + (hasText(metadata.meta_description) ? 15 : 0);
|
|
77
|
+
|
|
78
|
+
let penalty = 0;
|
|
79
|
+
for (const finding of Array.isArray(audit.findings) ? audit.findings : []) {
|
|
80
|
+
const severity = finding && typeof finding.severity === "string" ? finding.severity : "";
|
|
81
|
+
if (severity === "high" || severity === "blocked") penalty += 25;
|
|
82
|
+
else if (severity === "medium") penalty += 10;
|
|
83
|
+
else if (severity === "low") penalty += 4;
|
|
84
|
+
}
|
|
85
|
+
const trust = Math.round(Math.max(0, 100 - penalty) * 0.7) + (jsonLd.present === true ? 15 : 0) + (hasText(metadata.canonical) ? 15 : 0);
|
|
86
|
+
|
|
87
|
+
const action =
|
|
88
|
+
((Array.isArray(home.cta_anchors) ? home.cta_anchors.length : 0) > 0 ? 60 : 0) +
|
|
89
|
+
((Array.isArray(home.forms) ? home.forms.length : 0) > 0 ? 25 : 0) +
|
|
90
|
+
((typeof metadata.h1_count === "number" ? metadata.h1_count : 0) >= 1 ? 15 : 0);
|
|
91
|
+
|
|
92
|
+
const stages = [discovery, trust, action].map((s) => clampScore(s) ?? 0);
|
|
93
|
+
return clampScore(stages.reduce((sum, s) => sum + s, 0) / stages.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Richy's /audit answers in one of two shapes. Pull the overall score and up to
|
|
97
|
+
// three fixes from whichever one came back; never assume a shape.
|
|
98
|
+
export function normalizeAudit(body) {
|
|
99
|
+
const root = body && typeof body === "object" ? body : {};
|
|
100
|
+
const audit = "audit" in root ? root.audit ?? {} : root;
|
|
101
|
+
const pathScore = audit.pathScore ?? {};
|
|
102
|
+
const overall =
|
|
103
|
+
audit.kind === "richy_public_get_audit" ? scorePublicGetAudit(audit) : clampScore(pathScore.overall);
|
|
104
|
+
const rawFixes = Array.isArray(audit.topFixes) ? audit.topFixes : Array.isArray(audit.fix_queue) ? audit.fix_queue : [];
|
|
105
|
+
const topFixes = rawFixes
|
|
106
|
+
.filter((f) => f && typeof f.title === "string" && f.title.trim())
|
|
107
|
+
.slice(0, 3)
|
|
108
|
+
.map((f) => ({ title: String(f.title).slice(0, 200), why: String(f.why ?? "").slice(0, 400) }));
|
|
109
|
+
return { overallScore: overall, topFixes };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function normalizePresence(body) {
|
|
113
|
+
const report = body && typeof body === "object" ? (body.report ?? body) : {};
|
|
114
|
+
const rawLanes = report.lanes && typeof report.lanes === "object" ? report.lanes : {};
|
|
115
|
+
const map = { reviews: "reviews", gbp: "businessProfile", listings: "localListings", aiVisibility: "aiAnswers", social: "social" };
|
|
116
|
+
const lanes = {};
|
|
117
|
+
for (const [upstream, key] of Object.entries(map)) {
|
|
118
|
+
lanes[key] = rawLanes[upstream]?.configured === true;
|
|
119
|
+
}
|
|
120
|
+
return lanes;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// A configured service endpoint must be reachable over TLS. The only exception is
|
|
124
|
+
// a loopback http URL (127.0.0.1 / ::1 / localhost) for local dev and tests — over
|
|
125
|
+
// any other host, plain http would expose the API key / credentials to the network.
|
|
126
|
+
// Rejecting a bad base URL at construction stops a misconfigured or attacker-supplied
|
|
127
|
+
// endpoint from ever receiving a secret. Reused by both Weftens service clients.
|
|
128
|
+
export function requireSafeBaseUrl(rawUrl, ErrorClass) {
|
|
129
|
+
let url;
|
|
130
|
+
try {
|
|
131
|
+
url = new URL(rawUrl);
|
|
132
|
+
} catch {
|
|
133
|
+
throw new ErrorClass("bad_base_url");
|
|
134
|
+
}
|
|
135
|
+
const host = url.hostname.replace(/^\[/, "").replace(/\]$/, "").toLowerCase();
|
|
136
|
+
const loopback = host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
137
|
+
if (url.protocol === "https:") return url.toString().replace(/\/$/, "");
|
|
138
|
+
if (url.protocol === "http:" && loopback) return url.toString().replace(/\/$/, "");
|
|
139
|
+
throw new ErrorClass("insecure_base_url");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class RichyClient {
|
|
143
|
+
constructor({ baseUrl, key, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
144
|
+
if (!baseUrl || !key) throw new RichyError("richy_not_configured");
|
|
145
|
+
this.baseUrl = requireSafeBaseUrl(baseUrl, RichyError);
|
|
146
|
+
this.key = key;
|
|
147
|
+
this.fetchImpl = fetchImpl;
|
|
148
|
+
this.timeoutMs = timeoutMs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async _post(path, body) {
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
154
|
+
try {
|
|
155
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "content-type": "application/json", "x-richy-key": this.key },
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
// Never follow a redirect: a compromised or MITM'd endpoint could 302 us
|
|
161
|
+
// into internal network space (SSRF) and the redirected request would carry
|
|
162
|
+
// x-richy-key to the attacker's host. A 3xx from the service is an error.
|
|
163
|
+
redirect: "manual",
|
|
164
|
+
});
|
|
165
|
+
if (res.status >= 300 && res.status < 400) throw new RichyError("richy_unexpected_redirect");
|
|
166
|
+
if (!res.ok) throw new RichyError(res.status >= 400 && res.status < 500 ? "richy_rejected" : "richy_unavailable");
|
|
167
|
+
return JSON.parse(await readCapped(res, MAX_RESPONSE_BYTES));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error instanceof RichyError) throw error;
|
|
170
|
+
throw new RichyError(error?.name === "AbortError" ? "richy_timeout" : "richy_unavailable");
|
|
171
|
+
} finally {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async representationCheck({ name, website, handles = [], queries = [] }) {
|
|
177
|
+
const [audit, presence] = await Promise.allSettled([
|
|
178
|
+
website ? this._post("/audit", { url: website, name }) : Promise.reject(new RichyError("no_website")),
|
|
179
|
+
this._post("/represent", { business: { name, handles }, queries }),
|
|
180
|
+
]);
|
|
181
|
+
const siteOk = audit.status === "fulfilled";
|
|
182
|
+
const presenceOk = presence.status === "fulfilled";
|
|
183
|
+
if (!siteOk && !presenceOk) throw new RichyError("richy_unavailable");
|
|
184
|
+
const mapped = siteOk ? normalizeAudit(audit.value) : { overallScore: null, topFixes: [] };
|
|
185
|
+
return {
|
|
186
|
+
org: name,
|
|
187
|
+
website: website ?? null,
|
|
188
|
+
overallScore: mapped.overallScore,
|
|
189
|
+
topFixes: mapped.topFixes,
|
|
190
|
+
lanes: presenceOk ? normalizePresence(presence.value) : null,
|
|
191
|
+
siteCheckAvailable: siteOk,
|
|
192
|
+
presenceAvailable: presenceOk,
|
|
193
|
+
sample: false,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Deterministic, no-network. Realistic normalized shape, sample:true so a Weftens
|
|
199
|
+
// answer is never mistaken for a real Richy audit.
|
|
200
|
+
export class FixtureRichyClient {
|
|
201
|
+
async representationCheck({ name, website = null }) {
|
|
202
|
+
return {
|
|
203
|
+
org: name,
|
|
204
|
+
website,
|
|
205
|
+
overallScore: 61,
|
|
206
|
+
topFixes: [
|
|
207
|
+
{ title: "Put a clear next step on the homepage", why: "Visitors who can't see how to act usually leave." },
|
|
208
|
+
{ title: "Add an outcomes summary with real numbers", why: "Concrete results are what make you the safe choice." },
|
|
209
|
+
],
|
|
210
|
+
lanes: { reviews: true, businessProfile: false, localListings: false, aiAnswers: true, social: false },
|
|
211
|
+
siteCheckAvailable: true,
|
|
212
|
+
presenceAvailable: true,
|
|
213
|
+
sample: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Intent routing. Weftens' whole job is to decide which named agent answers a
|
|
2
|
+
// request and hand it over — Richy for how an org is represented, PIO for how an
|
|
3
|
+
// org operates. This is deliberately a keyword classifier, not a model call: the
|
|
4
|
+
// routing must be explainable and deterministic (a customer should be able to see
|
|
5
|
+
// *why* their question went to one agent over the other), and an explicit intent
|
|
6
|
+
// on the request always wins over inference.
|
|
7
|
+
|
|
8
|
+
const REPRESENTATION = [
|
|
9
|
+
"represent", "representation", "reputation", "online", "website", "site",
|
|
10
|
+
"review", "listing", "search", "seo", "visibility", "audit", "appear",
|
|
11
|
+
"how do we look", "what do people see", "brand",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const OPERATIONS = [
|
|
15
|
+
"event", "attendee", "participant", "check-in", "checkin", "roster",
|
|
16
|
+
"grant", "funding", "program", "people", "follow-up", "followup", "donor",
|
|
17
|
+
"attendance", "registration", "operation",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Returns { intent, confident }. `confident` is false when no keyword matched — in
|
|
21
|
+
// that case we route to BOTH rather than silently guessing one domain (a silent
|
|
22
|
+
// wrong-guess gives an operator a confident answer from the wrong specialist). The
|
|
23
|
+
// caller surfaces the low-confidence routing so the ambiguity is visible, not hidden.
|
|
24
|
+
export function classifyIntent(text = "") {
|
|
25
|
+
const t = String(text).toLowerCase();
|
|
26
|
+
const wantsRepresentation = REPRESENTATION.some((k) => t.includes(k));
|
|
27
|
+
const wantsOperations = OPERATIONS.some((k) => t.includes(k));
|
|
28
|
+
if (wantsRepresentation && wantsOperations) return { intent: "both", confident: true };
|
|
29
|
+
if (wantsOperations) return { intent: "operations", confident: true };
|
|
30
|
+
if (wantsRepresentation) return { intent: "representation", confident: true };
|
|
31
|
+
return { intent: "both", confident: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const INTENTS = ["representation", "operations", "both"];
|