agent-led-growth 0.8.4 → 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
|
+
"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,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.8.4 (2026-10-24)
|
|
4
14
|
|
|
5
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.
|
|
@@ -10,7 +20,8 @@ Quotes you can check. 0.8.3 printed each requirement with the text it came from,
|
|
|
10
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.
|
|
11
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`.
|
|
12
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=...>`.
|
|
13
|
-
- 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.
|
|
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.
|
|
14
25
|
- JSON: quotes can carry `label` (`steps[].quotes[]`, `needsEvidence`, pattern `evidence`); `observedPrices[]` can carry `paidQuote`. Nothing is removed.
|
|
15
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.
|
|
16
27
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-led-growth",
|
|
3
|
-
"version": "0.8.
|
|
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": {
|
package/src/interface/extract.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseRobots, AI_BOTS } from "../probe/index.js";
|
|
2
2
|
import { htmlToText } from "./sources.js";
|
|
3
|
-
import { quoteAt } from "./quote.js";
|
|
3
|
+
import { blocksOf, quoteAt } from "./quote.js";
|
|
4
4
|
|
|
5
5
|
// Deterministic extraction. Every fact carries the observation it came from and a quote from that page.
|
|
6
6
|
// A fact that cannot be extracted is null; callers turn nulls into unknowns. No inference happens here.
|
|
@@ -177,16 +177,48 @@ const AMBIGUITY_RE = /contact (?:us|sales)|custom pricing|talk to sales|get a qu
|
|
|
177
177
|
// limit: Track's "4 KB per request", "What it sends, per request".
|
|
178
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;
|
|
179
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
|
+
|
|
180
206
|
function paidPriceQuote(page, text) {
|
|
181
207
|
let tried = 0;
|
|
208
|
+
let fallback = null;
|
|
182
209
|
for (const m of text.matchAll(PRICE_RE)) {
|
|
183
210
|
// "$75M Series B" is a funding round, not a plan.
|
|
184
211
|
if (!/[1-9]/.test(m[0]) || /^[MB]\b/i.test(text.slice(m.index + m[0].length))) continue;
|
|
185
212
|
const q = quoteAt(page, text, m.index, m.index + m[0].length, page.obs.role);
|
|
186
|
-
if (q)
|
|
187
|
-
|
|
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;
|
|
188
220
|
}
|
|
189
|
-
return
|
|
221
|
+
return fallback;
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
export function extractPricingPages(observations, bodies) {
|
package/src/interface/funnel.js
CHANGED
|
@@ -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
|
|
|
@@ -166,7 +166,7 @@ function pay(doc) {
|
|
|
166
166
|
// A purchase the product's own pages document (src/interface/purchase.js). One approval by a person, then the agent
|
|
167
167
|
// buys on its own, is a handoff: a person steps in at a documented point and the agent carries on. It is not
|
|
168
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).slice(0, 1).map((e) => ({ for: what, obs: e.obs, quote: e.quote, ...(e.label ? { label: e.label } : {}) }));
|
|
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 } : {}) }));
|
|
170
170
|
const purchase = doc.pricing?.agentPurchase;
|
|
171
171
|
const purchaseRefs = (purchase?.evidence || []).map((e) => e.obs);
|
|
172
172
|
const purchaseQuotes = quote(purchase?.evidence, "payment");
|
|
@@ -354,6 +354,10 @@ function inSlug(sentence, match) {
|
|
|
354
354
|
return re.test(sentence) && !sentence.includes(` ${match} `) && !sentence.startsWith(`${match} `);
|
|
355
355
|
}
|
|
356
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
|
+
|
|
357
361
|
// Whether a sentence holding a match is about something else; such a match is skipped and the next one tried.
|
|
358
362
|
// page: { role, names, context() } for the rules that read where the sentence is (the inbox rule).
|
|
359
363
|
const SKIP = {
|
|
@@ -363,7 +367,7 @@ const SKIP = {
|
|
|
363
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),
|
|
364
368
|
humanFirst: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || EXAMPLE.test(sentence) || negatedBefore(sentence, match),
|
|
365
369
|
httpBootstrap: (sentence, ownDomain, match) => DOMAIN_ACTORS.test(sentence) || OAUTH_REGISTRATION.test(match),
|
|
366
|
-
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),
|
|
367
371
|
};
|
|
368
372
|
const GLOBAL = Object.fromEntries(Object.keys(SIGNALS).map((name) => [name, new RegExp(SIGNALS[name].source, `${SIGNALS[name].flags.replace("g", "")}g`)]));
|
|
369
373
|
|
|
@@ -372,16 +376,33 @@ const GLOBAL = Object.fromEntries(Object.keys(SIGNALS).map((name) => [name, new
|
|
|
372
376
|
// page. Tries at most MAX_TRIED matches a page, so a page that repeats a skipped phrase thousands of times costs no
|
|
373
377
|
// more than one that says it once.
|
|
374
378
|
const MAX_TRIED = 200;
|
|
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
|
+
}
|
|
375
391
|
function firstKept(page, re, skip, ownDomain) {
|
|
376
392
|
let tried = 0;
|
|
393
|
+
let weak = null;
|
|
377
394
|
for (const m of page.text.matchAll(re)) {
|
|
378
395
|
const q = quoteAt(page.body, page.text, m.index, m.index + m[0].length, page.role);
|
|
379
396
|
const at = { ...page, context: () => blockContext(page.body, page.text, m.index, m.index + m[0].length, page.role) };
|
|
380
|
-
if (q && !(skip && skip(q.quote, ownDomain, m[0], at, q.label)))
|
|
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.
|
|
381
402
|
tried += 1;
|
|
382
|
-
if (tried >= MAX_TRIED)
|
|
403
|
+
if (tried >= MAX_TRIED) break;
|
|
383
404
|
}
|
|
384
|
-
return
|
|
405
|
+
return weak;
|
|
385
406
|
}
|
|
386
407
|
|
|
387
408
|
function findSignals(observations, bodies, targetUrl, names) {
|
|
@@ -444,7 +465,8 @@ function pattern(id, found, signalNames, reason) {
|
|
|
444
465
|
// The product's agent-facing pages first: a quote from llms.txt or an agent-signup
|
|
445
466
|
// page says more than the same words in homepage navigation.
|
|
446
467
|
const entries = signalNames.flatMap((name) => found[name]);
|
|
447
|
-
const
|
|
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 } : {}) }));
|
|
448
470
|
const documented = signalNames.some((name) => found[name].some((entry) => entry.agentFacing));
|
|
449
471
|
return {
|
|
450
472
|
id,
|
package/src/interface/quote.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
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
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
6
|
// what it is: Heading: "Domains - $20 / mo".
|
|
7
|
+
import { NUMERIC_ENTITY, decodeNumeric } from "./sources.js";
|
|
7
8
|
|
|
8
9
|
// Elements that start a new block. Inline elements (a, span, strong, code) stay inside the sentence they are part of.
|
|
9
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;
|
|
@@ -16,7 +17,7 @@ const PHRASE_MAX = 160;
|
|
|
16
17
|
const SENTENCE_MAX = 320;
|
|
17
18
|
|
|
18
19
|
function decode(s) {
|
|
19
|
-
return s.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
|
|
20
|
+
return s.replace(/<[^>]+>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(NUMERIC_ENTITY, decodeNumeric);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// Blocks of an HTML page, with offsets into htmlToText(html): the same steps in the same order, split where a block
|
package/src/interface/sources.js
CHANGED
|
@@ -157,6 +157,14 @@ export function roleForLink({ href }) {
|
|
|
157
157
|
return null;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// "’" and "’" (x402.org writes "the Internet’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(/>/g, ">")
|
|
169
177
|
.replace(/"/g, '"')
|
|
170
178
|
.replace(/'/g, "'")
|
|
179
|
+
.replace(NUMERIC_ENTITY, decodeNumeric)
|
|
171
180
|
.replace(/\s+/g, " ")
|
|
172
181
|
.trim();
|
|
173
182
|
}
|