@relvehq/agent-ready 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +364 -3
- package/SETUP.md +354 -0
- package/dist/bin/agent-ready.d.ts +23 -0
- package/dist/bin/agent-ready.js +444 -0
- package/dist/cli/init.d.ts +70 -0
- package/dist/cli/init.js +366 -0
- package/dist/core/accept.d.ts +8 -0
- package/dist/core/accept.js +76 -0
- package/dist/core/config.d.ts +145 -0
- package/dist/core/config.js +103 -0
- package/dist/core/convert.d.ts +22 -0
- package/dist/core/convert.js +334 -0
- package/dist/core/detect.d.ts +30 -0
- package/dist/core/detect.js +82 -0
- package/dist/core/easy.d.ts +19 -0
- package/dist/core/easy.js +117 -0
- package/dist/core/enrich.d.ts +30 -0
- package/dist/core/enrich.js +155 -0
- package/dist/core/llms.d.ts +11 -0
- package/dist/core/llms.js +71 -0
- package/dist/core/report.d.ts +15 -0
- package/dist/core/report.js +46 -0
- package/dist/core/resolve.d.ts +43 -0
- package/dist/core/resolve.js +204 -0
- package/dist/core/respond.d.ts +18 -0
- package/dist/core/respond.js +59 -0
- package/dist/core/sitemap.d.ts +31 -0
- package/dist/core/sitemap.js +140 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +76 -0
- package/dist/next/dashboard.d.ts +14 -0
- package/dist/next/dashboard.js +56 -0
- package/dist/next/index.d.ts +42 -0
- package/dist/next/index.js +100 -0
- package/dist/next/route.d.ts +26 -0
- package/dist/next/route.js +167 -0
- package/dist/node/index.d.ts +8 -0
- package/dist/node/index.js +191 -0
- package/dist/proof/audit.d.ts +47 -0
- package/dist/proof/audit.js +253 -0
- package/dist/proof/bots.d.ts +24 -0
- package/dist/proof/bots.js +60 -0
- package/dist/proof/dashboard.d.ts +8 -0
- package/dist/proof/dashboard.js +77 -0
- package/dist/proof/hits.d.ts +18 -0
- package/dist/proof/hits.js +51 -0
- package/dist/proof/jsonl.d.ts +5 -0
- package/dist/proof/jsonl.js +80 -0
- package/dist/proof/stats.d.ts +39 -0
- package/dist/proof/stats.js +127 -0
- package/dist/refine/check.d.ts +17 -0
- package/dist/refine/check.js +134 -0
- package/dist/refine/extract.d.ts +7 -0
- package/dist/refine/extract.js +120 -0
- package/dist/refine/file-store.d.ts +6 -0
- package/dist/refine/file-store.js +87 -0
- package/dist/refine/model.d.ts +30 -0
- package/dist/refine/model.js +105 -0
- package/dist/refine/prompts.d.ts +49 -0
- package/dist/refine/prompts.js +178 -0
- package/dist/refine/refine.d.ts +38 -0
- package/dist/refine/refine.js +75 -0
- package/dist/refine/remote.d.ts +57 -0
- package/dist/refine/remote.js +238 -0
- package/dist/refine/run.d.ts +52 -0
- package/dist/refine/run.js +66 -0
- package/dist/refine/types.d.ts +17 -0
- package/dist/refine/types.js +8 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-admin.php +550 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-converter.php +762 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-llms.php +192 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-markdown-builder.php +455 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-negotiation.php +334 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-refiner.php +894 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-response.php +226 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-router.php +421 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-settings.php +201 -0
- package/dist/wp/relve-agent-ready/includes/class-relve-ar-store.php +583 -0
- package/dist/wp/relve-agent-ready/includes/index.php +2 -0
- package/dist/wp/relve-agent-ready/index.php +2 -0
- package/dist/wp/relve-agent-ready/readme.txt +108 -0
- package/dist/wp/relve-agent-ready/relve-agent-ready.php +81 -0
- package/dist/wp/relve-agent-ready/uninstall.php +35 -0
- package/package.json +64 -6
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Proof: the free checker. "How agent-ready is this site?" for any URL,
|
|
3
|
+
// installed or not. Read-only GETs, the way an agent would ask:
|
|
4
|
+
//
|
|
5
|
+
// page the HTML loads
|
|
6
|
+
// accept Accept: text/markdown gets Markdown (25)
|
|
7
|
+
// md-url <page>.md gets Markdown (15)
|
|
8
|
+
// llms /llms.txt exists and looks like one (15)
|
|
9
|
+
// robots robots.txt lets the AI answer bots in (15)
|
|
10
|
+
// sitemap /sitemap.xml lists pages (10)
|
|
11
|
+
// json-ld the page has structured data (10)
|
|
12
|
+
// vary Markdown is sent with Vary: Accept (5)
|
|
13
|
+
// canonical the page (or its Markdown) names a canonical URL (5)
|
|
14
|
+
//
|
|
15
|
+
// Score = points earned / 100 (a warning earns half). Plus the token count of
|
|
16
|
+
// the HTML vs the Markdown an agent got, or could get (when `convert` is
|
|
17
|
+
// passed: the CLI passes the package's converter). EDGE-SAFE unless `convert`
|
|
18
|
+
// is Node-only (the caller decides).
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.auditSite = auditSite;
|
|
21
|
+
exports.blockedBots = blockedBots;
|
|
22
|
+
exports.formatAudit = formatAudit;
|
|
23
|
+
const enrich_1 = require("../core/enrich");
|
|
24
|
+
const sitemap_1 = require("../core/sitemap");
|
|
25
|
+
const bots_1 = require("./bots");
|
|
26
|
+
const UA = "agent-ready-check/0.1 (+https://www.npmjs.com/package/@relvehq/agent-ready)";
|
|
27
|
+
async function auditSite(rawUrl, opts = {}) {
|
|
28
|
+
const f = opts.fetch ?? fetch;
|
|
29
|
+
const url = normalizeUrl(rawUrl);
|
|
30
|
+
const report = {
|
|
31
|
+
url,
|
|
32
|
+
checkedAt: (opts.now ?? (() => new Date()))().toISOString(),
|
|
33
|
+
ok: false,
|
|
34
|
+
error: null,
|
|
35
|
+
score: 0,
|
|
36
|
+
grade: "F",
|
|
37
|
+
checks: [],
|
|
38
|
+
tokens: { html: null, served: null, converted: null },
|
|
39
|
+
};
|
|
40
|
+
const get = (u, accept) => timed(f, u, accept, opts.timeoutMs ?? 10000);
|
|
41
|
+
const u = new URL(url);
|
|
42
|
+
const origin = u.origin;
|
|
43
|
+
const add = (c) => report.checks.push(c);
|
|
44
|
+
// The page itself.
|
|
45
|
+
const page = await get(url, "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8");
|
|
46
|
+
if (!page || !page.ok) {
|
|
47
|
+
report.error = page ? `The page answered HTTP ${page.status}.` : "The page could not be reached.";
|
|
48
|
+
add({ id: "page", label: "Page loads", status: "fail", points: 0, detail: report.error, fix: "Check the URL; the checker needs the page to load." });
|
|
49
|
+
return finish(report);
|
|
50
|
+
}
|
|
51
|
+
report.ok = true;
|
|
52
|
+
report.tokens.html = (0, enrich_1.estimateTokens)(page.body);
|
|
53
|
+
add({ id: "page", label: "Page loads", status: "pass", points: 0, detail: `HTTP ${page.status}, ${fmt(report.tokens.html)} tokens of HTML.`, fix: "" });
|
|
54
|
+
// Markdown by Accept, and by .md URL.
|
|
55
|
+
const viaAccept = await get(url, "text/markdown, text/html;q=0.5");
|
|
56
|
+
const acceptMd = !!viaAccept?.ok && isMarkdown(viaAccept.type);
|
|
57
|
+
add({
|
|
58
|
+
id: "accept",
|
|
59
|
+
label: "Answers Accept: text/markdown",
|
|
60
|
+
status: acceptMd ? "pass" : "fail",
|
|
61
|
+
points: 25,
|
|
62
|
+
detail: acceptMd ? `Markdown, ${fmt((0, enrich_1.estimateTokens)(viaAccept.body))} tokens.` : `Got ${viaAccept ? viaAccept.type || "no content type" : "no answer"}.`,
|
|
63
|
+
fix: acceptMd ? "" : "Serve Markdown when an agent asks for it: `npx agent-ready init`.",
|
|
64
|
+
});
|
|
65
|
+
const mdUrl = origin + (u.pathname === "/" ? "/index.md" : u.pathname.replace(/\/+$/, "") + ".md");
|
|
66
|
+
const viaMd = await get(mdUrl, "text/markdown, text/plain;q=0.9, */*;q=0.1");
|
|
67
|
+
const suffixMd = !!viaMd?.ok && isMarkdown(viaMd.type);
|
|
68
|
+
add({
|
|
69
|
+
id: "md-url",
|
|
70
|
+
label: "Has a .md version",
|
|
71
|
+
status: suffixMd ? "pass" : "fail",
|
|
72
|
+
points: 15,
|
|
73
|
+
detail: suffixMd ? `${mdUrl} answers with Markdown.` : `${mdUrl} answered ${viaMd ? `HTTP ${viaMd.status}` : "nothing"}.`,
|
|
74
|
+
fix: suffixMd ? "" : "Serve <page>.md as Markdown (the package does this).",
|
|
75
|
+
});
|
|
76
|
+
const md = acceptMd ? viaAccept : suffixMd ? viaMd : null;
|
|
77
|
+
if (md)
|
|
78
|
+
report.tokens.served = (0, enrich_1.estimateTokens)(md.body);
|
|
79
|
+
// llms.txt
|
|
80
|
+
const llms = await get(origin + "/llms.txt", "text/plain, text/markdown;q=0.9, */*;q=0.1");
|
|
81
|
+
const llmsOk = !!llms?.ok && /^\s*#\s+\S/.test(llms.body) && !/^\s*<(!doctype|html)/i.test(llms.body);
|
|
82
|
+
const llmsLinks = llmsOk ? (llms.body.match(/\]\(https?:\/\/[^)]+\)/g) ?? []).length : 0;
|
|
83
|
+
add({
|
|
84
|
+
id: "llms",
|
|
85
|
+
label: "Publishes /llms.txt",
|
|
86
|
+
status: llmsOk ? (llmsLinks ? "pass" : "warn") : "fail",
|
|
87
|
+
points: 15,
|
|
88
|
+
detail: llmsOk ? `${llmsLinks} links listed.` : llms?.ok ? "/llms.txt exists but isn't an llms.txt file." : "No /llms.txt.",
|
|
89
|
+
fix: llmsOk && llmsLinks ? "" : "Publish /llms.txt (the package builds one from your sitemap).",
|
|
90
|
+
});
|
|
91
|
+
// robots.txt
|
|
92
|
+
const robots = await get(origin + "/robots.txt", "text/plain");
|
|
93
|
+
const blocked = robots?.ok ? blockedBots(robots.body, bots_1.ANSWER_BOT_TOKENS) : [];
|
|
94
|
+
add({
|
|
95
|
+
id: "robots",
|
|
96
|
+
label: "Lets AI answer bots in",
|
|
97
|
+
status: !robots?.ok ? "warn" : blocked.length ? "fail" : "pass",
|
|
98
|
+
points: 15,
|
|
99
|
+
detail: !robots?.ok ? "No robots.txt (everything is allowed by default)." : blocked.length ? `Blocked: ${blocked.join(", ")}.` : `Allowed: ${bots_1.ANSWER_BOT_TOKENS.join(", ")}.`,
|
|
100
|
+
fix: blocked.length ? "Allow the bots that cite pages in answers (search and user bots), even if you block training bots." : !robots?.ok ? "Add a robots.txt that names your sitemap." : "",
|
|
101
|
+
});
|
|
102
|
+
// sitemap
|
|
103
|
+
const smPath = robots?.ok ? /^\s*sitemap:\s*(\S+)/im.exec(robots.body)?.[1] : undefined;
|
|
104
|
+
const sm = await get(smPath ?? origin + "/sitemap.xml", "application/xml, text/xml;q=0.9, */*;q=0.1");
|
|
105
|
+
const parsed = sm?.ok ? (0, sitemap_1.parseSitemap)(sm.body) : { urls: [], sitemaps: [] };
|
|
106
|
+
const smCount = parsed.urls.length + parsed.sitemaps.length;
|
|
107
|
+
add({
|
|
108
|
+
id: "sitemap",
|
|
109
|
+
label: "Has a sitemap",
|
|
110
|
+
status: smCount ? "pass" : "fail",
|
|
111
|
+
points: 10,
|
|
112
|
+
detail: smCount ? `${parsed.urls.length} pages${parsed.sitemaps.length ? `, ${parsed.sitemaps.length} child sitemaps` : ""}.` : "No sitemap found.",
|
|
113
|
+
fix: smCount ? "" : "Publish /sitemap.xml so agents (and the llms.txt builder) can find every page.",
|
|
114
|
+
});
|
|
115
|
+
// Structured data and canonical, from the HTML.
|
|
116
|
+
const ldBlocks = (page.body.match(/<script[^>]+type=["']application\/ld\+json["']/gi) ?? []).length;
|
|
117
|
+
add({
|
|
118
|
+
id: "json-ld",
|
|
119
|
+
label: "Has structured data (JSON-LD)",
|
|
120
|
+
status: ldBlocks ? "pass" : "warn",
|
|
121
|
+
points: 10,
|
|
122
|
+
detail: ldBlocks ? `${ldBlocks} JSON-LD blocks.` : "No JSON-LD on the page.",
|
|
123
|
+
fix: ldBlocks ? "" : "Add schema.org JSON-LD (Organization, Product, Article, FAQPage…): agents read it for exact facts.",
|
|
124
|
+
});
|
|
125
|
+
if (md) {
|
|
126
|
+
const vary = /\baccept\b/i.test(md.vary);
|
|
127
|
+
add({
|
|
128
|
+
id: "vary",
|
|
129
|
+
label: "Markdown sent with Vary: Accept",
|
|
130
|
+
status: vary ? "pass" : "warn",
|
|
131
|
+
points: 5,
|
|
132
|
+
detail: vary ? "Caches keep HTML and Markdown apart." : "No Vary: Accept on the Markdown.",
|
|
133
|
+
fix: vary ? "" : "Send Vary: Accept so a cache never gives a browser the Markdown.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
add({ id: "vary", label: "Markdown sent with Vary: Accept", status: "fail", points: 5, detail: "No Markdown served.", fix: "Comes with serving Markdown." });
|
|
138
|
+
}
|
|
139
|
+
const canonical = /<link[^>]+rel=["']canonical["']/i.test(page.body) || (md ? /rel="?canonical/i.test(md.link) : false);
|
|
140
|
+
add({
|
|
141
|
+
id: "canonical",
|
|
142
|
+
label: "Names a canonical URL",
|
|
143
|
+
status: canonical ? "pass" : "warn",
|
|
144
|
+
points: 5,
|
|
145
|
+
detail: canonical ? "Agents can cite the right address." : "No canonical link.",
|
|
146
|
+
fix: canonical ? "" : "Add <link rel=\"canonical\"> so answers cite the right URL.",
|
|
147
|
+
});
|
|
148
|
+
if (opts.convert) {
|
|
149
|
+
try {
|
|
150
|
+
const c = opts.convert(page.body, { url });
|
|
151
|
+
if (c?.markdown)
|
|
152
|
+
report.tokens.converted = (0, enrich_1.estimateTokens)(c.markdown);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// The estimate is a bonus; never fail the check over it.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return finish(report);
|
|
159
|
+
}
|
|
160
|
+
function finish(r) {
|
|
161
|
+
const total = r.checks.reduce((s, c) => s + c.points, 0);
|
|
162
|
+
const earned = r.checks.reduce((s, c) => s + (c.status === "pass" ? c.points : c.status === "warn" ? c.points / 2 : 0), 0);
|
|
163
|
+
r.score = total ? Math.round((earned / total) * 100) : 0;
|
|
164
|
+
r.grade = r.score >= 90 ? "A" : r.score >= 75 ? "B" : r.score >= 55 ? "C" : r.score >= 35 ? "D" : "F";
|
|
165
|
+
return r;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Bots from `tokens` that robots.txt shuts out of the whole site
|
|
169
|
+
* ("Disallow: /" in the group that applies to them, with no "Allow: /").
|
|
170
|
+
* A bot with its own group follows only that group; otherwise "*".
|
|
171
|
+
*/
|
|
172
|
+
function blockedBots(robots, tokens) {
|
|
173
|
+
const groups = [];
|
|
174
|
+
let cur = null;
|
|
175
|
+
for (const raw of robots.split(/\r?\n/)) {
|
|
176
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
177
|
+
const m = /^([A-Za-z-]+)\s*:\s*(.*)$/.exec(line);
|
|
178
|
+
if (!m)
|
|
179
|
+
continue;
|
|
180
|
+
const field = m[1].toLowerCase();
|
|
181
|
+
const value = m[2].trim();
|
|
182
|
+
if (field === "user-agent") {
|
|
183
|
+
if (!cur || cur.rules.length)
|
|
184
|
+
groups.push((cur = { agents: [], rules: [] }));
|
|
185
|
+
cur.agents.push(value.toLowerCase());
|
|
186
|
+
}
|
|
187
|
+
else if ((field === "disallow" || field === "allow") && cur) {
|
|
188
|
+
cur.rules.push(`${field}:${value}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const blocks = (rules) => rules.includes("disallow:/") && !rules.includes("allow:/");
|
|
192
|
+
const star = groups.filter((g) => g.agents.includes("*")).flatMap((g) => g.rules);
|
|
193
|
+
return tokens.filter((t) => {
|
|
194
|
+
const own = groups.filter((g) => g.agents.includes(t.toLowerCase())).flatMap((g) => g.rules);
|
|
195
|
+
return blocks(own.length ? own : star);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/** The report as terminal lines (`agent-ready check`). */
|
|
199
|
+
function formatAudit(r) {
|
|
200
|
+
const mark = { pass: "PASS", warn: "WARN", fail: "FAIL" };
|
|
201
|
+
const lines = [`agent-ready check ${r.url}`, ""];
|
|
202
|
+
if (r.error)
|
|
203
|
+
return [...lines, ` ${r.error}`].join("\n");
|
|
204
|
+
lines.push(` Score ${r.score}/100 (grade ${r.grade})`, "");
|
|
205
|
+
for (const c of r.checks.filter((c) => c.id !== "page")) {
|
|
206
|
+
lines.push(` ${mark[c.status]} ${c.label} ${c.detail}`);
|
|
207
|
+
if (c.fix)
|
|
208
|
+
lines.push(` -> ${c.fix}`);
|
|
209
|
+
}
|
|
210
|
+
const t = r.tokens;
|
|
211
|
+
lines.push("", ` An agent reading this page today: ${fmt(t.served ?? t.html ?? 0)} tokens${t.served ? " (Markdown)" : " (HTML)"}.`);
|
|
212
|
+
if (!t.served && t.converted && t.html) {
|
|
213
|
+
lines.push(` With agent-ready: about ${fmt(t.converted)} tokens (${Math.round((1 - t.converted / t.html) * 100)}% less), before custom files.`);
|
|
214
|
+
}
|
|
215
|
+
else if (t.served && t.html) {
|
|
216
|
+
lines.push(` Markdown is ${Math.round((1 - t.served / t.html) * 100)}% smaller than the HTML (${fmt(t.html)} tokens).`);
|
|
217
|
+
}
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
async function timed(f, url, accept, ms) {
|
|
221
|
+
const ctl = new AbortController();
|
|
222
|
+
const timer = setTimeout(() => ctl.abort(), ms);
|
|
223
|
+
try {
|
|
224
|
+
const res = await f(url, { headers: { accept, "user-agent": UA }, redirect: "follow", signal: ctl.signal });
|
|
225
|
+
const body = await res.text();
|
|
226
|
+
return {
|
|
227
|
+
ok: res.ok,
|
|
228
|
+
status: res.status,
|
|
229
|
+
type: res.headers.get("content-type") ?? "",
|
|
230
|
+
vary: res.headers.get("vary") ?? "",
|
|
231
|
+
link: res.headers.get("link") ?? "",
|
|
232
|
+
body: body.slice(0, 5000000),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function isMarkdown(type) {
|
|
243
|
+
return /text\/(markdown|x-markdown)/i.test(type);
|
|
244
|
+
}
|
|
245
|
+
function normalizeUrl(raw) {
|
|
246
|
+
const withScheme = /^https?:\/\//i.test(raw.trim()) ? raw.trim() : `https://${raw.trim()}`;
|
|
247
|
+
const u = new URL(withScheme);
|
|
248
|
+
u.hash = "";
|
|
249
|
+
return u.href;
|
|
250
|
+
}
|
|
251
|
+
function fmt(x) {
|
|
252
|
+
return x.toLocaleString("en-US");
|
|
253
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type BotKind =
|
|
2
|
+
/** Fetches pages to answer one person's question right now. */
|
|
3
|
+
"ai_assistant"
|
|
4
|
+
/** Builds an AI search index (answers cite the page). */
|
|
5
|
+
| "ai_search"
|
|
6
|
+
/** Collects training data. */
|
|
7
|
+
| "ai_training"
|
|
8
|
+
/** Classic search engine. */
|
|
9
|
+
| "search"
|
|
10
|
+
/** A script or HTTP library (curl, python-requests…). */
|
|
11
|
+
| "tool"
|
|
12
|
+
/** Anything else, including browsers. */
|
|
13
|
+
| "unknown";
|
|
14
|
+
export interface Bot {
|
|
15
|
+
id: string;
|
|
16
|
+
label: string;
|
|
17
|
+
operator: string;
|
|
18
|
+
kind: BotKind;
|
|
19
|
+
}
|
|
20
|
+
export declare function classifyAgent(userAgent: string | null | undefined): Bot;
|
|
21
|
+
/** True for the bots that read pages FOR an AI answer (assistants and AI search). */
|
|
22
|
+
export declare function isAnswerBot(bot: Bot): boolean;
|
|
23
|
+
/** The robots.txt tokens the checker looks at: the bots that cite pages in answers. */
|
|
24
|
+
export declare const ANSWER_BOT_TOKENS: string[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Proof: who is reading? A user agent -> a known AI bot (or not).
|
|
3
|
+
// Used only to REPORT on visits, never to choose what to serve (that would be
|
|
4
|
+
// cloaking; serving is decided by the request: Accept, .md, ?format=md).
|
|
5
|
+
//
|
|
6
|
+
// Matching is by case-insensitive substring, in list order: specific names
|
|
7
|
+
// come before broader ones ("Claude-SearchBot" before "ClaudeBot",
|
|
8
|
+
// "Perplexity-User" before "PerplexityBot", "Applebot-Extended" before "Applebot").
|
|
9
|
+
// EDGE-SAFE (pure).
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ANSWER_BOT_TOKENS = void 0;
|
|
12
|
+
exports.classifyAgent = classifyAgent;
|
|
13
|
+
exports.isAnswerBot = isAnswerBot;
|
|
14
|
+
const BOTS = [
|
|
15
|
+
{ id: "chatgpt-user", label: "ChatGPT-User", operator: "OpenAI", kind: "ai_assistant", needle: "chatgpt-user" },
|
|
16
|
+
{ id: "oai-searchbot", label: "OAI-SearchBot", operator: "OpenAI", kind: "ai_search", needle: "oai-searchbot" },
|
|
17
|
+
{ id: "gptbot", label: "GPTBot", operator: "OpenAI", kind: "ai_training", needle: "gptbot" },
|
|
18
|
+
{ id: "claude-user", label: "Claude-User", operator: "Anthropic", kind: "ai_assistant", needle: "claude-user" },
|
|
19
|
+
{ id: "claude-searchbot", label: "Claude-SearchBot", operator: "Anthropic", kind: "ai_search", needle: "claude-searchbot" },
|
|
20
|
+
{ id: "claudebot", label: "ClaudeBot", operator: "Anthropic", kind: "ai_training", needle: "claudebot" },
|
|
21
|
+
{ id: "anthropic-ai", label: "anthropic-ai", operator: "Anthropic", kind: "ai_training", needle: "anthropic-ai" },
|
|
22
|
+
{ id: "perplexity-user", label: "Perplexity-User", operator: "Perplexity", kind: "ai_assistant", needle: "perplexity-user" },
|
|
23
|
+
{ id: "perplexitybot", label: "PerplexityBot", operator: "Perplexity", kind: "ai_search", needle: "perplexitybot" },
|
|
24
|
+
{ id: "mistralai-user", label: "MistralAI-User", operator: "Mistral", kind: "ai_assistant", needle: "mistralai-user" },
|
|
25
|
+
{ id: "duckassistbot", label: "DuckAssistBot", operator: "DuckDuckGo", kind: "ai_assistant", needle: "duckassistbot" },
|
|
26
|
+
{ id: "meta-externalfetcher", label: "Meta-ExternalFetcher", operator: "Meta", kind: "ai_assistant", needle: "meta-externalfetcher" },
|
|
27
|
+
{ id: "meta-externalagent", label: "meta-externalagent", operator: "Meta", kind: "ai_training", needle: "meta-externalagent" },
|
|
28
|
+
{ id: "youbot", label: "YouBot", operator: "You.com", kind: "ai_search", needle: "youbot" },
|
|
29
|
+
{ id: "cohere-ai", label: "cohere-ai", operator: "Cohere", kind: "ai_training", needle: "cohere-ai" },
|
|
30
|
+
{ id: "bytespider", label: "Bytespider", operator: "ByteDance", kind: "ai_training", needle: "bytespider" },
|
|
31
|
+
{ id: "ccbot", label: "CCBot", operator: "Common Crawl", kind: "ai_training", needle: "ccbot" },
|
|
32
|
+
{ id: "amazonbot", label: "Amazonbot", operator: "Amazon", kind: "search", needle: "amazonbot" },
|
|
33
|
+
{ id: "applebot-extended", label: "Applebot-Extended", operator: "Apple", kind: "ai_training", needle: "applebot-extended" },
|
|
34
|
+
{ id: "applebot", label: "Applebot", operator: "Apple", kind: "search", needle: "applebot" },
|
|
35
|
+
{ id: "googlebot", label: "Googlebot", operator: "Google", kind: "search", needle: "googlebot" },
|
|
36
|
+
{ id: "bingbot", label: "Bingbot", operator: "Microsoft", kind: "search", needle: "bingbot" },
|
|
37
|
+
{ id: "curl", label: "curl", operator: "script", kind: "tool", needle: "curl/" },
|
|
38
|
+
{ id: "python", label: "Python", operator: "script", kind: "tool", needle: "python-" },
|
|
39
|
+
{ id: "node", label: "Node fetch", operator: "script", kind: "tool", needle: "node-fetch" },
|
|
40
|
+
{ id: "undici", label: "Node fetch", operator: "script", kind: "tool", needle: "undici" },
|
|
41
|
+
];
|
|
42
|
+
const UNKNOWN = { id: "unknown", label: "Other", operator: "-", kind: "unknown" };
|
|
43
|
+
function classifyAgent(userAgent) {
|
|
44
|
+
const ua = (userAgent ?? "").toLowerCase();
|
|
45
|
+
if (!ua)
|
|
46
|
+
return UNKNOWN;
|
|
47
|
+
for (const b of BOTS) {
|
|
48
|
+
if (ua.includes(b.needle)) {
|
|
49
|
+
const { needle: _needle, ...bot } = b;
|
|
50
|
+
return bot;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return UNKNOWN;
|
|
54
|
+
}
|
|
55
|
+
/** True for the bots that read pages FOR an AI answer (assistants and AI search). */
|
|
56
|
+
function isAnswerBot(bot) {
|
|
57
|
+
return bot.kind === "ai_assistant" || bot.kind === "ai_search";
|
|
58
|
+
}
|
|
59
|
+
/** The robots.txt tokens the checker looks at: the bots that cite pages in answers. */
|
|
60
|
+
exports.ANSWER_BOT_TOKENS = ["OAI-SearchBot", "ChatGPT-User", "Claude-SearchBot", "Claude-User", "PerplexityBot", "Perplexity-User"];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HitSummary } from "./stats";
|
|
2
|
+
export interface DashboardOptions {
|
|
3
|
+
/** Shown in the heading, e.g. "relvehq.com". */
|
|
4
|
+
site: string;
|
|
5
|
+
/** Days the summary covers, shown in the heading. */
|
|
6
|
+
days?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function renderDashboard(s: HitSummary, opts: DashboardOptions): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Proof: the dashboard page. A summary -> one self-contained HTML page
|
|
3
|
+
// (inline CSS, no scripts, light and dark). Every value from a request
|
|
4
|
+
// (paths, user agents) is escaped. EDGE-SAFE (pure).
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.renderDashboard = renderDashboard;
|
|
7
|
+
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
8
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
9
|
+
function renderDashboard(s, opts) {
|
|
10
|
+
const tiles = [
|
|
11
|
+
["Agent requests", n(s.total)],
|
|
12
|
+
["Served as Markdown", n(s.served)],
|
|
13
|
+
["From AI assistants & AI search", n(s.answerBotHits)],
|
|
14
|
+
["Tokens served", n(s.tokensServed)],
|
|
15
|
+
["Median response", s.medianMs === null ? "–" : `${s.medianMs} ms`],
|
|
16
|
+
];
|
|
17
|
+
const range = s.from && s.to ? `${s.from.slice(0, 10)} to ${s.to.slice(0, 10)}` : "no visits yet";
|
|
18
|
+
return `<!doctype html>
|
|
19
|
+
<html lang="en">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="utf-8">
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
23
|
+
<meta name="robots" content="noindex, nofollow">
|
|
24
|
+
<title>Agent visits · ${esc(opts.site)}</title>
|
|
25
|
+
<style>
|
|
26
|
+
:root{--bg:#f7f7f5;--card:#fff;--ink:#1c1c1a;--muted:#6b6b66;--line:#e4e3de;--accent:#d97706;color-scheme:light}
|
|
27
|
+
@media (prefers-color-scheme:dark){:root{--bg:#141413;--card:#1d1d1b;--ink:#ecebe6;--muted:#a3a29b;--line:#2f2e2b;--accent:#f59e0b;color-scheme:dark}}
|
|
28
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px 16px}
|
|
29
|
+
main{max-width:1040px;margin:0 auto;display:grid;gap:20px}
|
|
30
|
+
h1{font-size:24px;margin:0}h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);margin:0 0 10px}
|
|
31
|
+
.sub{color:var(--muted);margin:4px 0 0}
|
|
32
|
+
.tiles{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(170px,1fr))}
|
|
33
|
+
.tile,.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
|
34
|
+
.tile b{display:block;font-size:24px;font-variant-numeric:tabular-nums}.tile span{color:var(--muted);font-size:13px}
|
|
35
|
+
.grid{display:grid;gap:20px;grid-template-columns:repeat(auto-fit,minmax(320px,1fr))}
|
|
36
|
+
table{width:100%;border-collapse:collapse;font-size:14px}td,th{padding:6px 4px;border-top:1px solid var(--line);text-align:left;vertical-align:top}
|
|
37
|
+
th{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);border-top:0}
|
|
38
|
+
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}td.path{word-break:break-all;font-family:ui-monospace,Menlo,monospace;font-size:13px}
|
|
39
|
+
.bar{height:6px;background:var(--line);border-radius:3px;margin-top:4px}.bar i{display:block;height:6px;background:var(--accent);border-radius:3px}
|
|
40
|
+
.days{display:flex;align-items:flex-end;gap:2px;height:90px;overflow-x:auto}.days i{flex:1 0 6px;background:var(--accent);border-radius:2px 2px 0 0;min-height:1px}
|
|
41
|
+
.muted{color:var(--muted)}
|
|
42
|
+
</style>
|
|
43
|
+
</head>
|
|
44
|
+
<body>
|
|
45
|
+
<main>
|
|
46
|
+
<header>
|
|
47
|
+
<h1>Agent visits · ${esc(opts.site)}</h1>
|
|
48
|
+
<p class="sub">${esc(range)}${opts.days ? ` · last ${esc(opts.days)} days` : ""} · Markdown served by @relvehq/agent-ready</p>
|
|
49
|
+
</header>
|
|
50
|
+
<section class="tiles" aria-label="Totals">${tiles.map(([label, value]) => `<div class="tile"><b>${esc(value)}</b><span>${esc(label)}</span></div>`).join("")}</section>
|
|
51
|
+
${s.daily.length ? `<section class="card" aria-label="Requests per day"><h2>Requests per day</h2><div class="days">${daysBars(s.daily)}</div><p class="sub">${esc(s.daily[0].day)} to ${esc(s.daily[s.daily.length - 1].day)}</p></section>` : ""}
|
|
52
|
+
<div class="grid">
|
|
53
|
+
${card("Who is reading", s.byBot, (r) => `${esc(r.label)} <span class="muted">· ${esc(r.operator ?? "")}</span>`)}
|
|
54
|
+
${card("Kind of reader", s.byKind)}
|
|
55
|
+
${card("Top pages", s.byPath, (r) => esc(r.label), true)}
|
|
56
|
+
${card("How agents asked", s.byVia)}
|
|
57
|
+
${card("Where the Markdown came from", s.bySource)}
|
|
58
|
+
${card("Status codes", s.byStatus)}
|
|
59
|
+
</div>
|
|
60
|
+
<p class="sub">Bots are recognised from their user agent for reporting only; what is served never depends on it.</p>
|
|
61
|
+
</main>
|
|
62
|
+
</body>
|
|
63
|
+
</html>`;
|
|
64
|
+
}
|
|
65
|
+
function card(title, rows, label = (r) => esc(r.label), path = false) {
|
|
66
|
+
const max = Math.max(1, ...rows.map((r) => r.hits));
|
|
67
|
+
const body = rows.length
|
|
68
|
+
? rows
|
|
69
|
+
.map((r) => `<tr><td${path ? ' class="path"' : ""}>${label(r)}<div class="bar"><i style="width:${Math.max(2, Math.round((r.hits / max) * 100))}%"></i></div></td><td class="num">${n(r.hits)}</td><td class="num muted">${r.tokens ? n(r.tokens) : "–"}</td></tr>`)
|
|
70
|
+
.join("")
|
|
71
|
+
: `<tr><td colspan="3" class="muted">Nothing yet</td></tr>`;
|
|
72
|
+
return `<section class="card"><h2>${esc(title)}</h2><table><thead><tr><th scope="col"></th><th scope="col" class="num">Hits</th><th scope="col" class="num">Tokens</th></tr></thead><tbody>${body}</tbody></table></section>`;
|
|
73
|
+
}
|
|
74
|
+
function daysBars(days) {
|
|
75
|
+
const max = Math.max(1, ...days.map((d) => d.hits));
|
|
76
|
+
return days.map((d) => `<i title="${esc(d.day)}: ${n(d.hits)}" style="height:${Math.round((d.hits / max) * 100)}%"></i>`).join("");
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Awaitable, HitEvent } from "../core/config";
|
|
2
|
+
export interface HitQuery {
|
|
3
|
+
/** ISO time; only hits at or after it. */
|
|
4
|
+
since?: string;
|
|
5
|
+
/** Newest N. */
|
|
6
|
+
limit?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface HitStore {
|
|
9
|
+
add(event: HitEvent): Awaitable<void>;
|
|
10
|
+
list(query?: HitQuery): Awaitable<HitEvent[]>;
|
|
11
|
+
}
|
|
12
|
+
/** In memory, newest `max` kept. */
|
|
13
|
+
export declare function memoryHitStore(max?: number): HitStore & {
|
|
14
|
+
size(): number;
|
|
15
|
+
};
|
|
16
|
+
/** Several onHit handlers as one. Nulls are skipped; one failing never stops the others. */
|
|
17
|
+
export declare function teeHits(...handlers: Array<((e: HitEvent) => Awaitable<void>) | null | undefined>): (e: HitEvent) => Promise<void>;
|
|
18
|
+
export declare function filterHits(events: HitEvent[], q?: HitQuery): HitEvent[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Proof: keep the visits. A HitStore records every HitEvent and lists them
|
|
3
|
+
// back for the dashboard and `agent-ready stats`.
|
|
4
|
+
//
|
|
5
|
+
// const hits = memoryHitStore(); // dev, tests
|
|
6
|
+
// const hits = jsonlHitStore("hits.jsonl"); // a long-running Node server (proof/jsonl)
|
|
7
|
+
// onHit: hits.add // or teeHits(hits.add, reporterFromEnv(env))
|
|
8
|
+
//
|
|
9
|
+
// On serverless hosts (Amplify, Vercel) memory and local files don't last
|
|
10
|
+
// between requests: send hits to the dashboard endpoint (core/report) or to a
|
|
11
|
+
// database HitStore of your own. EDGE-SAFE.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.memoryHitStore = memoryHitStore;
|
|
14
|
+
exports.teeHits = teeHits;
|
|
15
|
+
exports.filterHits = filterHits;
|
|
16
|
+
/** In memory, newest `max` kept. */
|
|
17
|
+
function memoryHitStore(max = 10000) {
|
|
18
|
+
const events = [];
|
|
19
|
+
return {
|
|
20
|
+
add(e) {
|
|
21
|
+
events.push(e);
|
|
22
|
+
if (events.length > max)
|
|
23
|
+
events.splice(0, events.length - max);
|
|
24
|
+
},
|
|
25
|
+
list(q = {}) {
|
|
26
|
+
return filterHits(events, q);
|
|
27
|
+
},
|
|
28
|
+
size: () => events.length,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Several onHit handlers as one. Nulls are skipped; one failing never stops the others. */
|
|
32
|
+
function teeHits(...handlers) {
|
|
33
|
+
const live = handlers.filter((h) => typeof h === "function");
|
|
34
|
+
return async (e) => {
|
|
35
|
+
await Promise.all(live.map(async (h) => {
|
|
36
|
+
try {
|
|
37
|
+
await h(e);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// A logging failure never touches the response or the other loggers.
|
|
41
|
+
}
|
|
42
|
+
}));
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function filterHits(events, q = {}) {
|
|
46
|
+
let out = q.since ? events.filter((e) => e.ts >= q.since) : events.slice();
|
|
47
|
+
out.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
48
|
+
if (q.limit && out.length > q.limit)
|
|
49
|
+
out = out.slice(out.length - q.limit);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { HitEvent } from "../core/config";
|
|
2
|
+
import { type HitStore } from "./hits";
|
|
3
|
+
export declare function jsonlHitStore(file: string): HitStore;
|
|
4
|
+
/** Lines that aren't a hit (blank, broken, foreign) are skipped. */
|
|
5
|
+
export declare function parseJsonl(text: string): HitEvent[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// A HitStore in a JSON Lines file: one HitEvent per line, appended.
|
|
3
|
+
// For a long-running Node server or local testing; `agent-ready stats --log`
|
|
4
|
+
// reads the same file. NODE ONLY (node:fs, loaded on first use).
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.jsonlHitStore = jsonlHitStore;
|
|
40
|
+
exports.parseJsonl = parseJsonl;
|
|
41
|
+
const hits_1 = require("./hits");
|
|
42
|
+
function jsonlHitStore(file) {
|
|
43
|
+
const fs = () => Promise.resolve().then(() => __importStar(require("node:fs/promises")));
|
|
44
|
+
return {
|
|
45
|
+
async add(e) {
|
|
46
|
+
const f = await fs();
|
|
47
|
+
const dir = file.replace(/[\\/][^\\/]*$/, "");
|
|
48
|
+
if (dir && dir !== file)
|
|
49
|
+
await f.mkdir(dir, { recursive: true });
|
|
50
|
+
await f.appendFile(file, JSON.stringify(e) + "\n", "utf8");
|
|
51
|
+
},
|
|
52
|
+
async list(q) {
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = await (await fs()).readFile(file, "utf8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return (0, hits_1.filterHits)(parseJsonl(text), q);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Lines that aren't a hit (blank, broken, foreign) are skipped. */
|
|
65
|
+
function parseJsonl(text) {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const line of text.split(/\r?\n/)) {
|
|
68
|
+
if (!line.trim())
|
|
69
|
+
continue;
|
|
70
|
+
try {
|
|
71
|
+
const e = JSON.parse(line);
|
|
72
|
+
if (e && typeof e.ts === "string" && typeof e.path === "string")
|
|
73
|
+
out.push(e);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// skip
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { HitEvent } from "../core/config";
|
|
2
|
+
import { type BotKind } from "./bots";
|
|
3
|
+
export interface Count {
|
|
4
|
+
key: string;
|
|
5
|
+
label: string;
|
|
6
|
+
hits: number;
|
|
7
|
+
tokens: number;
|
|
8
|
+
}
|
|
9
|
+
export interface HitSummary {
|
|
10
|
+
from: string | null;
|
|
11
|
+
to: string | null;
|
|
12
|
+
total: number;
|
|
13
|
+
/** 2xx and 304 responses. */
|
|
14
|
+
served: number;
|
|
15
|
+
/** Hits from AI assistants and AI search (the ones that cite pages). */
|
|
16
|
+
answerBotHits: number;
|
|
17
|
+
tokensServed: number;
|
|
18
|
+
medianMs: number | null;
|
|
19
|
+
byBot: (Count & {
|
|
20
|
+
operator: string;
|
|
21
|
+
kind: BotKind;
|
|
22
|
+
})[];
|
|
23
|
+
byKind: Count[];
|
|
24
|
+
byPath: Count[];
|
|
25
|
+
byVia: Count[];
|
|
26
|
+
bySource: Count[];
|
|
27
|
+
byStatus: Count[];
|
|
28
|
+
/** One row per day (UTC), oldest first, gaps filled with 0. */
|
|
29
|
+
daily: {
|
|
30
|
+
day: string;
|
|
31
|
+
hits: number;
|
|
32
|
+
tokens: number;
|
|
33
|
+
}[];
|
|
34
|
+
}
|
|
35
|
+
export declare function summarize(events: HitEvent[], opts?: {
|
|
36
|
+
topPaths?: number;
|
|
37
|
+
}): HitSummary;
|
|
38
|
+
/** The summary as terminal lines (`agent-ready stats`). */
|
|
39
|
+
export declare function formatSummary(s: HitSummary): string;
|