agent-led-growth 0.8.3 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-led-growth",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Score: evals for agents as customers. Runs a real agent through signup, key and first call on your product, before you ship. Track, the analytics half, measures which agents convert after.",
5
5
  "author": {
6
6
  "name": "Kat Laszlo",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.5 (2026-10-25)
4
+
5
+ Quotes that say what they are for. 0.8.4's quotes were whole sentences or labelled elements, but some pointed at the wrong thing.
6
+
7
+ - Prices: a price that is not a sentence names the heading it sits under, usually its plan: `Price under "Pro": "$14 /month"` (aisend.app), `Price under "Developer": "$30 / month"` (inkbox.ai). The quote itself stays the page's own words. A price under Add-ons, a comparison, credits or a discount is quoted only when the page has no other: Resend's Pay quoted "Domains - $20 / mo", an add-on, and now quotes `Table cell under "Inboxes": "$1 / inbox"`. Resend's plan cards load after the page, so they are still not read.
8
+ - Weak quotes: a heading or list item of three words or fewer, a page title ("x402 | AgentMail | Documentation"), a question and a piece cut from inside link text are used only when the page has no sentence for the same rule. A docs-index line with a description still counts: Locus's "[x402 reference](...): Direct HTTP 402 payments via Base USDC." says what the page covers.
9
+ - Pay per request: a comparison ("Compare Locus Pro, RapidAPI, x402 Bazaar, ...") names other products and is not evidence that this one takes x402.
10
+ - Text: numeric HTML entities are decoded, so x402.org's quote reads "the Internet’s original sin", not "the Internet’s original sin".
11
+ - No recorded product changed state; only quotes changed.
12
+
13
+ ## 0.8.4 (2026-10-24)
14
+
15
+ Quotes you can check. 0.8.3 printed each requirement with the text it came from, but the text was cut from the page as flat words, so on resend.com Pay quoted "scale as you grow. Are you an AI agent? See pricing Add-ons if you send from a": a subtitle, a link and a heading run together.
16
+
17
+ - Rule: a quote is one or two whole sentences from one element of the page (a paragraph, a list item, a table cell), cut only at sentence ends, and it holds the words that triggered the rule. When those words sit in an element that is not a sentence, the quote is that element with a label: `Heading: "Domains - $20 / mo" (/pricing)`, `Table cell: "/v1/x402/search"`, `List item: "Identity provider: AgentID OpenID Connect"`, `OpenAPI path: "/v1/x402/numbers/buy"`, `pricing.json: "Pro: 20 USD per month"`. Words that run across two elements are never a quote, and the rule tries its next match instead. Quotes are printed whole; the 160-character cut with "..." is gone.
18
+ - The rules read the same sentence they quote, so a guard never decides on words from a neighbouring element.
19
+ - Pay quotes a paid price, from the pricing page before the homepage: firecrawl.dev's quote was its "$75M Series B" announcement and is now "Standard: $83/month" from /pricing. A price that can only be read out of a script's data and never appears in a sentence or table no longer makes Pay "needs a person"; it says the prices could not be quoted and a test decides. No recorded product changed state from this.
20
+ - Inbox: an emailed code or link counts only when the sentence is about the product's own signup: it names the product's signup or verify call, names the product ("Sign up for Acme", "Acme emails"), says "we send" or "our signup", sits on the product's agent-signup or auth page, or sits in a signup section of a page (a "Sign up" or "Quick start" heading, or right after the product's signup call). A narrative about agents in general ("An agent signs up with its owner's email, reads the verification code out of the owner's inbox") does not count, except on the product's own signup page. www.agentid.com no longer needs an inbox; telnyx.com, inkbox.ai, agentmail.to, aisend.app, www.cosmicjs.com and agentline.cloud still do, each quoting its own signup sentence.
21
+ - CLI signup: a bare `init` no longer counts as signing up; it needs `--agent`, `--temporary` or a signup subcommand. AgentID's `npx @agentmail/agentid-cli init` registers a developer's app and read as AgentID's signup needing a shell. Cloudflare's CLI quote is now its `wrangler deploy --temporary` sentence instead of `wrangler init my-worker`.
22
+ - Signup: www.agentid.com read as "Try first, claim later" from three things that say nothing about its own signup: a blog link ("[can an AI agent sign up for a SaaS](/blog/can-an-ai-agent-sign-up-for-a-saas)", also in its sitemap), "owner claim", which on AgentID is an OpenID Connect field in a token, and the question "How can you tell an agent signup from a human one?". Now a match inside link text or a URL slug, a sitemap entry, or a question does not count as an agent signup, and the claim rule needs a person doing it ("the owner claims the workspace"). HTML written inline in a text file is split at its tags like an HTML page, so a quote never shows raw `<td className=...>`.
23
+ - Signup elsewhere: when the product's agent-facing pages send the agent to sign up on another company's site (AgentID's llms.txt: "Create an AgentMail inbox to get an AgentID", linking console.agentmail.to/sign-up), Sign up and Access say so and need a test run, instead of saying a person has to create the account. No recorded product other than AgentID changed state. JSON: `onboarding.signupElsewhere`.
24
+ - Fixtures: www.agentid.com re-recorded 2026-10-24, and the recorder keeps 250,000 characters a page instead of 150,000. Its own llms-full.txt now says "The agent creates and uses the account without a human present." about 163,000 characters in, so AgentID reads as Agent is the customer from that sentence, as it does live.
25
+ - JSON: quotes can carry `label` (`steps[].quotes[]`, `needsEvidence`, pattern `evidence`); `observedPrices[]` can carry `paidQuote`. Nothing is removed.
26
+ - Tests: resend.com is recorded in fixtures/onboarding. Over every recorded product, each quote is a whole sentence ending in terminal punctuation or a labelled element, sits inside one block of its page, starts a sentence and holds its trigger. The inbox products above are pinned with their exact sentences, and AgentID, firecrawl.dev, mem0.ai, moltbook.com, neon.com, paywithlocus.com, Track and Resend with none.
27
+
3
28
  ## 0.8.3 (2026-10-24)
4
29
 
5
30
  False findings fixed. `check` read Track (alg.katrinalaszlo.com), which is free and takes no email, as charging per request, needing an inbox and having a plan to change.
package/bin/cli.js CHANGED
@@ -406,8 +406,9 @@ Examples:
406
406
  const chosen = onboarding?.patterns?.find((p) => p.id === (config.onboarding || onboarding.primary));
407
407
  // Only with the sentence it came from: a need without a quote is not reported (interface.json from before 0.8.3
408
408
  // has none).
409
- const inboxQuote = chosen?.needs?.includes("inbox") ? chosen.needsEvidence?.inbox?.[0]?.quote : null;
410
- if (inboxQuote && !process.env.AGENTMAIL_API_KEY && !opts.inbox) plan.warnings = [`The product's signup sends an emailed code or link ("${inboxQuote}") and no inbox is set. Set AGENTMAIL_API_KEY or pass --inbox, or the run may end inconclusive.`];
409
+ const inboxEvidence = chosen?.needs?.includes("inbox") ? chosen.needsEvidence?.inbox?.[0] : null;
410
+ const inboxQuote = inboxEvidence?.quote ? `${inboxEvidence.label ? `${inboxEvidence.label}: ` : ""}"${inboxEvidence.quote}"` : null;
411
+ if (inboxQuote && !process.env.AGENTMAIL_API_KEY && !opts.inbox) plan.warnings = [`The product's signup sends an emailed code or link (${inboxQuote}) and no inbox is set. Set AGENTMAIL_API_KEY or pass --inbox, or the run may end inconclusive.`];
411
412
  if (opts.json) process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
412
413
  else for (const line of renderPlan(plan, style)) console.error(line);
413
414
  process.exit(plan.ready ? 0 : 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-led-growth",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "Score: evals for agents as customers. Runs a real agent through signup, key and first call on your product, before you ship. Track, the analytics half, measures which agents convert after.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -157,7 +157,7 @@ export function renderBrief(audit, doc) {
157
157
  lines.push(`Conflict: a verified person must exist before any use, but ${audit.onboarding.chosen.name} lets an agent act first. Consider "A person sets up access first".`, "");
158
158
  }
159
159
  lines.push("## Steps", "", "| Step | State | How we know | Evidence |", "| --- | --- | --- | --- |");
160
- for (const s of audit.steps) lines.push(`| ${s.name} | ${s.state.replace("_", " ")} | ${s.basis.replace("_", " ")} | ${evidence(s.basedOn).join(", ") || "none"}${(s.quotes || []).map((q) => `<br>"${q.quote.replace(/\|/g, "\\|")}"`).join("")} |`);
160
+ for (const s of audit.steps) lines.push(`| ${s.name} | ${s.state.replace("_", " ")} | ${s.basis.replace("_", " ")} | ${evidence(s.basedOn).join(", ") || "none"}${(s.quotes || []).map((q) => `<br>${q.label ? `${q.label}: ` : ""}"${q.quote.replace(/\|/g, "\\|")}"`).join("")} |`);
161
161
  lines.push("", "## Fixes", "");
162
162
  if (!audit.findings.length) lines.push("None from public pages. A test with a real agent is the next step: `npx agent-led-growth test --check` shows the plan for free, `npx agent-led-growth test` runs it.");
163
163
  for (const f of audit.findings) lines.push(`${f.n}. **${f.title}** (${f.severity}, ${f.name}). Prompt: prompts/${f.file}`);
@@ -77,9 +77,9 @@ export function renderSteps(audit, style) {
77
77
  out.push(` ${mark(s.state, style)} ${s.name.padEnd(NAME)} ${first.padEnd(REASON)} ${style.dim(TAG[s.basis] || s.basis)}`);
78
78
  for (const line of rest) out.push(` ${" ".repeat(NAME + 4)}${line}`);
79
79
  // A claim that the product requires something is shown with the sentence it came from, so a reader can check it.
80
+ // The quote is whole (src/interface/quote.js), never cut here; an element that is not a sentence carries its label.
80
81
  for (const q of s.quotes || []) {
81
- const text = q.quote.length > 160 ? `${q.quote.slice(0, 157).replace(/\s+\S*$/, "")}...` : q.quote;
82
- for (const line of wrap(`"${text}" (${pathOf(q.url)})`, 60)) out.push(` ${" ".repeat(NAME + 4)}${style.dim(line)}`);
82
+ for (const line of wrap(`${q.label ? `${q.label}: ` : ""}"${q.quote}" (${pathOf(q.url)})`, 60)) out.push(` ${" ".repeat(NAME + 4)}${style.dim(line)}`);
83
83
  }
84
84
  }
85
85
  return out;
@@ -254,7 +254,7 @@ export function evaluatePricing(x, observations) {
254
254
  return {
255
255
  model: pj?.value.revenueModel ? { value: pj.value.revenueModel, method: "extracted", evidence: pj.evidence } : null,
256
256
  plans: plans.map((p) => ({ ...p, evidence: pj.evidence })),
257
- observedPrices: pagesFound.map((p) => ({ prices: p.value.prices, evidence: p.evidence })),
257
+ observedPrices: pagesFound.map((p) => ({ prices: p.value.prices, evidence: p.evidence, ...(p.value.paidQuote ? { paidQuote: p.value.paidQuote } : {}) })),
258
258
  agentCanDetermineCost: cost,
259
259
  ambiguities,
260
260
  };
@@ -1,5 +1,6 @@
1
1
  import { parseRobots, AI_BOTS } from "../probe/index.js";
2
2
  import { htmlToText } from "./sources.js";
3
+ import { blocksOf, quoteAt } from "./quote.js";
3
4
 
4
5
  // Deterministic extraction. Every fact carries the observation it came from and a quote from that page.
5
6
  // A fact that cannot be extracted is null; callers turn nulls into unknowns. No inference happens here.
@@ -176,6 +177,50 @@ const AMBIGUITY_RE = /contact (?:us|sales)|custom pricing|talk to sales|get a qu
176
177
  // limit: Track's "4 KB per request", "What it sends, per request".
177
178
  const USAGE_RE = /usage[- ]based|pay[- ]as[- ]you[- ]go|\b(?:priced|charged|billed|paid|pay|costs?|fee|price)\s+(?:\w+\s+)?per (?:request|call|token|seat|user)\b|metered billing/gi;
178
179
 
180
+ // A price under one of these headings is not a plan: Resend's "Domains - $20 / mo" sits under "Add-ons", AISend's
181
+ // "$20/mo" under "How AISend Compares" is a competitor's, Neon's "$100k in credits" is under "Early-stage startups".
182
+ // Such a price is quoted only when the page has no other.
183
+ const NOT_A_PLAN = /\badd-?ons?\b|\boverages?\b|\bcompar\w*|\bvs\b|\bcredits?\b|\bstartups?\b|\bdiscounts?\b/i;
184
+ // A price that is not a sentence names the heading it sits under, which is usually its plan, and the quote stays the
185
+ // page's own words: Price under "Pro": "$14 /month".
186
+ function withHeading(q, heading) {
187
+ if (!q.label || !heading || heading.length > 40 || q.quote.includes(heading)) return q;
188
+ return { ...q, label: `${q.label === "Phrase" ? "Price" : q.label} under "${heading}"` };
189
+ }
190
+
191
+ // The nearest heading above [start, end) that holds no price: Resend's "Dedicated IPs - $30 / mo" is a heading under
192
+ // "Domains - $20 / mo", and both sit under "Add-ons", the section that says what they are.
193
+ function sectionHeading(page, text, start, end) {
194
+ const blocks = blocksOf(page, page.obs.role);
195
+ if (!blocks) return "";
196
+ let i = blocks.findIndex((b) => b.start <= start && end <= b.end);
197
+ for (i -= 1; i >= 0; i -= 1) {
198
+ const b = blocks[i];
199
+ if (b.kind !== "heading") continue;
200
+ const words = text.slice(b.start, b.end).replace(/[#*_`]/g, " ").replace(/\s+/g, " ").trim();
201
+ if (!/(?:\$|€|£)\s?\d/.test(words)) return words;
202
+ }
203
+ return "";
204
+ }
205
+
206
+ function paidPriceQuote(page, text) {
207
+ let tried = 0;
208
+ let fallback = null;
209
+ for (const m of text.matchAll(PRICE_RE)) {
210
+ // "$75M Series B" is a funding round, not a plan.
211
+ if (!/[1-9]/.test(m[0]) || /^[MB]\b/i.test(text.slice(m.index + m[0].length))) continue;
212
+ const q = quoteAt(page, text, m.index, m.index + m[0].length, page.obs.role);
213
+ if (q) {
214
+ const heading = sectionHeading(page, text, m.index, m.index + m[0].length);
215
+ const labelled = withHeading(q, heading);
216
+ if (!NOT_A_PLAN.test(heading)) return labelled;
217
+ fallback ??= labelled;
218
+ }
219
+ if (++tried >= 200) break;
220
+ }
221
+ return fallback;
222
+ }
223
+
179
224
  export function extractPricingPages(observations, bodies) {
180
225
  const out = [];
181
226
  for (const page of pages(observations, bodies, ["pricing", "homepage"])) {
@@ -184,7 +229,10 @@ export function extractPricingPages(observations, bodies) {
184
229
  const ambiguities = [...new Set((text.match(AMBIGUITY_RE) || []).map((s) => s.toLowerCase()))];
185
230
  const usage = [...new Set((text.match(USAGE_RE) || []).map((s) => s.toLowerCase()))];
186
231
  if (page.obs.role === "homepage" && !prices.length) continue;
187
- out.push(fact({ prices, ambiguities, usageTerms: usage }, page.obs.id, quoteAround(text, /(\$|€|£)\s?\d/) || quoteAround(text, AMBIGUITY_RE) || text.slice(0, 140)));
232
+ // A paid price is what Pay quotes when it says paid plans are published, so it gets a clean quote: the sentence or
233
+ // labelled element holding the first price above zero that has one (src/interface/quote.js), or null.
234
+ const paid = paidPriceQuote(page, text);
235
+ out.push(fact({ prices, ambiguities, usageTerms: usage, paidQuote: paid ? { obs: page.obs.id, ...paid } : null }, page.obs.id, quoteAround(text, /(\$|€|£)\s?\d/) || quoteAround(text, AMBIGUITY_RE) || text.slice(0, 140)));
188
236
  }
189
237
  return out;
190
238
  }
@@ -1,5 +1,5 @@
1
1
  import { STAGES, PILLAR } from "../schema/stages.js";
2
- import { AGENTID_NEEDS } from "./onboarding.js";
2
+ import { AGENTID_NEEDS, weakQuote } from "./onboarding.js";
3
3
  import { PASSING } from "../dashboard/strip.js";
4
4
  import { freePlansOnly } from "./evaluate.js";
5
5
 
@@ -64,7 +64,7 @@ function needsOf(pattern) {
64
64
  if (!NEEDS_SENTENCE[need]) throw new Error(`no sentence for need "${need}"`);
65
65
  return NEEDS_SENTENCE[need];
66
66
  }).join(" ");
67
- const quotes = kept.flatMap((need) => quoted(need).slice(0, 1).map((e) => ({ for: need, obs: e.obs, quote: e.quote })));
67
+ const quotes = kept.flatMap((need) => quoted(need).slice(0, 1).map((e) => ({ for: need, obs: e.obs, quote: e.quote, ...(e.label ? { label: e.label } : {}) })));
68
68
  return { kept, sentence, quotes };
69
69
  }
70
70
 
@@ -124,6 +124,9 @@ function signup(doc) {
124
124
  if (existing?.cli === "token") return step("signup", "handoff", "A person creates the account and a token. The agent's CLI takes the token from a flag or an environment variable, with no browser.", refs(existing));
125
125
  if (existing?.cli === "browser") return step("signup", "handoff", "A person creates the account and signs the CLI in through a browser; no token option was found in the pages read. The agent carries on from there.", refs(existing));
126
126
  if (existing) return step("signup", "handoff", "A person creates the account and gives the agent a key. The agent carries on from there.", refs(existing));
127
+ // The product sends the agent to sign up on another company's site, which is not read: unknown, not a gap.
128
+ const elsewhere = doc.onboarding?.signupElsewhere;
129
+ if (elsewhere) return step("signup", "not_checked", `The docs send the agent to sign up on ${elsewhere.domain}, another company's site, which was not read. Only a test run shows whether an agent can sign up there.`, elsewhere.evidence.map((e) => e.obs));
127
130
  return step("signup", "needs_person", "The pages read document no way for an agent to sign up. A person may have to create the account; only a real agent run can confirm.");
128
131
  }
129
132
 
@@ -138,6 +141,8 @@ function access(doc, signupStep) {
138
141
  const self = (doc.onboarding?.patterns || []).find((p) => SIGNUP_PATTERNS.has(p.id));
139
142
  if (signupStep.state === "agent_can" && self?.provider === "AgentID") return step("access", "agent_can", "Signed in with AgentID, the agent holds its own account and gets its key or session from it, not from a person.", [...refs, ...signupStep.basedOn]);
140
143
  if (signupStep.state === "agent_can") return step("access", "agent_can", "The agent signup path hands back a key the agent can use.", [...refs, ...signupStep.basedOn]);
144
+ const elsewhere = doc.onboarding?.signupElsewhere;
145
+ if (elsewhere && signupStep.state === "not_checked") return step("access", "not_checked", `The agent gets its credential where it signs up, on ${elsewhere.domain}. Only a test run shows whether it can.`, [...refs, ...signupStep.basedOn]);
141
146
  if (methods.length || terms.length) return step("access", "needs_person", `The docs describe ${describeAuth(methods, terms)}, but the pages read do not show how an agent gets one.`, refs);
142
147
  return step("access", "not_checked", "The docs read do not say how an agent authenticates.", refs);
143
148
  }
@@ -154,18 +159,20 @@ function pay(doc) {
154
159
  const plans = doc.pricing?.plans || [];
155
160
  const observed = doc.pricing?.observedPrices || [];
156
161
  const paidPlans = plans.filter((p) => typeof p.amount === "number" && p.amount > 0);
157
- const paidText = observed.filter((o) => (o.prices || []).some((price) => /[1-9]/.test(price)));
162
+ // The pricing page first: a homepage price can be anything (a demo's "$49.00", a funding round).
163
+ const roleOf = (o) => doc.observations?.find((x) => x.id === o.evidence?.[0]?.obs)?.role;
164
+ const paidText = observed.filter((o) => (o.prices || []).some((price) => /[1-9]/.test(price))).sort((a, b) => Number(roleOf(b) === "pricing") - Number(roleOf(a) === "pricing"));
158
165
  const refs = [...plans.flatMap((p) => (p.evidence || []).map((e) => e.obs)), ...paidText.flatMap((o) => (o.evidence || []).map((e) => e.obs))];
159
166
  // A purchase the product's own pages document (src/interface/purchase.js). One approval by a person, then the agent
160
167
  // buys on its own, is a handoff: a person steps in at a documented point and the agent carries on. It is not
161
168
  // agent_can, which would say no person is needed, and not needs_person, which would say a person checks out each time.
169
+ const quote = (entries, what) => (entries || []).filter((e) => e.quote).sort((a, b) => weakQuote(a) - weakQuote(b)).slice(0, 1).map((e) => ({ for: what, obs: e.obs, quote: e.quote, ...(e.label ? { label: e.label } : {}) }));
162
170
  const purchase = doc.pricing?.agentPurchase;
163
171
  const purchaseRefs = (purchase?.evidence || []).map((e) => e.obs);
164
- const purchaseQuotes = (purchase?.evidence || []).filter((e) => e.quote).slice(0, 1).map((e) => ({ for: "payment", obs: e.obs, quote: e.quote }));
172
+ const purchaseQuotes = quote(purchase?.evidence, "payment");
165
173
  const perRequestCall = purchase?.rule === "pay_per_request_own_api";
166
174
  const checkoutCall = purchase?.rule === "checkout_protocol_own_api";
167
175
  const call = purchase?.call ? ` (${purchase.call})` : "";
168
- const quote = (entries, what) => (entries || []).filter((e) => e.quote).slice(0, 1).map((e) => ({ for: what, obs: e.obs, quote: e.quote }));
169
176
  const over = purchase?.protocol ? ` over ${purchase.protocol}` : "";
170
177
  if (purchase?.approval === "once") {
171
178
  const how = perRequestCall ? `pays over ${purchase.protocol || "x402 or MPP"}${call}` : checkoutCall ? `checks out over ${purchase.protocol}${call}` : purchase.call ? `buys through ${purchase.call}${over}` : `buys through the API${over}`;
@@ -183,11 +190,24 @@ function pay(doc) {
183
190
  if (perRequest) return step("pay", "not_checked", "The docs mention paying per request over x402. A test with a real agent shows whether it can actually pay.", (perRequest.evidence || []).map((e) => e.obs), null, quote(perRequest.evidence, "payment"));
184
191
  // Only the product's own domain is read: a purchase path on a payments provider's site (Stripe Projects lists
185
192
  // Cloudflare, Neon, Turso and others on stripe.com) is not seen here, so the sentence says what was read.
186
- if (paidPlans.length || paidText.length) return step("pay", "needs_person", "Paid plans are published. The pages read document no way for an agent to buy one, so a person may have to check out.", refs, null, quote(paidPlans.length ? paidPlans[0].evidence : paidText[0].evidence, "payment"));
193
+ // A paid price in prose counts only with a clean quote (the sentence or labelled element it is in): Cloudflare's
194
+ // pricing page carries its prices only inside a script's JSON, which is no sentence to show.
195
+ const quotedText = paidText.filter((o) => o.paidQuote);
196
+ if (paidPlans.length || quotedText.length) return step("pay", "needs_person", "Paid plans are published. The pages read document no way for an agent to buy one, so a person may have to check out.", refs, null, paidPlans.length ? planQuote(paidPlans[0]) : quote([quotedText[0].paidQuote], "payment"));
197
+ if (paidText.length) return step("pay", "not_checked", "Prices appear in the pages read, but not in a sentence or table that can be quoted. A test shows whether an agent can pay.", refs);
187
198
  if (plans.length || observed.length) return step("pay", "not_checked", "Only free plans were found, so there is nothing to pay for yet.", refs);
188
199
  return step("pay", "not_checked", "No plans were found in the pages read.");
189
200
  }
190
201
 
202
+ // A paid plan from pricing.json, quoted as the plan it is rather than a cut of the file's JSON:
203
+ // pricing.json: "Pro: 20 USD per month".
204
+ function planQuote(plan) {
205
+ const obs = plan.evidence?.[0]?.obs;
206
+ if (!obs) return [];
207
+ const price = [plan.amount, plan.currency, plan.period ? `per ${plan.period}` : ""].filter((x) => x !== null && x !== "").join(" ");
208
+ return [{ for: "payment", obs, quote: `${plan.name || plan.id || "Plan"}: ${price}`, label: "pricing.json" }];
209
+ }
210
+
191
211
  function use(doc) {
192
212
  const caps = doc.capabilities || [];
193
213
  const api = doc.interfaces?.api;
@@ -218,7 +238,7 @@ function withAgentId(doc, s, sentence) {
218
238
  const self = patterns.find((p) => SIGNUP_PATTERNS.has(p.id));
219
239
  const agentId = patterns.find((p) => p.provider === "AgentID");
220
240
  if (!agentId || agentId === self) return s;
221
- const quotes = [...(s.quotes || []), ...agentId.evidence.filter((e) => e.quote).slice(0, 1).map((e) => ({ for: "agent_identity", obs: e.obs, quote: e.quote }))];
241
+ const quotes = [...(s.quotes || []), ...agentId.evidence.filter((e) => e.quote).slice(0, 1).map((e) => ({ for: "agent_identity", obs: e.obs, quote: e.quote, ...(e.label ? { label: e.label } : {}) }))];
222
242
  return { ...s, reason: `${s.reason} ${sentence}`, basedOn: [...new Set([...s.basedOn, ...agentId.evidence.map((e) => e.obs)])], quotes };
223
243
  }
224
244
 
@@ -1,5 +1,6 @@
1
1
  import { htmlToText, registrableDomain, sameSite } from "./sources.js";
2
2
  import { freePlansOnly } from "./evaluate.js";
3
+ import { blockContext, quoteAt } from "./quote.js";
3
4
 
4
5
  // Which ways can an agent start using this product? Read from public docs only, so every pattern is a claim the docs
5
6
  // make, with the quotes that make it; none is a result. A product can support several. Rules, not a model: each signal
@@ -14,8 +15,9 @@ const SIGNALS = {
14
15
  // Agent-infra products use the same words for signing up somewhere else: AgentLine's "so your agent can sign up and
15
16
  // verify", AgentID's "apps where agents can sign up and sign in with AgentID". "No agent can sign up" is the opposite.
16
17
  bootstrap: /\bagent[- ]?sign[- ]?up\b|(?<!\b(?:no|not|never|where)\s+(?:\w+\s+){0,2})\bagents? (?:can )?(?:sign(?:s)? (?:itself |themselves )?up|creates? (?:its|their) own (?:accounts?|API keys?))(?:\*\*)?(?: here| for an account| in one (?:API )?(?:call|request)| and get (?:an? |its |their )?(?:own )?(?:API )?key| \(beta\)| instantly| immediately| automatically| programmatically)*(?=(?:\*\*)?\s*(?:[.;!)\n]|,\s*(?:and )?get\b|[—–]|$))|\/agents?\/sign-?up\b|\bsign(?:s|ing)? (?:itself|themselves) up\b|\bregisters? itself\b|\bno (?:human|account|signup|sign-up)(?: or API key)? (?:is )?(?:needed|required)\b|\bwithout (?:a |an )?(?:human|account)\b|\banonymous(?:ly)? (?:create|creates|provision|access|account|project|identit)|\btemporary (?:account|project|deployment)s?\b|--temporary\b|\binit --agent\b|\bagent_mode\b|\bbot[_ ]signup\b|\bidentity_types_supported\b/i,
17
- // A person can take ownership afterwards.
18
- claim: /\bclaim(?:s|ed|ing)? (?:it|this|the|your|their) (?:account|project|workspace|deployment|bucket|inbox|resources?)\b|\bclaim (?:url|code|link|token|flow|page|email)\b|\bclaim_url\b|\bclaimUrl\b|\/claim\b|\bunclaimed\b|\bclaimable\b|\b(?:human|owner|person) (?:can |may |will )?(?:later )?claim\b|\battach (?:a |the )?(?:human )?owner\b|\badopt (?:the |this )?workspace\b/i,
18
+ // A person can take ownership afterwards. "The owner claims the workspace", with an object: an OpenID Connect "owner
19
+ // claim" is a field in a token (AgentID's llms-full.txt "the requested owner claim quietly missing"), not a person.
20
+ claim: /\bclaim(?:s|ed|ing)? (?:it|this|the|your|their) (?:account|project|workspace|deployment|bucket|inbox|resources?)\b|\bclaim (?:url|code|link|token|flow|page|email)\b|\bclaim_url\b|\bclaimUrl\b|\/claim\b|\bunclaimed\b|\bclaimable\b|\b(?:human|owner|person) (?:can |may |will |must |then )?(?:later )?claims? (?:it|this|the|your|their|its)\b|\battach (?:a |the )?(?:human )?owner\b|\badopt (?:the |this )?workspace\b/i,
19
21
  // What the agent made goes away unless someone keeps it.
20
22
  // Tied to the account or resource: "quotes expire after 5 minutes" is not this. The sentence must name an account,
21
23
  // project, workspace, key or token and not a stored thing (STORED_THING below): Supermemory's "temporary facts drop
@@ -44,7 +46,7 @@ const SIGNALS = {
44
46
  // The documented way in is a command-line tool rather than an HTTP call.
45
47
  // At most four words between the tool and the flag or subcommand, so it is one command line: Track's docs say
46
48
  // "npx agent-led-growth check <url> runs a real agent through signup", which is prose after a command, not a signup.
47
- cliBootstrap: /\b(?:npx|wrangler|mem0|stripe|npm i(?:nstall)?|pip install|brew install)\b(?:[ \t]+[^\s`]+){0,4}?[ \t]+(?:--temporary|--agent|init\b|sign-?up\b|projects init)/i,
49
+ cliBootstrap: /\b(?:npx|wrangler|mem0|stripe|npm i(?:nstall)?|pip install|brew install)\b(?:[ \t]+[^\s`]+){0,4}?[ \t]+(?:--temporary|--agent|sign-?up\b|projects init)/i,
48
50
  // The product's CLI takes a token without a browser, so an agent can run it unattended once a person has made the
49
51
  // token: Vercel's "Set the VERCEL_TOKEN environment variable" or "Pass the --token option", GitHub's "--with-token".
50
52
  // An environment variable counts only where the text calls it one: code samples are full of other _API_KEY names.
@@ -195,9 +197,19 @@ function findAgentIdSignIn(doc, bodies) {
195
197
  ...(agentFacing ? [...text.matchAll(AGENTID_DECLARED)] : []),
196
198
  ...[...text.matchAll(AGENTID_ROUTE)],
197
199
  ].sort((x, y) => x.index - y.index);
198
- const match = matches.find((m) => !AGENTID_ELSEWHERE.test(text.slice(Math.max(0, m.index - 60), m.index + m[0].length + 60)) && !announcedByOther(text.slice(Math.max(0, m.index - 60), m.index + m[0].length), doc) && !AGENTID_VENDOR.test(sentenceAround(text, m.index, m.index + m[0].length)));
200
+ const kept = matches.filter((m) => !AGENTID_ELSEWHERE.test(text.slice(Math.max(0, m.index - 60), m.index + m[0].length + 60)) && !announcedByOther(text.slice(Math.max(0, m.index - 60), m.index + m[0].length), doc) && !AGENTID_VENDOR.test(sentenceAround(text, m.index, m.index + m[0].length)));
201
+ // The first kept match with a clean quote: one sentence or labelled element holding the words (src/interface/quote.js).
202
+ let match = null;
203
+ let q = null;
204
+ for (const m of kept) {
205
+ q = quoteAt(body, text, m.index, m.index + m[0].length, obs.role);
206
+ if (q) {
207
+ match = m;
208
+ break;
209
+ }
210
+ }
199
211
  if (!match) continue;
200
- out.push({ obs: obs.id, quote: quoteAround(text, match.index), agentFacing });
212
+ out.push({ obs: obs.id, quote: q.quote, ...(q.label ? { label: q.label } : {}), agentFacing });
201
213
  if (AGENTID_OWNER.test(text.slice(Math.max(0, match.index - OWNER_WINDOW), match.index + OWNER_WINDOW))) ownerDocumented = true;
202
214
  }
203
215
  return { entries: out, ownerDocumented };
@@ -284,47 +296,133 @@ const SIGNUP_ELSEWHERE = /\bsign(?:s|ing)? up for (?:services|other|any|third-pa
284
296
  // An optional step is not a requirement: Locus's "the human is needed only for an optional AgentMail OTP".
285
297
  const OPTIONAL = /\boptional(?:ly)?\b/i;
286
298
 
299
+ // An inbox step counts only when its subject is the product's own signup. AgentID's llms-full.txt tells the general
300
+ // problem, "An agent signs up with its owner's email, reads the verification code out of the owner's inbox", and that
301
+ // read as AgentID's own signup needing an inbox. A sentence counts when it says so itself (ownSignup below), or when it
302
+ // sits on the product's own agent-signup or auth page, or in a signup section of a page (under a "Sign up", "Agent
303
+ // Signup", "Registration" or "Quick start" heading, or right after a step that is the product's own signup call).
304
+ // A third-person narrative ("an agent signs up", "AI agents can sign up for a SaaS product") counts only on the
305
+ // product's own signup page.
306
+ // The sentence calls one of the product's signup, sign-in or verify endpoints: AgentLine's "`POST /v1/auth/otp` →
307
+ // `POST /v1/auth/verify`", Cosmic's "(POST /v3/agents/sign-up)", AgentMail's "curl -X POST .../v0/agent/sign-up".
308
+ const SIGNUP_CALL = /\b(?:POST|GET|PUT)\b[^\n]{0,24}?\/[\w.\/{}:-]*?(?:sign[-_]?up|sign[-_]?in|register|registration|verif\w*|otp|auth|login|magic[-_]?link|onboard)\b/i;
309
+ // "We send", "we'll email", "our signup": the product speaking about its own flow.
310
+ const FIRST_PERSON = /\b(?:we|we'll|we will)\s+(?:then\s+)?(?:send|email|text|mail)\b|\bour (?:sign[- ]?up|signup|registration|onboarding|verification|sign[- ]?in|login)\b|\b(?:create an account|sign up|register) here\b/i;
311
+ const SIGNUP_HEADING = /^\W*(?:\w+ )?(?:sign[- ]?up|signup|sign[- ]?in|register|registration|create (?:an |your )?account|get(?:ting)? started|quick ?start|onboarding)\b(?![^?]*\?)/i;
312
+ const GENERIC_NARRATIVE = /\b(?:an?|every|each|any|most|many|some)\s+(?:AI\s+)?(?:agents?|users?|people|persons?|humans?|developers?|bots?)\b(?:\s+\w+){0,3}?\s+(?:sign(?:s|ed|ing)?|can sign|could sign) up\b/i;
313
+ const OWN_SIGNUP_PAGES = new Set(["onboarding", "auth"]);
314
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
315
+ // The product's names: its host's labels (agentmail, cosmicjs and cosmic) and the first part of its page title.
316
+ function productNames(doc) {
317
+ const labels = doc.target.host.toLowerCase().split(".").slice(0, -1).filter((l) => l !== "www" && l.length > 2);
318
+ const variants = labels.flatMap((l) => [l, l.replace(/(?:js|hq|app|ai|io|dev|labs)$/, "")]).filter((l) => l.length > 2);
319
+ const title = String(doc.product?.name?.value || "").split(/\s+[|\u2014\u2013:-]\s+|\s*[|:]\s*/)[0].trim();
320
+ return [...new Set([...variants, ...(title && title.length <= 40 ? [title.toLowerCase()] : [])])];
321
+ }
322
+ function ownSignupSentence(sentence, names) {
323
+ if (SIGNUP_CALL.test(sentence) || FIRST_PERSON.test(sentence)) return true;
324
+ if (!names.length) return false;
325
+ const name = `(?:${names.map(escapeRe).join("|")})`;
326
+ return new RegExp(`\\b(?:sign(?:s|ed|ing)?[- ]?(?:up|in)|log(?:s|ged|ging)?[- ]?in|register(?:s|ed|ing)?|creat(?:e|es|ed|ing) (?:an? |your )?account)\\s+(?:for|to|with|on|at|into)\\s+(?:an? )?${name}\\b|\\b${name}\\s+(?:sends?|emails?|will (?:send|email)|texts?)\\b|\\b(?:your|an?|the) ${name} account\\b|\\b${name}[\\w.]*\\/(?:sign-?up|register|signin|login)\\b`, "i").test(sentence);
327
+ }
328
+ function ownSignup(sentence, match, page) {
329
+ if (ownSignupSentence(sentence, page.names)) return true;
330
+ // On the product's own signup page the sentence still has to be about signing up: Moltbook's "Verification code
331
+ // expired." is a line of an error list, Locus's "Never ask the human to reveal their ... email verification
332
+ // code" is advice. The words are looked for outside the match.
333
+ if (OWN_SIGNUP_PAGES.has(page.role) && SIGNUP_CONTEXT.test(withoutMatch(sentence, match))) return true;
334
+ if (GENERIC_NARRATIVE.test(sentence)) return false;
335
+ const ctx = page.context();
336
+ if (!ctx) return false;
337
+ const heading = ctx.heading.replace(/[#*_`]/g, " ").trim();
338
+ return SIGNUP_HEADING.test(heading) || ownSignupSentence(heading, page.names) || ownSignupSentence(ctx.previous, page.names);
339
+ }
340
+
341
+ // A question says nothing about this product: AgentID's llms-full.txt "How can you tell an agent signup from a human
342
+ // one?" heads an explainer, and its comparison table's row "Can the agent sign up for a new service on its own" has no
343
+ // question mark. An FAQ's answer, where it says yes, is its own sentence and is tried next.
344
+ const QUESTION = /\?\s*(?:\*\*)?\s*$|^\W*(?:can|could|does|do|is|are|will|should|how|why|what|when|where)\b/i;
345
+
346
+ // The match names a page rather than saying anything: AgentID's "[can an AI agent sign up for a
347
+ // SaaS](/blog/can-an-ai-agent-sign-up-for-a-saas)" is a link to a blog post, in its text and in its slug. A path like
348
+ // /agents/signup starts with a slash and still counts.
349
+ function inSlug(sentence, match) {
350
+ if (match.startsWith("/")) return false;
351
+ const m = escapeRe(match);
352
+ if (new RegExp(`\\[[^\\]]*${m}[^\\]]*\\]\\(`, "i").test(sentence)) return true;
353
+ const re = new RegExp(`[\\w/]-${m}|${m}-[a-z]`, "i");
354
+ return re.test(sentence) && !sentence.includes(` ${match} `) && !sentence.startsWith(`${match} `);
355
+ }
356
+
357
+ // A comparison names other products: Locus's "Compare Locus Pro, RapidAPI, x402 Bazaar, Composio, Eden AI and ToolRoute
358
+ // on billing" names x402 Bazaar, a competitor, not a way to pay Locus.
359
+ const COMPARISON = /^\W*compare\b|\bvs\.?\s|\bversus\b|\balternatives? to\b/i;
360
+
287
361
  // Whether a sentence holding a match is about something else; such a match is skipped and the next one tried.
362
+ // page: { role, names, context() } for the rules that read where the sentence is (the inbox rule).
288
363
  const SKIP = {
289
- bootstrap: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || SIGNUP_OFF.test(sentence) || PERSON_WITHOUT_ACCOUNT.test(sentence) || PAYMENT_WAY_IN.test(sentence) || CLIENT_REGISTRATION.test(sentence) || BROKERED.test(sentence) || (/^temporary\b/i.test(match) && STORED_THING.test(sentence)),
364
+ bootstrap: (sentence, ownDomain, match) => inSlug(sentence, match) || QUESTION.test(sentence) || DOMAIN_ACTORS.test(sentence) || SIGNUP_OFF.test(sentence) || PERSON_WITHOUT_ACCOUNT.test(sentence) || PAYMENT_WAY_IN.test(sentence) || CLIENT_REGISTRATION.test(sentence) || BROKERED.test(sentence) || (/^temporary\b/i.test(match) && STORED_THING.test(sentence)),
290
365
  expiry: (sentence) => !ACCOUNT_THING.test(sentence) || STORED_THING.test(sentence) || NO_EXPIRY.test(sentence),
291
366
  restricted: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || EXAMPLE.test(sentence) || negatedBefore(sentence, match),
292
- emailStep: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || EXAMPLE.test(sentence) || INDEX_LINE.test(sentence) || SIGNUP_ELSEWHERE.test(sentence) || OPTIONAL.test(sentence) || NO_INBOX.test(sentence) || negatedBefore(sentence, match) || !SIGNUP_CONTEXT.test(withoutMatch(sentence, match)),
367
+ emailStep: (sentence, ownDomain, match, page, label) => label === "Code" || DOMAIN_ACTORS.test(sentence) || EXAMPLE.test(sentence) || INDEX_LINE.test(sentence) || SIGNUP_ELSEWHERE.test(sentence) || OPTIONAL.test(sentence) || NO_INBOX.test(sentence) || negatedBefore(sentence, match) || !ownSignup(sentence, match, page),
293
368
  humanFirst: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || EXAMPLE.test(sentence) || negatedBefore(sentence, match),
294
369
  httpBootstrap: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || OAUTH_REGISTRATION.test(match),
295
- payPerRequest: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || SELLER.test(sentence) || PROTOCOL_DESCRIPTION.test(sentence) || paysElsewhere(sentence, ownDomain) || negatedBefore(sentence, match),
370
+ payPerRequest: (sentence, ownDomain, match) => COMPARISON.test(sentence) || DOMAIN_ACTORS.test(sentence) || SELLER.test(sentence) || PROTOCOL_DESCRIPTION.test(sentence) || paysElsewhere(sentence, ownDomain) || negatedBefore(sentence, match),
296
371
  };
297
- const GLOBAL = Object.fromEntries(Object.keys(SKIP).map((name) => [name, new RegExp(SIGNALS[name].source, `${SIGNALS[name].flags}g`)]));
372
+ const GLOBAL = Object.fromEntries(Object.keys(SIGNALS).map((name) => [name, new RegExp(SIGNALS[name].source, `${SIGNALS[name].flags.replace("g", "")}g`)]));
298
373
 
299
- // The first match whose sentence is not about something else. Tries at most MAX_TRIED matches a page, so a page that
300
- // repeats a skipped phrase thousands of times costs no more than one that says it once.
374
+ // The first match that has a clean quote (src/interface/quote.js) and whose sentence is not about something else. The
375
+ // guards read the same sentence the quote shows, so a rule never decides on words from a neighbouring element of the
376
+ // page. Tries at most MAX_TRIED matches a page, so a page that repeats a skipped phrase thousands of times costs no
377
+ // more than one that says it once.
301
378
  const MAX_TRIED = 200;
302
- function firstKept(text, re, skip, ownDomain) {
379
+ // A quote that names something without saying anything about it: a heading of a word or two (x402.org's "x402"). Kept
380
+ // only when the page has no sentence for the same rule. A docs-index line with a description is not this: Locus's
381
+ // "[x402 reference](...): Direct HTTP 402 payments via Base USDC." says what the page covers.
382
+ // A page title as a heading ("x402 | AgentMail | Documentation") and a list item of a few words ("About x402") say no
383
+ // more.
384
+ const SHORT_LABELS = new Set(["Heading", "List item", "Text", "Phrase"]);
385
+ export function weakQuote(q) {
386
+ // A question, or a piece cut from inside link text ("[What is x402?"), says nothing either.
387
+ if (/\?\W*$/.test(q.quote) || (q.quote.match(/\[/g) || []).length !== (q.quote.match(/\]/g) || []).length) return true;
388
+ if (!SHORT_LABELS.has(q.label)) return false;
389
+ return q.quote.trim().split(/\s+/).length <= 3 || (q.label === "Heading" && / \| /.test(q.quote));
390
+ }
391
+ function firstKept(page, re, skip, ownDomain) {
303
392
  let tried = 0;
304
- for (const m of text.matchAll(re)) {
305
- if (!skip(sentenceAround(text, m.index, m.index + m[0].length).replace(/\s+/g, " "), ownDomain, m[0])) return m;
393
+ let weak = null;
394
+ for (const m of page.text.matchAll(re)) {
395
+ const q = quoteAt(page.body, page.text, m.index, m.index + m[0].length, page.role);
396
+ const at = { ...page, context: () => blockContext(page.body, page.text, m.index, m.index + m[0].length, page.role) };
397
+ if (q && !(skip && skip(q.quote, ownDomain, m[0], at, q.label))) {
398
+ if (!weakQuote(q)) return { index: m.index, ...q };
399
+ weak ??= { index: m.index, ...q };
400
+ }
401
+ // A weak match counts too, so a page that repeats a heading thousands of times costs no more than one.
306
402
  tried += 1;
307
- if (tried >= MAX_TRIED) return null;
403
+ if (tried >= MAX_TRIED) break;
308
404
  }
309
- return null;
405
+ return weak;
310
406
  }
311
407
 
312
- function findSignals(observations, bodies, targetUrl) {
408
+ function findSignals(observations, bodies, targetUrl, names) {
313
409
  const ownDomain = registrableDomain(new URL(targetUrl).hostname);
314
410
  const found = Object.fromEntries(Object.keys(SIGNALS).map((name) => [name, []]));
315
411
  for (const obs of observations) {
316
412
  const body = bodies.get(obs.id);
317
413
  if (!body) continue;
318
- const text = body.html ? htmlToText(body.text) : body.text;
319
- for (const [name, re] of Object.entries(SIGNALS)) {
414
+ const page = { body, role: obs.role, names, text: body.html ? htmlToText(body.text) : body.text };
415
+ for (const name of Object.keys(SIGNALS)) {
320
416
  if (found[name].length >= EVIDENCE_PER_SIGNAL) continue;
321
- // A sitemap's URL slugs ("/posts/x402-launch") are page names, and a template or example page
322
- // (vercel.com/templates/next.js/x402-ai-starter) is code for the reader's own app: neither documents paying this product.
323
- if (name === "payPerRequest" && (obs.role === "sitemap" || EXAMPLE_PAGE.test(new URL(obs.url).pathname))) continue;
324
- // Guarded signals try every match, so one sentence about something else does not hide a real one.
325
- const skip = SKIP[name];
326
- const match = skip ? firstKept(text, GLOBAL[name], skip, ownDomain) : re.exec(text);
327
- if (match) found[name].push({ obs: obs.id, quote: quoteAround(text, match.index), agentFacing: AGENT_FACING.has(obs.role) });
417
+ // A sitemap's URL slugs ("/posts/x402-launch", "/blog/can-an-ai-agent-sign-up-for-a-saas") are page names, not
418
+ // claims. A template or example page (vercel.com/templates/next.js/x402-ai-starter) is code for the reader's own
419
+ // app: it does not document paying this product.
420
+ if (obs.role === "sitemap") continue;
421
+ if (name === "payPerRequest" && EXAMPLE_PAGE.test(new URL(obs.url).pathname)) continue;
422
+ // Every match is tried, so one sentence about something else, or words run together from two elements, does not
423
+ // hide a real one.
424
+ const match = firstKept(page, GLOBAL[name], SKIP[name], ownDomain);
425
+ if (match) found[name].push({ obs: obs.id, quote: match.quote, ...(match.label ? { label: match.label } : {}), agentFacing: AGENT_FACING.has(obs.role) });
328
426
  }
329
427
  }
330
428
  return found;
@@ -360,14 +458,15 @@ function needsFor(id, found) {
360
458
  const NEED_SIGNALS = { inbox: ["emailStep"], cli: ["cliBootstrap"], wallet: ["payPerRequest"], agent_identity: ["agentIdSignIn", "agentIdentity"] };
361
459
  function needsEvidenceFor(needs, found) {
362
460
  const ordered = (entries) => [...entries.filter((e) => e.agentFacing), ...entries.filter((e) => !e.agentFacing)];
363
- return Object.fromEntries(needs.map((need) => [need, ordered(NEED_SIGNALS[need].flatMap((name) => found[name] || [])).map(({ obs, quote }) => ({ obs, quote }))]));
461
+ return Object.fromEntries(needs.map((need) => [need, ordered(NEED_SIGNALS[need].flatMap((name) => found[name] || [])).map(({ obs, quote, label }) => ({ obs, quote, ...(label ? { label } : {}) }))]));
364
462
  }
365
463
 
366
464
  function pattern(id, found, signalNames, reason) {
367
465
  // The product's agent-facing pages first: a quote from llms.txt or an agent-signup
368
466
  // page says more than the same words in homepage navigation.
369
467
  const entries = signalNames.flatMap((name) => found[name]);
370
- const evidence = [...entries.filter((e) => e.agentFacing), ...entries.filter((e) => !e.agentFacing)].map(({ obs, quote }) => ({ obs, quote }));
468
+ const ordered = [...entries.filter((e) => e.agentFacing), ...entries.filter((e) => !e.agentFacing)];
469
+ const evidence = [...ordered.filter((e) => !weakQuote(e)), ...ordered.filter((e) => weakQuote(e))].map(({ obs, quote, label }) => ({ obs, quote, ...(label ? { label } : {}) }));
371
470
  const documented = signalNames.some((name) => found[name].some((entry) => entry.agentFacing));
372
471
  return {
373
472
  id,
@@ -404,9 +503,27 @@ function findApiHosts(doc, bodies) {
404
503
  return [...hosts.values()];
405
504
  }
406
505
 
506
+ // A signup page on another company's site that the product's agent-facing pages send the agent to: AgentID's llms.txt
507
+ // "[Get started](https://console.agentmail.to/sign-up): Create an AgentMail inbox to get an AgentID". That page is not
508
+ // read, so whether an agent can sign up there is unknown, not a gap in this product's docs.
509
+ const SIGNUP_URL = /https:\/\/([a-z0-9.-]+\.[a-z]{2,})(\/[^\s"'`<>)\]]*)?/gi;
510
+ const SIGNUP_PATH = /\/(?:sign-?up|register|get-started)(?:[/?#]|$)/i;
511
+ function findSignupElsewhere(doc, bodies) {
512
+ for (const obs of doc.observations) {
513
+ const body = bodies.get(obs.id);
514
+ if (!body || !obs.ok || !AGENT_FACING.has(obs.role)) continue;
515
+ const text = body.html ? htmlToText(body.text) : body.text;
516
+ for (const match of text.matchAll(SIGNUP_URL)) {
517
+ if (sameSite(`https://${match[1]}/`, doc.target.url) || !SIGNUP_PATH.test(match[2] || "")) continue;
518
+ return { domain: registrableDomain(match[1].toLowerCase()), url: match[0], evidence: [{ obs: obs.id, quote: quoteAround(text, match.index) }] };
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+
407
524
  export function detectOnboarding(doc, bodies) {
408
525
  const observations = doc.observations;
409
- const found = findSignals(observations, bodies, doc.target.url);
526
+ const found = findSignals(observations, bodies, doc.target.url, productNames(doc));
410
527
  const onIssuerSite = sameSite(AGENTID_ISSUER, doc.target.url);
411
528
  const agentId = onIssuerSite ? { entries: [], ownerDocumented: false } : findAgentIdSignIn(doc, bodies);
412
529
  found.agentIdSignIn = agentId.entries;
@@ -462,6 +579,7 @@ export function detectOnboarding(doc, bodies) {
462
579
  return {
463
580
  patterns,
464
581
  apiHosts: findApiHosts(doc, bodies),
582
+ signupElsewhere: findSignupElsewhere(doc, bodies),
465
583
  primary: agentFirst?.id || patterns[0]?.id || null,
466
584
  reason: patterns.length ? `${patterns.length} way${patterns.length === 1 ? "" : "s"} for an agent to start, from the sources read.` : `No way for an agent to start was found in the sources read.${doc.otherHostsSkipped ? " Links to other hosts were not read." : ""} This is not evidence that none exists.`,
467
585
  };
@@ -1,5 +1,6 @@
1
1
  import { htmlToText, registrableDomain, sameSite } from "./sources.js";
2
- import { AGENT_FACING, DOMAIN_ACTORS, PROTOCOL_DESCRIPTION, PROTOCOL_SITES, SELLER, paysElsewhere, quoteAround, sentenceAround } from "./onboarding.js";
2
+ import { AGENT_FACING, DOMAIN_ACTORS, PROTOCOL_DESCRIPTION, PROTOCOL_SITES, SELLER, paysElsewhere, sentenceAround } from "./onboarding.js";
3
+ import { quoteAt } from "./quote.js";
3
4
 
4
5
  // Whether the product's own pages document a way for an agent to buy from it through an API, and whether a person
5
6
  // approves first. Rules over page text, like the onboarding signals: each rule is a named pattern, every hit carries
@@ -239,7 +240,7 @@ export function detectAgentPurchase(doc, bodies) {
239
240
  const text = body.html ? htmlToText(body.text) : body.text;
240
241
  // The product publishes its UCP business profile: the product saying agents can check out with it over UCP.
241
242
  if (obs.role === "ucp_profile") {
242
- if (isUcpProfile(text)) hits.push({ rule: "checkout_protocol_own_api", obs: obs.id, quote: quoteAround(text, 0), approval: false, call: null, score: 2, protocol: "UCP", agentFacing: true });
243
+ if (isUcpProfile(text)) hits.push({ rule: "checkout_protocol_own_api", obs: obs.id, quote: new URL(obs.url).pathname, label: "UCP business profile", approval: false, call: null, score: 2, protocol: "UCP", agentFacing: true });
243
244
  continue;
244
245
  }
245
246
  const sellerPage = SELLER_PAGE.test(new URL(obs.url).pathname);
@@ -249,7 +250,10 @@ export function detectAgentPurchase(doc, bodies) {
249
250
  const hit = find(text, ownDomain);
250
251
  if (!hit) continue;
251
252
  const near = text.slice(Math.max(0, hit.index - APPROVAL_WINDOW), hit.index + APPROVAL_WINDOW);
252
- hits.push({ rule, obs: obs.id, quote: quoteAround(text, hit.index), approval: Boolean(hit.approved) || APPROVAL.test(hit.sentence) || APPROVAL.test(near), call: hit.call, score: hit.score, protocol: hit.protocol || protocolIn(hit.sentence, text), agentFacing: AGENT_FACING.has(obs.role) });
253
+ // The sentence or labelled element holding the hit (src/interface/quote.js); null when the words run across two
254
+ // elements of the page, and then the hit has no quote.
255
+ const q = quoteAt(body, text, hit.index, hit.index + 1, obs.role);
256
+ hits.push({ rule, obs: obs.id, quote: q?.quote || null, ...(q?.label ? { label: q.label } : {}), approval: Boolean(hit.approved) || APPROVAL.test(hit.sentence) || APPROVAL.test(near), call: hit.call, score: hit.score, protocol: hit.protocol || protocolIn(hit.sentence, text), agentFacing: AGENT_FACING.has(obs.role) });
253
257
  }
254
258
  }
255
259
  if (!hits.length) return null;
@@ -265,6 +269,6 @@ export function detectAgentPurchase(doc, bodies) {
265
269
  approval: hits.some((h) => h.approval) ? "once" : "none_documented",
266
270
  call: ruled.find((h) => h.call)?.call || null,
267
271
  protocol: ruled.find((h) => h.protocol)?.protocol || null,
268
- evidence: ordered.filter((h) => h.rule === rule).concat(ordered.filter((h) => h.rule !== rule)).slice(0, EVIDENCE_MAX * 2).map(({ obs, quote }) => ({ obs, quote })),
272
+ evidence: ordered.filter((h) => h.rule === rule).concat(ordered.filter((h) => h.rule !== rule)).slice(0, EVIDENCE_MAX * 2).map(({ obs, quote, label }) => ({ obs, quote, ...(label ? { label } : {}) })),
269
273
  };
270
274
  }
@@ -0,0 +1,256 @@
1
+ // The quote shown under a step that says the product needs something. Kat reads every one, so a quote is one or two
2
+ // whole sentences from one element of the page (a paragraph, a list item, a table cell), never text run together from
3
+ // separate elements. Resend's pricing page flattened to "scale as you grow. Are you an AI agent? See pricing Add-ons if
4
+ // you send from a": a subtitle, a link and a heading read as one sentence. When the words sit in an element with no
5
+ // sentence (a heading, a table cell, a button, a line of code), the quote is that element's text with a label saying
6
+ // what it is: Heading: "Domains - $20 / mo".
7
+ import { NUMERIC_ENTITY, decodeNumeric } from "./sources.js";
8
+
9
+ // Elements that start a new block. Inline elements (a, span, strong, code) stay inside the sentence they are part of.
10
+ const BLOCK_TAG = /^<\/?\s*(address|article|aside|blockquote|br|button|caption|dd|details|dialog|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|img|input|label|legend|li|main|menu|nav|ol|option|p|pre|section|select|summary|svg|table|tbody|td|template|textarea|tfoot|th|thead|title|tr|ul)\b/i;
11
+ const KIND_OF_TAG = { td: "cell", th: "cell", li: "item", dt: "item", dd: "item", button: "button", option: "button", summary: "button", pre: "code", title: "heading" };
12
+ export const LABEL = { cell: "Table cell", heading: "Heading", item: "List item", button: "Button", code: "Code", line: "Line", paragraph: "Text", phrase: "Phrase" };
13
+ // An element's text with no sentence in it is quoted whole only up to this length; longer is a run of inline elements
14
+ // (a navigation bar, a footer), which is not a quote.
15
+ const PHRASE_MAX = 160;
16
+ // A sentence longer than this is a run of data (a JSON blob in a page, a minified list), not a sentence to quote.
17
+ const SENTENCE_MAX = 320;
18
+
19
+ function decode(s) {
20
+ return s.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(NUMERIC_ENTITY, decodeNumeric);
21
+ }
22
+
23
+ // Blocks of an HTML page, with offsets into htmlToText(html): the same steps in the same order, split where a block
24
+ // element starts or ends, so the blocks joined with one space are exactly htmlToText's output.
25
+ export function htmlBlocks(html) {
26
+ const src = html.replace(/<script[\s\S]*?<\/script>/gi, " ").replace(/<style[\s\S]*?<\/style>/gi, " ");
27
+ const segments = [];
28
+ let kind = "phrase";
29
+ let last = 0;
30
+ for (const m of src.matchAll(/<[^>]+>/g)) {
31
+ const tag = BLOCK_TAG.exec(m[0]);
32
+ if (!tag) continue;
33
+ segments.push({ kind, raw: src.slice(last, m.index) });
34
+ const name = tag[1].toLowerCase();
35
+ const closing = /^<\s*\//.test(m[0]);
36
+ kind = closing ? "phrase" : /^h[1-6]$/.test(name) ? "heading" : KIND_OF_TAG[name] || "phrase";
37
+ last = m.index + m[0].length;
38
+ }
39
+ segments.push({ kind, raw: src.slice(last) });
40
+ const blocks = [];
41
+ let offset = 0;
42
+ for (const seg of segments) {
43
+ const text = decode(seg.raw).replace(/\s+/g, " ").trim();
44
+ if (!text) continue;
45
+ blocks.push({ start: offset, end: offset + text.length, kind: seg.kind, raw: seg.raw });
46
+ offset += text.length + 1;
47
+ }
48
+ return blocks;
49
+ }
50
+
51
+ const FENCE = /^\s*(?:```|~~~)/;
52
+ const HEADING_LINE = /^\s{0,3}#{1,6}\s/;
53
+ const ITEM_LINE = /^\s*(?:[-*+]|\d+[.)])\s+/;
54
+ const TABLE_LINE = /^\s*\|/;
55
+ // A line that is only an HTML or MDX tag (`<Card title="Verify" href="...">`, `</Card>`) ends a block, like a blank line.
56
+ const TAG_LINE = /^\s*<\/?[A-Za-z][^>]*>\s*$/;
57
+ const INLINE_BLOCK_TAG = /<\/?\s*(?:td|th|tr|table|thead|tbody|p|li|ul|ol|div|h[1-6])\b[^>]*>/i;
58
+ const INLINE_BLOCK_TAG_NAME = /^<(\/)?\s*(td|th|tr|table|thead|tbody|p|li|ul|ol|div|h[1-6])\b/i;
59
+ const TAG_KIND = { td: "cell", th: "cell", li: "item", h1: "heading", h2: "heading", h3: "heading", h4: "heading", h5: "heading", h6: "heading" };
60
+ function pushPiece(blocks, line, start, a, b, kind) {
61
+ while (a < b && /\s/.test(line[a])) a += 1;
62
+ while (b > a && /\s/.test(line[b - 1])) b -= 1;
63
+ if (b > a) blocks.push({ start: start + a, end: start + b, kind });
64
+ }
65
+ // A shell-comment block in a text file (AgentMail's llms.txt): "#" alone is a blank line and "#" followed by two or
66
+ // more spaces is text, not a heading.
67
+ const COMMENT_BLANK = /^\s*#\s*$/;
68
+ const COMMENT_TEXT = /^\s*#\s{2,}\S/;
69
+
70
+ // Blocks of a Markdown or plain-text page, with offsets into the text: a paragraph (lines joined until a blank line),
71
+ // a heading, a list item with its wrapped lines, a table cell, a line of a code block. lineMode makes every line its
72
+ // own block, for YAML and other line-oriented files.
73
+ export function textBlocks(text, { lineMode = false } = {}) {
74
+ const blocks = [];
75
+ let open = null;
76
+ const close = () => {
77
+ if (open) blocks.push(open);
78
+ open = null;
79
+ };
80
+ let inFence = false;
81
+ let at = 0;
82
+ for (const line of text.split("\n")) {
83
+ const start = at;
84
+ const end = at + line.length;
85
+ at = end + 1;
86
+ const blank = !line.trim() || (!inFence && (COMMENT_BLANK.test(line) || TAG_LINE.test(line)));
87
+ if (FENCE.test(line)) {
88
+ close();
89
+ inFence = !inFence;
90
+ continue;
91
+ }
92
+ if (blank) {
93
+ close();
94
+ continue;
95
+ }
96
+ if (inFence || lineMode) {
97
+ close();
98
+ blocks.push({ start, end, kind: inFence ? "code" : "line" });
99
+ continue;
100
+ }
101
+ // HTML or MDX written inline in a text file (AgentID's llms-full.txt has a table as `<td className="...">Can the
102
+ // agent sign up ...</td> <td ...>`): each piece of text between block tags is its own block, as in an HTML page.
103
+ if (INLINE_BLOCK_TAG.test(line)) {
104
+ close();
105
+ let kind = "paragraph";
106
+ let from = 0;
107
+ for (const m of line.matchAll(/<[^>]+>/g)) {
108
+ pushPiece(blocks, line, start, from, m.index, kind);
109
+ const tag = INLINE_BLOCK_TAG_NAME.exec(m[0]);
110
+ if (tag) kind = tag[1] ? "paragraph" : TAG_KIND[tag[2].toLowerCase()] || "paragraph";
111
+ from = m.index + m[0].length;
112
+ }
113
+ pushPiece(blocks, line, start, from, line.length, kind);
114
+ continue;
115
+ }
116
+ if (HEADING_LINE.test(line) && !COMMENT_TEXT.test(line)) {
117
+ close();
118
+ blocks.push({ start, end, kind: "heading" });
119
+ continue;
120
+ }
121
+ if (TABLE_LINE.test(line)) {
122
+ close();
123
+ for (const cell of line.matchAll(/[^|]+/g)) if (cell[0].trim() && !/^[\s:-]+$/.test(cell[0])) blocks.push({ start: start + cell.index, end: start + cell.index + cell[0].length, kind: "cell" });
124
+ continue;
125
+ }
126
+ if (ITEM_LINE.test(line)) {
127
+ close();
128
+ open = { start, end, kind: "item" };
129
+ continue;
130
+ }
131
+ if (open) open.end = end;
132
+ else open = { start, end, kind: "paragraph" };
133
+ }
134
+ close();
135
+ return blocks;
136
+ }
137
+
138
+ const blockCache = new WeakMap();
139
+ // The page's blocks over the text the rules read (htmlToText for HTML, the body itself otherwise).
140
+ export function blocksOf(body, role) {
141
+ if (blockCache.has(body)) return blockCache.get(body);
142
+ const blocks = body.html ? htmlBlocks(body.text) : isJson(body.text) ? null : textBlocks(body.text, { lineMode: role === "openapi" || role === "sitemap" || role === "robots" });
143
+ blockCache.set(body, blocks);
144
+ return blocks;
145
+ }
146
+
147
+ const isJson = (text) => /^\s*[[{]/.test(text);
148
+
149
+ // Where one sentence ends inside a block: terminal punctuation, any closing quote, bracket or Markdown mark, then
150
+ // space and a character that can start a sentence. Not after an abbreviation ("e.g.", "vs.") or an initial.
151
+ const BOUNDARY = /[.!?]["'”’)\]*_`]*(?=\s+[^\sa-z])/g;
152
+ const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|incl|Inc|Ltd|Co|Mr|Mrs|Ms|Dr|St|No|U\.S)$/;
153
+ const TERMINAL = /[.!?]["'”’)\]*_`]*$/;
154
+
155
+ function sentencesIn(s) {
156
+ const out = [];
157
+ let from = 0;
158
+ for (const m of s.matchAll(BOUNDARY)) {
159
+ if (m[0][0] === "." && ABBREVIATION.test(s.slice(0, m.index))) continue;
160
+ const end = m.index + m[0].length;
161
+ out.push([from, end]);
162
+ from = end;
163
+ }
164
+ out.push([from, s.length]);
165
+ return out.map(([a, b]) => {
166
+ while (a < b && /\s/.test(s[a])) a += 1;
167
+ while (b > a && /\s/.test(s[b - 1])) b -= 1;
168
+ return [a, b];
169
+ }).filter(([a, b]) => b > a);
170
+ }
171
+
172
+ // Markdown marks at the start of a block are layout, not words: "- ", "## ", "> ", "1. ".
173
+ // A "#" that opens each line of a comment block is layout too.
174
+ const tidy = (s) => s.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)/, "").replace(/\n[ \t]*#(?=\s)/g, "\n").replace(/\s+/g, " ").trim();
175
+
176
+ // A div's text with no sentence in it is often several links or buttons side by side ("Start Sending Free View B2B
177
+ // Plans ($14/mo) Read the Docs"). Its phrase is then only the inline elements the words are in: split at every tag
178
+ // but comments, the same way htmlBlocks splits a page, so the pieces line up with the block's text.
179
+ function inlinePiece(raw, start, end) {
180
+ const pieces = [];
181
+ let offset = 0;
182
+ for (const part of raw.split(/<(?!!--)[^>]+>/)) {
183
+ const text = decode(part).replace(/\s+/g, " ").trim();
184
+ if (!text) continue;
185
+ pieces.push({ start: offset, end: offset + text.length, text });
186
+ offset += text.length + 1;
187
+ }
188
+ const hit = pieces.filter((p) => p.end > start && p.start < end);
189
+ return hit.length ? hit.map((p) => p.text).join(" ") : null;
190
+ }
191
+
192
+ // { quote, label? } for the words at [start, end) of a block's text, or null when they cross a sentence pair or the
193
+ // block is a long run with no sentence in it.
194
+ // Sentences are looked for only within WINDOW characters of the match: a 2 MB page on one line would otherwise be split
195
+ // end to end for every match. A sentence that reaches the window's edge is longer than any quote, so it is none.
196
+ const WINDOW = 1000;
197
+ function fromBlock(s, kind, start, end, raw = null) {
198
+ const lo = Math.max(0, start - WINDOW);
199
+ const hi = Math.min(s.length, end + WINDOW);
200
+ const near = s.slice(lo, hi);
201
+ const hit = sentencesIn(near).filter(([a, b]) => b > start - lo && a < end - lo);
202
+ if (!hit.length || hit.length > 2) return null;
203
+ const a = hit[0][0];
204
+ const b = hit.at(-1)[1];
205
+ if ((a === 0 && lo > 0) || (b === near.length && hi < s.length)) return null;
206
+ const piece = tidy(near.slice(a, b));
207
+ if (!piece) return null;
208
+ if (TERMINAL.test(piece) && kind !== "code" && kind !== "cell" && kind !== "heading") return piece.length <= SENTENCE_MAX ? { quote: piece } : null;
209
+ // A Markdown paragraph or list item is one element as its author wrote it, so it is quoted whole even without a full
210
+ // stop (llms.txt lines often have none). A div's text is cut to the inline elements the words are in.
211
+ const phrase = raw !== null && kind === "phrase" ? tidy(inlinePiece(raw, start, end) || "") : piece;
212
+ const max = kind === "paragraph" || kind === "item" ? SENTENCE_MAX : PHRASE_MAX;
213
+ if (!phrase || phrase.length > max) return null;
214
+ return { quote: phrase, label: LABEL[kind] || LABEL.phrase };
215
+ }
216
+
217
+ // A JSON string at [start, end): its text, labelled with its key (description, summary), or for a key itself, "Key"
218
+ // ("OpenAPI path" for an OpenAPI path).
219
+ function fromJson(text, start, end, role) {
220
+ let qs = start;
221
+ while (qs >= 0 && !(text[qs] === '"' && text[qs - 1] !== "\\")) qs -= 1;
222
+ let qe = end;
223
+ while (qe < text.length && !(text[qe] === '"' && text[qe - 1] !== "\\")) qe += 1;
224
+ if (qs < 0 || qe >= text.length || qe - qs > 2000) return null;
225
+ // Inside a string only when an odd number of unescaped quotes come before the opening one plus it.
226
+ const before = text.slice(0, qs + 1).match(/(?<!\\)"/g)?.length || 0;
227
+ if (before % 2 === 0) return null;
228
+ const raw = text.slice(qs + 1, qe).replace(/\\n/g, " ").replace(/\\"/g, '"').replace(/\\\//g, "/").replace(/\\\\/g, "\\");
229
+ const isKey = /^\s*:/.test(text.slice(qe + 1, qe + 20));
230
+ if (isKey) return raw.length <= PHRASE_MAX ? { quote: raw, label: role === "openapi" && raw.startsWith("/") ? "OpenAPI path" : "Key" } : null;
231
+ const key = /"([^"\\]{1,40})"\s*:\s*$/.exec(text.slice(Math.max(0, qs - 60), qs))?.[1] || "Value";
232
+ const inner = fromBlock(raw, "phrase", start - qs - 1, end - qs - 1);
233
+ return inner ? { quote: inner.quote, label: key } : null;
234
+ }
235
+
236
+ // The quote for a match at [start, end) of a page's text, or null when no clean quote holds it: the match runs across
237
+ // two elements of the page, or its element is a long run with no sentence. A caller with a null tries its next match.
238
+ export function quoteAt(body, text, start, end, role) {
239
+ if (!body.html && isJson(body.text)) return fromJson(text, start, end, role);
240
+ const blocks = blocksOf(body, role);
241
+ const block = blocks.find((b) => b.start <= start && end <= b.end);
242
+ if (!block) return null;
243
+ return fromBlock(text.slice(block.start, block.end), block.kind, start - block.start, end - block.start, block.raw ?? null);
244
+ }
245
+
246
+ // The block holding [start, end), and the heading above it on the page, for rules that read what a sentence is about.
247
+ export function blockContext(body, text, start, end, role) {
248
+ if (!body.html && isJson(body.text)) return null;
249
+ const blocks = blocksOf(body, role);
250
+ const i = blocks.findIndex((b) => b.start <= start && end <= b.end);
251
+ if (i < 0) return null;
252
+ let h = i - 1;
253
+ while (h >= 0 && blocks[h].kind !== "heading") h -= 1;
254
+ const slice = (b) => (b ? text.slice(b.start, b.end) : "");
255
+ return { block: slice(blocks[i]), heading: h >= 0 ? slice(blocks[h]) : "", previous: slice(blocks[i - 1]) };
256
+ }
@@ -23,8 +23,8 @@ export const SCHEMA_DOC = {
23
23
  authentication_requirement: "{ value: none|required|mixed|not_declared|unknown, reason, basedOn, rule, operations: [{ operation, value, reason, basedOn, rule, source: operation|global|absent, schemeNames }] }; operation security overrides global security; none requires an explicit operation [] or anonymous {} alternative; absent or empty global security is not_declared; unresolved declarations or incomplete operation coverage are unknown",
24
24
  pricing: "model, plans, observedPrices, agentCanDetermineCost, ambiguities, agentPurchase. agentPurchase is null or { rule: agent_buys_through_api|payment_required_approval|pay_per_request_own_api|checkout_protocol_own_api, rules, approval: once|none_documented, call, protocol: x402|MPP|ACP|AP2|UCP|null, evidence: [{ obs, quote }] }: a way for an agent to buy from the product through an API, as the product's own pages describe it (an agent buying with a purchase call, a cap or an approval; 402 payment_required with an approval link; x402 or MPP on a call to the product's own API; an ACP or UCP checkout session call on the product's own API, or a UCP business profile at /.well-known/ucp). protocol names the payment or checkout protocol the quoted sentence names, when it names one. approval once means a person approves first and the agent buys after that. A documented claim, never a result.",
25
25
  machineAccess: "robots, llms.txt, agent.json facts plus aiCrawlersAllowed, hasAgentReadableIndex and agentAuthDiscoverable",
26
- onboarding: "{ patterns: [{ id: try_then_claim|limited_until_claimed|agent_is_customer|agent_identity|existing_account|pay_per_request, name, status: documented|inferred, reason, evidence: [{ obs, quote }], humanBoundary, needs: [inbox|cli|wallet|agent_identity], needsEvidence?: { <need>: [{ obs, quote }] }, provider?: AgentID }], apiHosts: [{ domain, host, example, evidence }], primary, reason }. apiHosts are API URLs on other domains that the product's agent-facing pages point to. Ways an agent can start, as the public docs describe them; needs lists what testing that way end to end requires beyond HTTP requests, which a given deployment may or may not provide. provider names the identity provider an agent_identity pattern uses, when the docs name one the detector knows (AgentID). A pattern is a documented claim, never a result.",
27
- funnel: "{ headline, passed, stepsPassing, documented, verified, of, stopsAt, path, paths, steps: [{ id: discover|understand|signup|access|use|pay|manage, name, area: web|onboarding|monetization, question, state: agent_did|agent_can|handoff|needs_person|blocked|not_checked|not_applicable, reason, fix?, basedOn, quotes?, live?, evidence? }], applicable, fixes }. not_applicable: the product's machine-readable files say the step has nothing to do (Pay when every plan in pricing.json costs 0 and no purchase is documented; Manage when that is one plan); it neither passes nor stops the walk, and applicable counts the other steps. quotes: [{ for, obs, quote }], the sentences behind a claim that the product needs or requires something; a need with no quote is not reported. The seven steps an agent takes, from public evidence only. handoff means a person steps in at a documented point and the agent carries on, which counts as passing. agent_did appears only when the latest test run for that step on this product passed; a blocked test run sets blocked. live names that test. evidence lists, newest first, what else set or touched the step: { kind: test|pay|signal|stuck, at, reason, runId?, outcome?, count? }; a test or payment or the product's own signals can decide the state, a stuck report only attaches. documented counts agent_can and handoff steps, verified counts agent_did steps; the headline gives both and never calls a documented step verified. passed counts the passing steps before the first one that does not pass; stepsPassing counts every passing step (documented plus verified), the total the headline gives.",
26
+ onboarding: "{ patterns: [{ id: try_then_claim|limited_until_claimed|agent_is_customer|agent_identity|existing_account|pay_per_request, name, status: documented|inferred, reason, evidence: [{ obs, quote }], humanBoundary, needs: [inbox|cli|wallet|agent_identity], needsEvidence?: { <need>: [{ obs, quote, label? }] }, provider?: AgentID }], apiHosts: [{ domain, host, example, evidence }], signupElsewhere: { domain, url, evidence } | null, primary, reason }. apiHosts are API URLs on other domains that the product's agent-facing pages point to. signupElsewhere is a signup page on another company's site that those pages send the agent to; it is not read. Ways an agent can start, as the public docs describe them; needs lists what testing that way end to end requires beyond HTTP requests, which a given deployment may or may not provide. provider names the identity provider an agent_identity pattern uses, when the docs name one the detector knows (AgentID). A pattern is a documented claim, never a result.",
27
+ funnel: "{ headline, passed, stepsPassing, documented, verified, of, stopsAt, path, paths, steps: [{ id: discover|understand|signup|access|use|pay|manage, name, area: web|onboarding|monetization, question, state: agent_did|agent_can|handoff|needs_person|blocked|not_checked|not_applicable, reason, fix?, basedOn, quotes?, live?, evidence? }], applicable, fixes }. not_applicable: the product's machine-readable files say the step has nothing to do (Pay when every plan in pricing.json costs 0 and no purchase is documented; Manage when that is one plan); it neither passes nor stops the walk, and applicable counts the other steps. quotes: [{ for, obs, quote, label? }], the sentences behind a claim that the product needs or requires something; a need with no quote is not reported. quote is one or two whole sentences from one element of the page; label is set when the quote is an element that is not a sentence (Heading, Table cell, List item, Button, Code, Text, Phrase, OpenAPI path, pricing.json, or a JSON key such as description). The seven steps an agent takes, from public evidence only. handoff means a person steps in at a documented point and the agent carries on, which counts as passing. agent_did appears only when the latest test run for that step on this product passed; a blocked test run sets blocked. live names that test. evidence lists, newest first, what else set or touched the step: { kind: test|pay|signal|stuck, at, reason, runId?, outcome?, count? }; a test or payment or the product's own signals can decide the state, a stuck report only attaches. documented counts agent_can and handoff steps, verified counts agent_did steps; the headline gives both and never calls a documented step verified. passed counts the passing steps before the first one that does not pass; stepsPassing counts every passing step (documented plus verified), the total the headline gives.",
28
28
  unknowns: "every field whose verdict is unknown or whose fact is null, with the reason",
29
29
  limits: "what this run could not do (no JS rendering, no login, no model interpretation)",
30
30
  },
@@ -157,6 +157,14 @@ export function roleForLink({ href }) {
157
157
  return null;
158
158
  }
159
159
 
160
+ // "&#8217;" and "&#x2019;" (x402.org writes "the Internet&#8217;s original sin"). src/interface/quote.js decodes the same
161
+ // way, so its block offsets line up with this text.
162
+ export const NUMERIC_ENTITY = /&#(?:(\d{1,7})|x([0-9a-f]{1,6}));/gi;
163
+ export function decodeNumeric(whole, dec, hex) {
164
+ const n = dec ? Number(dec) : parseInt(hex, 16);
165
+ return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : whole;
166
+ }
167
+
160
168
  export function htmlToText(html) {
161
169
  return html
162
170
  .replace(/<script[\s\S]*?<\/script>/gi, " ")
@@ -168,6 +176,7 @@ export function htmlToText(html) {
168
176
  .replace(/&gt;/g, ">")
169
177
  .replace(/&quot;/g, '"')
170
178
  .replace(/&#39;/g, "'")
179
+ .replace(NUMERIC_ENTITY, decodeNumeric)
171
180
  .replace(/\s+/g, " ")
172
181
  .trim();
173
182
  }