llms-ink 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -74,6 +74,16 @@ const { parsed, issues } = await ink.checkText(await readFile('public/llms.txt',
74
74
  Without `base`, `site` and `url` are `null`; without `base` and `links`, `llmsFull` is `null` and
75
75
  `links.checked` is 0. Up to 2 MB.
76
76
 
77
+ ### `ink.markdown(html, { url?, signal? })` → `Promise<string>`
78
+
79
+ A page's HTML as Markdown, the way llms.ink reads pages: hidden-instruction text (comments, hidden elements,
80
+ off-screen text addressed to AI), scripts, navigation, headers, footers and forms are dropped first. `url` (never
81
+ fetched) resolves relative links. Up to 5 MB; nothing is stored.
82
+
83
+ ```ts
84
+ const md = await ink.markdown(await readFile('dist/pricing/index.html', 'utf8'), { url: 'https://example.com/pricing/' });
85
+ ```
86
+
77
87
  ### `ink.stream(url, { mode?, fresh?, signal? })` → `AsyncGenerator<StreamEvent>`
78
88
 
79
89
  The same build as `llms()`, as it happens:
@@ -104,7 +114,15 @@ After `vite build` (not the dev server or SSR builds), writes `llms.txt` (and `l
104
114
  `site` into `build.outDir`. llms.ink reads the live site, so the files describe the previous deploy. If llms.ink
105
115
  fails, the plugin warns and keeps any existing file (e.g. one from `public/`); `strict: true` fails the build.
106
116
  Options: `site`, `full`, `key` (default `LLMS_INK_KEY`), `native` (default `true`; a file llms.ink generated before
107
- is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`.
117
+ is regenerated, not echoed), `strict`, `timeout` (ms, default 300000), `baseUrl`, `markdownPages`.
118
+
119
+ `markdownPages: true` also writes a Markdown version next to every HTML page of this build (`about.html` →
120
+ `about.md`, `docs/index.html` → `docs/index.md`), converted with `ink.markdown()` from the local output, so the
121
+ `.md` pages match what you're deploying. Existing `.md` files are kept; `/404`, `/500` and pages over 5 MB are
122
+ skipped; a rate limit is waited out (`Retry-After`). Failures warn and the build goes on, unless `strict`. Pass
123
+ `{ concurrency, exclude }` (default 4 at a time; `exclude` is a RegExp on the page path) to tune it. Large sites:
124
+ use an API key (60 conversions a minute anonymous per IP, the key's own limit otherwise). They're static files:
125
+ `/about.md` works as is; for `Accept: text/markdown` on the page URL, add a rewrite to `<page>.md` on your host.
108
126
 
109
127
  ### Astro: `llms-ink/astro`
110
128
 
@@ -137,6 +155,29 @@ Everything else, and llms.ink's own fetcher, gets `undefined` (Express: `next()`
137
155
  `LLMS_INK_KEY`; recommended on shared hosting IPs), `markdown`, `baseUrl`. Don't also ship a static `llms.txt`:
138
156
  most hosts serve it before the middleware runs.
139
157
 
158
+ #### AI traffic
159
+
160
+ ```ts
161
+ const llms = llmsInkHandler({ site: 'https://example.com', traffic: { key: env.LLMS_INK_KEY } });
162
+ const response = (await llms(request, ctx)) ?? (await app(request)); // Workers: ctx; Next.js proxy: the event
163
+
164
+ expressApp.use(llmsInkExpress({ site: 'https://example.com', traffic: { key: process.env.LLMS_INK_KEY } }));
165
+ ```
166
+
167
+ With `traffic: { key, site?, flushEvery? }` the middleware also counts requests from AI agents (`AI_AGENTS`):
168
+ user fetches (`ChatGPT-User`, `Claude-User`, `Perplexity-User`…, someone asked an assistant) and crawlers (`GPTBot`,
169
+ `OAI-SearchBot`, `ClaudeBot`, `PerplexityBot`, `Googlebot`, `bingbot`…). It counts every request it sees, served or
170
+ not, per path and agent in memory, and every `flushEvery` seconds (default 60, at least 10) sends the day's totals to
171
+ `POST /api/v0/traffic`, at most 1000 rows per request. Only `{ site, period, rows: [{ path, agent, kind, count }] }`
172
+ leaves your server: no IPs, user agents, query strings or cookies; browsers aren't counted. Sends never block a
173
+ response: with `ctx` they go to `ctx.waitUntil`, without one (Node, Bun, Deno) an unref'd timer sends. Failed sends
174
+ keep their counts and retry with backoff (honoring `Retry-After`); a `401` or `403 site_claimed` stops counting with
175
+ one warning. `site` defaults to the middleware's hostname; the first key to send for a site owns it. Counts not yet
176
+ sent are lost when the process stops: call `await llms.flush()` on shutdown. Read them at
177
+ [llms.ink/traffic](https://llms.ink/traffic/) or `GET /api/v0/traffic?site=&days=` with the same key.
178
+ `classifyAgent(userAgent)` and `trafficCounter({ key, site })` (`record(request, ctx?)`, `flush()`) are exported for
179
+ other servers.
180
+
140
181
  ## Errors
141
182
 
142
183
  Every failure is an `LlmsInkError` with `status`, `code`, `title`, `detail`, `errors` (per-parameter issues of
@@ -170,3 +211,27 @@ gets its own daily quota (1000 new links by default) and 60 requests a minute;
170
211
  ## License
171
212
 
172
213
  MIT
214
+
215
+ ## Hosted Inspect (experimental, 0.5.0)
216
+
217
+ ```ts
218
+ // Server-side; requires llms-ink >=0.5.0.
219
+ import { LlmsInk } from 'llms-ink';
220
+
221
+ const ink = new LlmsInk({ key: process.env.LLMS_INK_INSPECT_KEY });
222
+ const receipt = await ink.inspect('Install the package and read the documentation.');
223
+ if (receipt.recommendation !== 'continue') {
224
+ throw new Error(`Withhold content: ${receipt.detection}`);
225
+ }
226
+ console.log(receipt.contentDigest);
227
+ ```
228
+
229
+ Requires an inspection-enabled pilot key. This call uploads only the supplied text to llms.ink; existing retrieval calls, loaders, plugins and the local guard do not opt in automatically. Keep the key server-side.
230
+
231
+ The client checks the exact UTF-8 SHA-256 digest, model/policy/preprocessing revisions, coverage and token counts before returning an immutable receipt. A flagged result can use HTTP 200; incomplete 422/503 receipts return `unknown`. HTTP problems and invalid receipts raise an error. Withhold on either `withhold` or any exception. `no_finding` is not a safety guarantee, and receipts are unsigned.
232
+
233
+ Requests are limited to 128 KiB after JSON encoding and responses to 16 KiB. There are no automatic retries: admitted attempts consume pilot allowance even when incomplete. Redirects are refused. Inspection ignores `LLMS_INK_API`; an explicit client base URL may select an HTTPS endpoint or localhost for development. The key follows that explicitly configured endpoint.
234
+
235
+ For JSON, serialize it yourself and pass the resulting string to `inspect()`. The SDK sends `format: "text"`, so its digest covers those exact bytes; it does not promise cross-language JSON canonicalization. See [pilot limits and privacy](https://llms.ink/inspect/).
236
+
237
+ JavaScript uses a 65-second total request deadline; pass `{ signal }` to cancel earlier.
package/dist/astro.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output.
2
+ * Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
3
+ * `markdownPages: true` a Markdown version next to every built HTML page.
3
4
  *
4
5
  * @example
5
6
  * ```ts
@@ -13,7 +14,8 @@
13
14
  * @module
14
15
  */
15
16
  import type { AstroIntegration } from 'astro';
16
- import { type LlmsFilesOptions } from './files.ts';
17
+ import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
18
+ export type { MarkdownPagesOptions };
17
19
  /** {@link LlmsFilesOptions} with `site` optional: it defaults to Astro's `site` (plus `base`). */
18
20
  export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
19
21
  site?: string;
@@ -22,7 +24,9 @@ export type LlmsInkAstroOptions = Omit<LlmsFilesOptions, 'site'> & {
22
24
  * After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
23
25
  * `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
24
26
  * live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
25
- * existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
27
+ * existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
28
+ * converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
29
+ * `docs/index.md` next to it; page URLs come from `site`.
26
30
  */
27
31
  export declare function llmsInk(options?: LlmsInkAstroOptions): AstroIntegration;
28
32
  export default llmsInk;
package/dist/astro.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output.
2
+ * Astro integration: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
3
+ * `markdownPages: true` a Markdown version next to every built HTML page.
3
4
  *
4
5
  * @example
5
6
  * ```ts
@@ -18,7 +19,9 @@ import { llmsFilesWriter } from "./files.js";
18
19
  * After `astro build`, fetches llms.txt — and llms-full.txt with `full: true` — for `site` (default: the
19
20
  * `site` and `base` of the Astro config) and writes them into the static output directory. llms.ink reads the
20
21
  * live site, so the files describe the previous deploy. When llms.ink can't be reached it warns and keeps any
21
- * existing file (e.g. one copied from `public/`); `strict: true` fails the build instead.
22
+ * existing file (e.g. one copied from `public/`); `strict: true` fails the build instead. `markdownPages: true`
23
+ * converts each prerendered `.html` page of this build through `POST /api/v0/markdown` and writes `about.md` /
24
+ * `docs/index.md` next to it; page URLs come from `site`.
22
25
  */
23
26
  export function llmsInk(options = {}) {
24
27
  const settings = { ...options, site: options.site ?? '' };
package/dist/files.d.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
3
- * them into a build's output directory.
3
+ * them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
4
4
  *
5
5
  * @module
6
6
  */
7
+ /** `markdownPages` settings beyond `true`. */
8
+ export interface MarkdownPagesOptions {
9
+ /** Pages converted at once. Default 4. */
10
+ concurrency?: number;
11
+ /** Pages to skip, matched against the page path (`/docs/`, `/about`). Default: `/404` and `/500` pages. */
12
+ exclude?: RegExp;
13
+ }
7
14
  /** Options for the Vite plugin and the Astro integration. */
8
15
  export interface LlmsFilesOptions {
9
16
  /** The deployed site to describe, e.g. `https://example.com`. llms.ink reads the live site. */
@@ -18,7 +25,14 @@ export interface LlmsFilesOptions {
18
25
  * generates (`native=0`).
19
26
  */
20
27
  native?: boolean;
21
- /** Fail the build when a file can't be fetched. Default `false`: warn and keep any existing file. */
28
+ /**
29
+ * Write a Markdown version of every HTML page of this build next to it (`about.html` → `about.md`,
30
+ * `docs/index.html` → `docs/index.md`), converted by llms.ink from the local output (`POST /api/v0/markdown`):
31
+ * chrome and hidden-instruction text dropped, relative links resolved against `site`. Existing `.md` files are
32
+ * never overwritten; pages over 5 MB are skipped. Default `false`.
33
+ */
34
+ markdownPages?: boolean | MarkdownPagesOptions;
35
+ /** Fail the build when a file can't be fetched or a page can't be converted. Default `false`: warn and go on. */
22
36
  strict?: boolean;
23
37
  /** Milliseconds to wait for llms.ink per file. Default 300000 (building a big site can take minutes). */
24
38
  timeout?: number;
@@ -30,8 +44,12 @@ export interface Log {
30
44
  info(message: string): void;
31
45
  warn(message: string): void;
32
46
  }
47
+ /** The endpoint's body limit. */
48
+ export declare const MAX_PAGE_BYTES = 5000000;
33
49
  /**
34
50
  * A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
35
- * a failed fetch is retried on the next call).
51
+ * a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
36
52
  */
37
53
  export declare function llmsFilesWriter(options: LlmsFilesOptions): (dir: string, log: Log) => Promise<void>;
54
+ /** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
55
+ export declare function pagePath(file: string): string;
package/dist/files.js CHANGED
@@ -1,44 +1,50 @@
1
1
  /**
2
2
  * Shared by the Vite plugin and the Astro integration: fetch llms.txt (and llms-full.txt) for a site and write
3
- * them into a build's output directory.
3
+ * them into a build's output directory; with `markdownPages`, write a `.md` version next to every built HTML page.
4
4
  *
5
5
  * @module
6
6
  */
7
- import { access, mkdir, writeFile } from 'node:fs/promises';
7
+ import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
8
8
  import { join } from 'node:path';
9
- import { LlmsInk } from "./index.js";
9
+ import { setTimeout as wait } from 'node:timers/promises';
10
+ import { LlmsInk, LlmsInkError } from "./index.js";
10
11
  const FILES = [['llms.txt', 'llms'], ['llms-full.txt', 'full']];
12
+ /** The endpoint's body limit. */
13
+ export const MAX_PAGE_BYTES = 5_000_000;
14
+ const PAGE_TIMEOUT = 60_000;
15
+ /** Tries per page when llms.ink says to slow down (429 with Retry-After). */
16
+ const RATE_TRIES = 4;
17
+ const DEFAULT_EXCLUDE = /^\/(404|500)(\.html|\/)?$/;
18
+ const reason = (error) => (error instanceof Error ? error.message : String(error));
11
19
  /**
12
20
  * A writer that fetches the files once and writes them into `dir` on every call (watch builds call it again;
13
- * a failed fetch is retried on the next call).
21
+ * a failed fetch is retried on the next call). Markdown pages are converted on every call: they describe this build.
14
22
  */
15
23
  export function llmsFilesWriter(options) {
16
24
  let fetched;
17
25
  return async (dir, log) => {
18
26
  const { site, strict = false } = options;
19
- if (!site) {
20
- const message = 'No `site` set: llms.txt not written.';
21
- if (strict)
22
- throw new Error(message);
23
- return log.warn(message);
24
- }
25
- fetched ??= fetchFiles(options);
26
- const results = await fetched;
27
27
  const failures = [];
28
- for (const result of results) {
29
- const path = join(dir, result.name);
30
- if ('text' in result) {
31
- await mkdir(dir, { recursive: true });
32
- await writeFile(path, result.text);
33
- log.info(`Wrote ${result.name} for ${site} (${result.source}, ~${result.tokens} tokens).`);
34
- continue;
28
+ if (!site)
29
+ failures.push('No `site` set: llms.txt not written.');
30
+ else {
31
+ fetched ??= fetchFiles(options);
32
+ for (const result of await fetched) {
33
+ const path = join(dir, result.name);
34
+ if ('text' in result) {
35
+ await mkdir(dir, { recursive: true });
36
+ await writeFile(path, result.text);
37
+ log.info(`Wrote ${result.name} for ${site} (${result.source}, ~${result.tokens} tokens).`);
38
+ continue;
39
+ }
40
+ const kept = await access(path).then(() => 'kept the existing file', () => 'none written');
41
+ failures.push(`Couldn't get ${result.name} for ${site}: ${reason(result.error)} (${kept}).`);
35
42
  }
36
- const reason = result.error instanceof Error ? result.error.message : String(result.error);
37
- const kept = await access(path).then(() => 'kept the existing file', () => 'none written');
38
- failures.push(`Couldn't get ${result.name} for ${site}: ${reason} (${kept}).`);
43
+ if (failures.length)
44
+ fetched = undefined;
39
45
  }
40
- if (failures.length)
41
- fetched = undefined;
46
+ if (options.markdownPages)
47
+ failures.push(...(await writeMarkdownPages(dir, options, log)));
42
48
  if (strict && failures.length)
43
49
  throw new Error(failures.join(' '));
44
50
  for (const message of failures)
@@ -50,3 +56,74 @@ async function fetchFiles({ site, full, key, native = true, timeout = 300_000, b
50
56
  const files = full ? FILES : FILES.slice(0, 1);
51
57
  return Promise.all(files.map(([name, mode]) => ink.llms(site, { mode, native, signal: AbortSignal.timeout(timeout) }).then(({ text, tokens, source }) => ({ name, text, tokens, source }), (error) => ({ name, error }))));
52
58
  }
59
+ /** Every `.html` file under `dir`, as paths relative to it with `/` separators, sorted. Symlinks aren't followed. */
60
+ async function htmlFiles(dir, prefix = '') {
61
+ const entries = await readdir(join(dir, prefix), { withFileTypes: true }).catch(() => []);
62
+ const nested = await Promise.all(entries.map((e) => {
63
+ const path = prefix ? `${prefix}/${e.name}` : e.name;
64
+ if (e.isDirectory())
65
+ return htmlFiles(dir, path);
66
+ return e.isFile() && e.name.endsWith('.html') ? [path] : [];
67
+ }));
68
+ return nested.flat().sort();
69
+ }
70
+ /** `docs/index.html` → `/docs/`, `about.html` → `/about`, `index.html` → `/`. */
71
+ export function pagePath(file) {
72
+ const path = `/${file.replace(/\.html$/, '')}`;
73
+ return path.endsWith('/index') ? path.slice(0, -'index'.length) : path;
74
+ }
75
+ /**
76
+ * Converts each HTML page of `dir` through llms.ink and writes `<page>.md` next to it. Returns the failure messages
77
+ * (the caller warns or, with `strict`, fails the build).
78
+ */
79
+ async function writeMarkdownPages(dir, options, log) {
80
+ const settings = typeof options.markdownPages === 'object' ? options.markdownPages : {};
81
+ const exclude = settings.exclude ?? DEFAULT_EXCLUDE;
82
+ const concurrency = Math.max(1, Math.floor(settings.concurrency ?? 4));
83
+ const ink = new LlmsInk({ key: options.key, baseUrl: options.baseUrl });
84
+ const site = options.site ? new URL(options.site.endsWith('/') ? options.site : `${options.site}/`) : null;
85
+ const pages = (await htmlFiles(dir)).filter((file) => !exclude.test(pagePath(file)));
86
+ const failures = [];
87
+ let written = 0;
88
+ let skipped = 0;
89
+ const convert = async (file) => {
90
+ const target = join(dir, file.replace(/\.html$/, '.md'));
91
+ const md = file.replace(/\.html$/, '.md');
92
+ if (await access(target).then(() => true, () => false)) {
93
+ skipped++;
94
+ return log.info(`Kept ${md}: it already exists.`);
95
+ }
96
+ const source = join(dir, file);
97
+ if ((await stat(source)).size > MAX_PAGE_BYTES) {
98
+ skipped++;
99
+ return log.warn(`Skipped ${file}: over ${MAX_PAGE_BYTES.toLocaleString('en-US')} bytes.`);
100
+ }
101
+ const html = await readFile(source, 'utf8');
102
+ // Page URL under `site` (its path included: Astro's `base`).
103
+ const url = site ? new URL(pagePath(file).slice(1), site).href : undefined;
104
+ for (let attempt = 1;; attempt++) {
105
+ try {
106
+ const text = await ink.markdown(html, { url, signal: AbortSignal.timeout(PAGE_TIMEOUT) });
107
+ await writeFile(target, text);
108
+ written++;
109
+ return;
110
+ }
111
+ catch (error) {
112
+ const limited = error instanceof LlmsInkError && error.status === 429 && error.code === 'rate_limited';
113
+ if (limited && attempt < RATE_TRIES) {
114
+ await wait(Math.min(error.retryAfter ?? 10, 60) * 1000);
115
+ continue;
116
+ }
117
+ failures.push(`Couldn't convert ${file} to Markdown: ${reason(error).replace(/\.$/, '')}.`);
118
+ return;
119
+ }
120
+ }
121
+ };
122
+ let next = 0;
123
+ await Promise.all(Array.from({ length: Math.min(concurrency, pages.length) }, async () => {
124
+ while (next < pages.length)
125
+ await convert(pages[next++]);
126
+ }));
127
+ log.info(`Wrote ${written} Markdown page${written === 1 ? '' : 's'}${skipped ? ` (${skipped} skipped)` : ''}${failures.length ? `; ${failures.length} failed` : ''}.`);
128
+ return failures;
129
+ }
package/dist/index.d.ts CHANGED
@@ -13,12 +13,14 @@
13
13
  *
14
14
  * @module
15
15
  */
16
- export declare const VERSION = "0.3.0";
16
+ import { type InspectionReceipt } from './inspect.ts';
17
+ export type { InspectionReceipt } from './inspect.ts';
18
+ export declare const VERSION = "0.5.0";
17
19
  export declare const DEFAULT_BASE_URL = "https://llms.ink";
18
20
  /** `llms` for the index (llms.txt), `full` for llms-full.txt. */
19
21
  export type Mode = 'llms' | 'full';
20
22
  /** What the link is. */
21
- export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video';
23
+ export type Kind = 'site' | 'section' | 'page' | 'repo' | 'document' | 'video' | 'channel';
22
24
  /** `native` = published by the site; `generated` = made by llms.ink. */
23
25
  export type Source = 'native' | 'generated';
24
26
  /** A transcript line; `start` and `end` are seconds into the source video. */
@@ -137,7 +139,7 @@ export interface CheckResult {
137
139
  checkedAt: string;
138
140
  }
139
141
  /** Every error code the API answers with (https://llms.ink/docs/#errors). */
140
- export type ErrorCode = 'invalid_request' | 'invalid_url' | 'invalid_key' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
142
+ export type ErrorCode = 'inspection_not_enabled' | 'inspection_quota' | 'inspection_unavailable' | 'invalid_request' | 'invalid_url' | 'invalid_key' | 'key_required' | 'site_claimed' | 'not_found' | 'unknown_version' | 'method_not_allowed' | 'payload_too_large' | 'too_large' | 'unsupported_media_type' | 'unprocessable' | 'rate_limited' | 'quota_exceeded' | 'internal' | 'upstream_error' | 'unavailable' | 'timeout' | 'loop_detected';
141
143
  /** `invalid_request` only: one invalid parameter. */
142
144
  export interface Issue {
143
145
  path: string;
@@ -244,6 +246,12 @@ export interface CheckTextOptions {
244
246
  links?: boolean;
245
247
  signal?: AbortSignal;
246
248
  }
249
+ /** Options for {@link LlmsInk.markdown}. */
250
+ export interface MarkdownOptions {
251
+ /** Where the page lives (never fetched): relative links in the Markdown resolve against it. */
252
+ url?: string;
253
+ signal?: AbortSignal;
254
+ }
247
255
  /**
248
256
  * A client for https://llms.ink.
249
257
  *
@@ -262,10 +270,24 @@ export declare class LlmsInk {
262
270
  * any public link.
263
271
  */
264
272
  llms(url: string, options?: LlmsOptions): Promise<Result>;
273
+ /**
274
+ * Explicitly upload supplied text to the hosted Inspect pilot. Requires an enabled key.
275
+ * The receipt binds to these exact UTF-8 bytes; no finding is not a safety guarantee.
276
+ * No retries. 65-second default deadline. LLMS_INK_API is ignored; baseUrl must be explicit.
277
+ * Keep keys server-side. Malformed receipts and HTTP problems throw LlmsInkError.
278
+ */
279
+ inspect(text: string, options?: {
280
+ signal?: AbortSignal;
281
+ }): Promise<InspectionReceipt>;
265
282
  /** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
266
283
  check(url: string, options?: CheckOptions): Promise<CheckResult>;
267
284
  /** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
268
285
  checkText(text: string, options?: CheckTextOptions): Promise<CheckResult>;
286
+ /**
287
+ * A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
288
+ * headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
289
+ */
290
+ markdown(html: string, options?: MarkdownOptions): Promise<string>;
269
291
  /**
270
292
  * Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
271
293
  * `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
package/dist/index.js CHANGED
@@ -13,8 +13,9 @@
13
13
  *
14
14
  * @module
15
15
  */
16
+ import { prepareInspection, inspectionJson, inspectionReceipt } from "./inspect.js";
16
17
  import { env } from "./env.js";
17
- export const VERSION = '0.3.0';
18
+ export const VERSION = '0.5.0';
18
19
  export const DEFAULT_BASE_URL = 'https://llms.ink';
19
20
  /**
20
21
  * An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
@@ -83,10 +84,12 @@ export class LlmsInk {
83
84
  key;
84
85
  /** Origin of the API, without a trailing slash. */
85
86
  baseUrl;
87
+ #inspectBaseUrl;
86
88
  #fetch;
87
89
  constructor(options = {}) {
88
90
  const key = (options.key ?? env('LLMS_INK_KEY'))?.trim();
89
91
  this.key = key || undefined;
92
+ this.#inspectBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
90
93
  this.baseUrl = (options.baseUrl ?? env('LLMS_INK_API') ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
91
94
  if (!options.fetch && typeof globalThis.fetch !== 'function') {
92
95
  throw new TypeError('No global fetch: use Node 18+ or pass options.fetch.');
@@ -102,6 +105,44 @@ export class LlmsInk {
102
105
  const res = await this.#request('/api/v0/llms', llmsParams(url, options), 'application/json', options.signal);
103
106
  return (await this.#json(res));
104
107
  }
108
+ /**
109
+ * Explicitly upload supplied text to the hosted Inspect pilot. Requires an enabled key.
110
+ * The receipt binds to these exact UTF-8 bytes; no finding is not a safety guarantee.
111
+ * No retries. 65-second default deadline. LLMS_INK_API is ignored; baseUrl must be explicit.
112
+ * Keep keys server-side. Malformed receipts and HTTP problems throw LlmsInkError.
113
+ */
114
+ async inspect(text, options = {}) {
115
+ if (!this.key)
116
+ throw new LlmsInkError('Inspect requires an inspection-enabled API key.', { code: 'key_required' });
117
+ const { body, digest } = await prepareInspection(text, this.#inspectBaseUrl);
118
+ const controller = new AbortController();
119
+ const abort = () => controller.abort(options.signal?.reason);
120
+ const timer = setTimeout(() => controller.abort(new DOMException('Inspection timed out.', 'TimeoutError')), 65000);
121
+ if (options.signal?.aborted)
122
+ abort();
123
+ options.signal?.addEventListener('abort', abort, { once: true });
124
+ try {
125
+ const res = await this.#fetch(`${this.#inspectBaseUrl}/api/v0/inspect`, {
126
+ method: 'POST', redirect: 'error', signal: controller.signal,
127
+ headers: { authorization: `Bearer ${this.key}`, 'content-type': 'application/json', accept: 'application/json' }, body,
128
+ });
129
+ const data = await inspectionJson(res);
130
+ if (!res.ok && data && typeof data === 'object' && !('detection' in data)) {
131
+ const p = data;
132
+ throw errorFor(p.detail || p.title || `Inspect returned HTTP ${res.status}.`, { status: res.status, code: p.code, retryAfter: retryAfter(res.headers.get('retry-after')) });
133
+ }
134
+ return inspectionReceipt(data, digest, res.status);
135
+ }
136
+ catch (err) {
137
+ if (err instanceof LlmsInkError || isAbort(err))
138
+ throw err;
139
+ throw new LlmsInkError('Inspection did not return a valid receipt. Withhold the content.', { code: 'inspection_unavailable' });
140
+ }
141
+ finally {
142
+ clearTimeout(timer);
143
+ options.signal?.removeEventListener('abort', abort);
144
+ }
145
+ }
105
146
  /** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
106
147
  async check(url, options = {}) {
107
148
  const res = await this.#request('/api/v0/check', { url }, 'application/json', options.signal);
@@ -117,6 +158,15 @@ export class LlmsInk {
117
158
  const res = await this.#request('/api/v0/check', params, 'application/json', options.signal, text);
118
159
  return (await this.#json(res));
119
160
  }
161
+ /**
162
+ * A page's HTML as Markdown (`POST /api/v0/markdown`, up to 5 MB): hidden-instruction text, scripts, navigation,
163
+ * headers and footers dropped, the way llms.ink reads pages. Nothing is fetched or stored.
164
+ */
165
+ async markdown(html, options = {}) {
166
+ const params = options.url ? { url: options.url } : {};
167
+ const res = await this.#request('/api/v0/markdown', params, 'text/markdown', options.signal, html, 'text/html; charset=utf-8');
168
+ return res.text();
169
+ }
120
170
  /**
121
171
  * Like {@link LlmsInk.llms}, as the build happens: yields `progress` and `partial` events, then one
122
172
  * `result`. Errors — including a terminal `error` event — are thrown as {@link LlmsInkError}s.
@@ -183,10 +233,10 @@ export class LlmsInk {
183
233
  const known = event.type === 'progress' || event.type === 'partial' || event.type === 'result';
184
234
  return known ? event : undefined;
185
235
  }
186
- async #request(path, params, accept, signal, body) {
236
+ async #request(path, params, accept, signal, body, type = 'text/markdown; charset=utf-8') {
187
237
  const headers = { accept };
188
238
  if (body !== undefined)
189
- headers['content-type'] = 'text/markdown; charset=utf-8';
239
+ headers['content-type'] = type;
190
240
  if (!IS_BROWSER)
191
241
  headers['user-agent'] = `llms-ink-js/${VERSION}`;
192
242
  if (this.key)
@@ -0,0 +1,21 @@
1
+ /** Hosted Inspect pilot. Only explicit inspect() calls upload content. */
2
+ export interface InspectionReceipt {
3
+ readonly detection: 'no_finding' | 'flagged' | 'unknown';
4
+ readonly coverage: 'complete' | 'partial' | 'none';
5
+ readonly recommendation: 'continue' | 'withhold';
6
+ readonly reason: string;
7
+ readonly modelRevision: string;
8
+ readonly policyRevision: string;
9
+ readonly preprocessingRevision: string;
10
+ readonly contentDigest: string;
11
+ readonly inputTokens: number;
12
+ readonly inspectedTokens: number;
13
+ readonly windows: number;
14
+ readonly enforced: false;
15
+ }
16
+ export declare function prepareInspection(text: string, baseUrl: string): Promise<{
17
+ body: string;
18
+ digest: string;
19
+ }>;
20
+ export declare function inspectionJson(res: Response): Promise<unknown>;
21
+ export declare function inspectionReceipt(value: unknown, digest: string, status: number): InspectionReceipt;
@@ -0,0 +1,67 @@
1
+ export async function prepareInspection(text, baseUrl) {
2
+ const url = new URL(baseUrl);
3
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
4
+ throw new TypeError('Inspect requires HTTPS (or localhost for development).');
5
+ if (typeof text !== 'string' || !text.length)
6
+ throw new TypeError('Inspect requires nonempty text.');
7
+ const bytes = new TextEncoder().encode(text);
8
+ if (new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes) !== text)
9
+ throw new TypeError('Inspect requires valid Unicode.');
10
+ const body = JSON.stringify({ format: 'text', content: text });
11
+ if (new TextEncoder().encode(body).length > 131072)
12
+ throw new TypeError('Inspect request exceeds 128 KiB after JSON encoding.');
13
+ const crypto = globalThis.crypto ?? (await import('node:crypto')).webcrypto;
14
+ const digest = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))].map(b => b.toString(16).padStart(2, '0')).join('');
15
+ return { body, digest };
16
+ }
17
+ export async function inspectionJson(res) {
18
+ const reader = res.body?.getReader();
19
+ if (!reader)
20
+ throw new Error('Inspect returned an empty response.');
21
+ const chunks = [];
22
+ let size = 0;
23
+ try {
24
+ while (true) {
25
+ const { value, done } = await reader.read();
26
+ if (done)
27
+ break;
28
+ size += value.length;
29
+ if (size > 16384) {
30
+ await reader.cancel();
31
+ throw new Error('Inspect response exceeds 16 KiB.');
32
+ }
33
+ chunks.push(value);
34
+ }
35
+ }
36
+ finally {
37
+ reader.releaseLock();
38
+ }
39
+ const bytes = new Uint8Array(size);
40
+ let offset = 0;
41
+ for (const chunk of chunks) {
42
+ bytes.set(chunk, offset);
43
+ offset += chunk.length;
44
+ }
45
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
46
+ }
47
+ export function inspectionReceipt(value, digest, status) {
48
+ const fail = () => { throw new Error('Inspect returned an invalid or unsupported receipt. Withhold the content.'); };
49
+ if (!value || typeof value !== 'object')
50
+ return fail();
51
+ const r = value;
52
+ if (![200, 422, 503].includes(status) || (status !== 200 && r.detection !== 'unknown') || r.contentDigest !== digest || r.modelRevision !== '4a43fec65a670e48835d8e8c85c705b4b0d1c907' || r.policyRevision !== 'llms-ink-inspect-1' || r.preprocessingRevision !== 'utf8-json-stringify-1' || r.enforced !== false)
53
+ return fail();
54
+ if (!Number.isSafeInteger(r.inputTokens) || r.inputTokens < 0 || !Number.isSafeInteger(r.inspectedTokens) || r.inspectedTokens < 0 || r.inspectedTokens > r.inputTokens || !Number.isInteger(r.windows) || r.windows < 0 || r.windows > 43)
55
+ return fail();
56
+ let valid = false;
57
+ if (r.detection === 'no_finding')
58
+ valid = r.coverage === 'complete' && r.recommendation === 'continue' && r.reason === 'no_finding' && r.inputTokens <= 8192 && r.inspectedTokens === r.inputTokens && (r.inputTokens === 0 || r.windows > 0);
59
+ if (r.detection === 'flagged')
60
+ valid = ['complete', 'partial'].includes(r.coverage) && r.recommendation === 'withhold' && ['instruction_pattern', 'model_detection'].includes(r.reason) && r.inputTokens <= 8192 && r.inspectedTokens > 0 && r.windows > 0 && (r.coverage !== 'complete' || r.inspectedTokens === r.inputTokens);
61
+ if (r.detection === 'unknown')
62
+ valid = r.coverage === 'none' && r.recommendation === 'withhold' && r.inspectedTokens === 0 && r.windows === 0 && ['token_limit', 'model_unavailable', 'model_failure', 'inspection_timeout'].includes(r.reason);
63
+ if (!valid)
64
+ return fail();
65
+ const { detection, coverage, recommendation, reason, modelRevision, policyRevision, preprocessingRevision, contentDigest, inputTokens, inspectedTokens, windows, enforced } = r;
66
+ return Object.freeze({ detection, coverage, recommendation, reason, modelRevision, policyRevision, preprocessingRevision, contentDigest, inputTokens, inspectedTokens, windows, enforced });
67
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app.
3
- * A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and Hono, plus an Express/Connect
4
- * adapter.
2
+ * Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
3
+ * (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
4
+ * Hono, plus an Express/Connect adapter.
5
5
  *
6
6
  * @example
7
7
  * ```ts
@@ -9,14 +9,16 @@
9
9
  *
10
10
  * const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
11
11
  * export default {
12
- * async fetch(request: Request): Promise<Response> {
13
- * return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
12
+ * async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
13
+ * return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
14
14
  * },
15
15
  * };
16
16
  * ```
17
17
  *
18
18
  * @module
19
19
  */
20
+ import { type TrafficOptions, type WaitUntil } from './traffic.ts';
21
+ export { AI_AGENTS, classifyAgent, trafficCounter, type AgentKind, type AiAgent, type TrafficBatch, type TrafficCounter, type TrafficOptions, type TrafficRow, type WaitUntil } from './traffic.ts';
20
22
  /** Options for {@link llmsInkHandler} and {@link llmsInkExpress}. */
21
23
  export interface LlmsInkHandlerOptions {
22
24
  /** The deployed site to describe, e.g. `https://example.com`. */
@@ -33,9 +35,22 @@ export interface LlmsInkHandlerOptions {
33
35
  markdown?: boolean;
34
36
  /** Defaults to the `LLMS_INK_API` environment variable, else https://llms.ink. */
35
37
  baseUrl?: string;
38
+ /**
39
+ * Count requests from AI agents (ChatGPT-User, Claude-User, GPTBot, ClaudeBot… see `AI_AGENTS`) per path and send
40
+ * daily aggregates to llms.ink every `flushEvery` seconds; read them at https://llms.ink/traffic/. Sends only
41
+ * `{ path, agent, kind, count }`: no IPs, user agents, query strings or cookies. Off unless set.
42
+ */
43
+ traffic?: TrafficOptions;
44
+ }
45
+ /**
46
+ * A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. Pass the
47
+ * platform's `ctx` (Workers' `ExecutionContext`, Next.js's `NextFetchEvent`) so traffic sends outlive the response.
48
+ */
49
+ export interface LlmsInkHandler {
50
+ (request: Request, ctx?: WaitUntil): Promise<Response | undefined>;
51
+ /** Sends the traffic counted so far (with `traffic`); resolves at once without it. */
52
+ flush(): Promise<void>;
36
53
  }
37
- /** A fetch-API handler: a `Response` for the requests it serves, `undefined` for everything else. */
38
- export type LlmsInkHandler = (request: Request) => Promise<Response | undefined>;
39
54
  /**
40
55
  * Answers `GET`/`HEAD` `/llms.txt` and `/llms-full.txt` with llms.ink's generated files for `site`
41
56
  * (`native=0`, so a site whose /llms.txt is this handler can't loop), and, with `markdown: true`, Markdown
@@ -59,6 +74,10 @@ export interface NodeResponse {
59
74
  end(chunk: Uint8Array): unknown;
60
75
  }
61
76
  /** Express/Connect middleware: `next()` when the request isn't one {@link llmsInkHandler} serves. */
62
- export type NodeMiddleware = (req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void) => void;
77
+ export interface NodeMiddleware {
78
+ (req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void): void;
79
+ /** Sends the traffic counted so far (with `traffic`). */
80
+ flush(): Promise<void>;
81
+ }
63
82
  /** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
64
83
  export declare function llmsInkExpress(options: LlmsInkHandlerOptions): NodeMiddleware;
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app.
3
- * A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and Hono, plus an Express/Connect
4
- * adapter.
2
+ * Serve llms.txt, llms-full.txt and (optionally) Markdown versions of pages from llms.ink, inside your own app, and
3
+ * (optionally) count AI-agent traffic. A fetch-API handler for Cloudflare Workers, Next.js middleware, Deno, Bun and
4
+ * Hono, plus an Express/Connect adapter.
5
5
  *
6
6
  * @example
7
7
  * ```ts
@@ -9,8 +9,8 @@
9
9
  *
10
10
  * const llms = llmsInkHandler({ site: 'https://example.com', markdown: true });
11
11
  * export default {
12
- * async fetch(request: Request): Promise<Response> {
13
- * return (await llms(request)) ?? fetch(request); // undefined: not ours, the app answers
12
+ * async fetch(request: Request, env: unknown, ctx: ExecutionContext): Promise<Response> {
13
+ * return (await llms(request, ctx)) ?? fetch(request); // undefined: not ours, the app answers
14
14
  * },
15
15
  * };
16
16
  * ```
@@ -19,6 +19,8 @@
19
19
  */
20
20
  import { env } from "./env.js";
21
21
  import { DEFAULT_BASE_URL, VERSION } from "./index.js";
22
+ import { trafficCounter } from "./traffic.js";
23
+ export { AI_AGENTS, classifyAgent, trafficCounter } from "./traffic.js";
22
24
  const FILES = { '/llms.txt': 'llms', '/llms-full.txt': 'full' };
23
25
  /** llms.ink's own fetcher (`compatible; llms.ink/0.1`): answering it from llms.ink would loop. */
24
26
  const LLMS_INK_FETCHER = /\bllms\.ink\/\d/;
@@ -44,6 +46,7 @@ export function llmsInkHandler(options) {
44
46
  headers.authorization = `Bearer ${key}`;
45
47
  const cache = new Map();
46
48
  const pending = new Map();
49
+ const traffic = options.traffic ? trafficCounter({ ...options.traffic, site: options.traffic.site ?? site.hostname, baseUrl: base }) : null;
47
50
  /** Fetches `upstream`; on failure the last good copy, else llms.ink's error. */
48
51
  async function refresh(upstream) {
49
52
  const last = cache.get(upstream);
@@ -85,7 +88,8 @@ export function llmsInkHandler(options) {
85
88
  // Concurrent requests share one fetch; each gets its own copy of the body.
86
89
  return (await loading).clone();
87
90
  }
88
- return async (request) => {
91
+ const handler = async (request, ctx) => {
92
+ traffic?.record(request, ctx);
89
93
  if (request.method !== 'GET' && request.method !== 'HEAD')
90
94
  return undefined;
91
95
  if (LLMS_INK_FETCHER.test(request.headers.get('user-agent') ?? ''))
@@ -110,11 +114,12 @@ export function llmsInkHandler(options) {
110
114
  }
111
115
  return request.method === 'HEAD' ? new Response(null, response) : response;
112
116
  };
117
+ return Object.assign(handler, { flush: () => traffic?.flush() ?? Promise.resolve() });
113
118
  }
114
119
  /** {@link llmsInkHandler} as Express/Connect middleware: `app.use(llmsInkExpress({ site }))`. */
115
120
  export function llmsInkExpress(options) {
116
121
  const handle = llmsInkHandler(options);
117
- return (req, res, next) => {
122
+ const middleware = (req, res, next) => {
118
123
  const headers = new Headers();
119
124
  for (const name of ['accept', 'user-agent']) {
120
125
  const value = req.headers[name];
@@ -132,6 +137,7 @@ export function llmsInkExpress(options) {
132
137
  })
133
138
  .catch(next);
134
139
  };
140
+ return Object.assign(middleware, { flush: handle.flush });
135
141
  }
136
142
  function pick(from, names) {
137
143
  const to = new Headers();
@@ -0,0 +1,68 @@
1
+ /**
2
+ * AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
3
+ * aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
4
+ * your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
5
+ * strings, no cookies, nothing about people who visit.
6
+ *
7
+ * @module
8
+ */
9
+ /** `user`: fetched because someone asked an assistant; `crawler`: indexing for search or collecting for training. */
10
+ export type AgentKind = 'user' | 'crawler';
11
+ /** One AI agent: the name it puts in its User-Agent (matched case-insensitively), what kind it is, who runs it. */
12
+ export interface AiAgent {
13
+ agent: string;
14
+ kind: AgentKind;
15
+ operator: string;
16
+ }
17
+ /**
18
+ * The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
19
+ * `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
20
+ */
21
+ export declare const AI_AGENTS: readonly AiAgent[];
22
+ /** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
23
+ export declare function classifyAgent(userAgent: string | null | undefined): AiAgent | null;
24
+ /** Options for {@link trafficCounter}, and the middleware's `traffic` option. */
25
+ export interface TrafficOptions {
26
+ /** API key (`lk_…`). The first key to send counts for a site owns it; read them back with the same key. */
27
+ key: string;
28
+ /** Hostname the counts are filed under, e.g. `example.com`. The middleware defaults it to its `site`. */
29
+ site?: string;
30
+ /** Seconds between sends. Default 60, at least 10. */
31
+ flushEvery?: number;
32
+ }
33
+ /** One aggregate: requests from one agent to one path in one UTC day. */
34
+ export interface TrafficRow {
35
+ path: string;
36
+ agent: string;
37
+ kind: AgentKind;
38
+ count: number;
39
+ }
40
+ /** The body of `POST /api/v0/traffic`. */
41
+ export interface TrafficBatch {
42
+ site: string;
43
+ /** UTC day, `YYYY-MM-DD`. */
44
+ period: string;
45
+ rows: TrafficRow[];
46
+ }
47
+ /** Workers' `ExecutionContext`, Next.js's `NextFetchEvent`: keeps a send alive after the response. */
48
+ export interface WaitUntil {
49
+ waitUntil(promise: Promise<unknown>): void;
50
+ }
51
+ /** Counts AI-agent requests and sends them in the background. */
52
+ export interface TrafficCounter {
53
+ /**
54
+ * Counts the request when an AI agent sent it (`GET`/`HEAD` only). When a send is due it starts one and hands it
55
+ * to `ctx.waitUntil`; without `ctx`, a background timer sends. Never throws and never waits.
56
+ */
57
+ record(request: Request, ctx?: WaitUntil): void;
58
+ /** Sends everything counted so far. Never rejects; failed sends are kept and retried with backoff. */
59
+ flush(): Promise<void>;
60
+ }
61
+ /**
62
+ * A counter for one site. Use it directly in servers the middleware doesn't cover:
63
+ * `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
64
+ */
65
+ export declare function trafficCounter(options: TrafficOptions & {
66
+ site: string;
67
+ baseUrl?: string;
68
+ }): TrafficCounter;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * AI traffic: which AI agents fetch which pages of your site, counted in memory and sent to llms.ink as daily
3
+ * aggregates (`POST /api/v0/traffic`). Only requests from the agents in {@link AI_AGENTS} are counted; what leaves
4
+ * your server is `{ site, period, rows: [{ path, agent, kind, count }] }` — no IPs, no user agents, no query
5
+ * strings, no cookies, nothing about people who visit.
6
+ *
7
+ * @module
8
+ */
9
+ import { DEFAULT_BASE_URL, VERSION } from "./index.js";
10
+ /**
11
+ * The agents counted, from the operators' published bot lists. Robots-only tokens (`Google-Extended`,
12
+ * `Applebot-Extended`) never appear in a User-Agent, so they aren't here: Googlebot and Applebot do that crawling.
13
+ */
14
+ export const AI_AGENTS = [
15
+ // Fetches on behalf of someone asking an assistant right now.
16
+ { agent: 'ChatGPT-User', kind: 'user', operator: 'OpenAI' },
17
+ { agent: 'Claude-User', kind: 'user', operator: 'Anthropic' },
18
+ { agent: 'Perplexity-User', kind: 'user', operator: 'Perplexity' },
19
+ { agent: 'MistralAI-User', kind: 'user', operator: 'Mistral' },
20
+ { agent: 'DuckAssistBot', kind: 'user', operator: 'DuckDuckGo' },
21
+ { agent: 'meta-externalfetcher', kind: 'user', operator: 'Meta' },
22
+ { agent: 'Google-NotebookLM', kind: 'user', operator: 'Google' },
23
+ { agent: 'Amzn-User', kind: 'user', operator: 'Amazon' },
24
+ // Crawlers: AI search indexes, training collection, and the search engines AI answers draw on.
25
+ { agent: 'OAI-SearchBot', kind: 'crawler', operator: 'OpenAI' },
26
+ { agent: 'GPTBot', kind: 'crawler', operator: 'OpenAI' },
27
+ { agent: 'Claude-SearchBot', kind: 'crawler', operator: 'Anthropic' },
28
+ { agent: 'ClaudeBot', kind: 'crawler', operator: 'Anthropic' },
29
+ { agent: 'anthropic-ai', kind: 'crawler', operator: 'Anthropic' },
30
+ { agent: 'PerplexityBot', kind: 'crawler', operator: 'Perplexity' },
31
+ { agent: 'Google-CloudVertexBot', kind: 'crawler', operator: 'Google' },
32
+ { agent: 'Googlebot', kind: 'crawler', operator: 'Google' },
33
+ { agent: 'bingbot', kind: 'crawler', operator: 'Microsoft' },
34
+ { agent: 'Applebot', kind: 'crawler', operator: 'Apple' },
35
+ { agent: 'Amzn-SearchBot', kind: 'crawler', operator: 'Amazon' },
36
+ { agent: 'Amazonbot', kind: 'crawler', operator: 'Amazon' },
37
+ { agent: 'meta-externalagent', kind: 'crawler', operator: 'Meta' },
38
+ { agent: 'Bytespider', kind: 'crawler', operator: 'ByteDance' },
39
+ { agent: 'CCBot', kind: 'crawler', operator: 'Common Crawl' },
40
+ { agent: 'cohere-ai', kind: 'crawler', operator: 'Cohere' },
41
+ { agent: 'YouBot', kind: 'crawler', operator: 'You.com' },
42
+ { agent: 'DuckDuckBot', kind: 'crawler', operator: 'DuckDuckGo' },
43
+ ];
44
+ const BY_TOKEN = new Map(AI_AGENTS.map((a) => [a.agent.toLowerCase(), a]));
45
+ /** One pass over the User-Agent: a known name not glued to a preceding letter, digit or hyphen. */
46
+ const MATCH = new RegExp(`(?:^|[^a-z0-9-])(${AI_AGENTS.map((a) => a.agent.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')).join('|')})`, 'i');
47
+ /** The AI agent behind a User-Agent header, or null for browsers, other bots and anything unknown. */
48
+ export function classifyAgent(userAgent) {
49
+ if (!userAgent)
50
+ return null;
51
+ const match = MATCH.exec(userAgent);
52
+ return match ? BY_TOKEN.get(match[1].toLowerCase()) ?? null : null;
53
+ }
54
+ /** Rows per POST (the API's limit). */
55
+ const ROWS_PER_POST = 1000;
56
+ /** Distinct path/agent/day rows kept in memory; past it, new paths count as `(other)`. */
57
+ const MAX_PENDING = 5000;
58
+ /** The path new rows are folded into once {@link MAX_PENDING} is reached. */
59
+ const OTHER_PATH = '/(other)';
60
+ const MAX_PATH = 512;
61
+ const MAX_BACKOFF_MS = 3_600_000;
62
+ const SEND_TIMEOUT_MS = 10_000;
63
+ /** The path only: no query string or fragment (URL.pathname has neither), capped in length. */
64
+ function cleanPath(url) {
65
+ const path = new URL(url).pathname || '/';
66
+ return path.length > MAX_PATH ? path.slice(0, MAX_PATH) : path;
67
+ }
68
+ /**
69
+ * A counter for one site. Use it directly in servers the middleware doesn't cover:
70
+ * `counter.record(request, ctx)` for each request, `await counter.flush()` before shutting down.
71
+ */
72
+ export function trafficCounter(options) {
73
+ const key = options.key?.trim();
74
+ if (!key)
75
+ throw new TypeError('traffic needs an llms.ink API key (traffic: { key: "lk_…" }).');
76
+ // `https://Example.com/x` or `example.com` → `example.com`.
77
+ const site = new URL(/^https?:\/\//i.test(options.site) ? options.site : `https://${options.site}`).hostname.toLowerCase();
78
+ const every = Math.max(10, options.flushEvery ?? 60) * 1000;
79
+ const endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')}/api/v0/traffic`;
80
+ const headers = { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'user-agent': `llms-ink-js/${VERSION}` };
81
+ /** `period\tpath\tagent` → row. */
82
+ const pending = new Map();
83
+ let lastSend = Date.now();
84
+ let retryAt = 0;
85
+ let backoff = 0;
86
+ let inflight = null;
87
+ let timer;
88
+ /** Set when llms.ink refuses the key or the site: counting stops, once, with a warning. */
89
+ let stopped = false;
90
+ function add(period, path, agent, kind, count) {
91
+ let id = `${period}\t${path}\t${agent}`;
92
+ let row = pending.get(id);
93
+ if (!row && pending.size >= MAX_PENDING) {
94
+ path = OTHER_PATH;
95
+ id = `${period}\t${path}\t${agent}`;
96
+ row = pending.get(id);
97
+ }
98
+ if (row)
99
+ row.count += count;
100
+ else
101
+ pending.set(id, { period, path, agent, kind, count });
102
+ }
103
+ function stop(message) {
104
+ stopped = true;
105
+ pending.clear();
106
+ clearTimeout(timer);
107
+ console.warn(`llms-ink traffic: ${message} Counting stopped.`);
108
+ }
109
+ function retryLater(rows, retryAfter) {
110
+ for (const r of rows)
111
+ add(r.period, r.path, r.agent, r.kind, r.count);
112
+ backoff = Math.min(MAX_BACKOFF_MS, Math.max(every, backoff * 2));
113
+ retryAt = Date.now() + Math.max(backoff, (retryAfter ?? 0) * 1000);
114
+ }
115
+ /** One POST; resolves to what to do with its rows. */
116
+ async function send(batch) {
117
+ let res;
118
+ try {
119
+ res = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(batch), signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
120
+ }
121
+ catch {
122
+ return { retryAfter: null };
123
+ }
124
+ if (res.ok) {
125
+ await res.body?.cancel();
126
+ return 'sent';
127
+ }
128
+ // llms.ink's problem details (`code`, `detail`) say why a batch was refused.
129
+ const problem = await res.json().catch(() => null);
130
+ const why = problem ? `${problem.code ?? ''}: ${problem.detail ?? ''}` : '';
131
+ if (res.status === 429 || res.status >= 500)
132
+ return { retryAfter: Number(res.headers.get('retry-after')) || null };
133
+ if (res.status === 401 || res.status === 403) {
134
+ stop(`llms.ink refused ${site} (${res.status} ${why || 'check the key'}).`);
135
+ return 'stop';
136
+ }
137
+ console.warn(`llms-ink traffic: llms.ink rejected a batch for ${site} (${res.status} ${why}).`);
138
+ return 'drop';
139
+ }
140
+ async function sendAll(rows) {
141
+ const batches = [];
142
+ const byPeriod = new Map();
143
+ for (const r of rows)
144
+ byPeriod.set(r.period, [...(byPeriod.get(r.period) ?? []), r]);
145
+ for (const [period, list] of byPeriod) {
146
+ for (let i = 0; i < list.length; i += ROWS_PER_POST) {
147
+ const chunk = list.slice(i, i + ROWS_PER_POST);
148
+ batches.push({ batch: { site, period, rows: chunk.map(({ path, agent, kind, count }) => ({ path, agent, kind, count })) }, rows: chunk });
149
+ }
150
+ }
151
+ for (let i = 0; i < batches.length; i++) {
152
+ const outcome = await send(batches[i].batch);
153
+ if (outcome === 'stop')
154
+ return;
155
+ if (typeof outcome === 'object') {
156
+ retryLater(batches.slice(i).flatMap((b) => b.rows), outcome.retryAfter);
157
+ return;
158
+ }
159
+ }
160
+ backoff = 0;
161
+ retryAt = 0;
162
+ }
163
+ /** Waits for a send in flight, then sends what was counted meanwhile. */
164
+ async function flush() {
165
+ while (inflight)
166
+ await inflight;
167
+ if (!pending.size || stopped)
168
+ return;
169
+ clearTimeout(timer);
170
+ timer = undefined;
171
+ const rows = [...pending.values()];
172
+ pending.clear();
173
+ lastSend = Date.now();
174
+ inflight = sendAll(rows)
175
+ .catch(() => { })
176
+ .finally(() => {
177
+ inflight = null;
178
+ if (pending.size && !stopped)
179
+ arm();
180
+ });
181
+ await inflight;
182
+ }
183
+ /** A background send where nothing keeps one alive (Node, Bun, Deno); unref'd so it never holds the process open. */
184
+ function arm() {
185
+ if (timer)
186
+ return;
187
+ const delay = Math.max(1000, Math.max(lastSend + every, retryAt) - Date.now());
188
+ timer = setTimeout(() => {
189
+ timer = undefined;
190
+ void flush();
191
+ }, delay);
192
+ // Node's and Bun's timers have unref(); elsewhere setTimeout returns a number.
193
+ if (typeof timer === 'object' && 'unref' in timer)
194
+ timer.unref();
195
+ }
196
+ return {
197
+ record(request, ctx) {
198
+ try {
199
+ if (stopped || (request.method !== 'GET' && request.method !== 'HEAD'))
200
+ return;
201
+ const agent = classifyAgent(request.headers.get('user-agent'));
202
+ if (!agent)
203
+ return;
204
+ const now = Date.now();
205
+ add(new Date(now).toISOString().slice(0, 10), cleanPath(request.url), agent.agent, agent.kind, 1);
206
+ const due = now - lastSend >= every || pending.size >= ROWS_PER_POST;
207
+ if (!inflight && now >= retryAt && due) {
208
+ const sending = flush();
209
+ ctx?.waitUntil(sending);
210
+ }
211
+ else if (!ctx) {
212
+ arm();
213
+ }
214
+ }
215
+ catch {
216
+ // Counting must never break the site.
217
+ }
218
+ },
219
+ flush,
220
+ };
221
+ }
package/dist/vite.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output.
2
+ * Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
3
+ * `markdownPages: true` a Markdown version next to every built HTML page.
3
4
  *
4
5
  * @example
5
6
  * ```ts
@@ -13,13 +14,14 @@
13
14
  * @module
14
15
  */
15
16
  import type { Plugin } from 'vite';
16
- import { type LlmsFilesOptions } from './files.ts';
17
- export type { LlmsFilesOptions };
17
+ import { type LlmsFilesOptions, type MarkdownPagesOptions } from './files.ts';
18
+ export type { LlmsFilesOptions, MarkdownPagesOptions };
18
19
  /**
19
20
  * After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
20
21
  * `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
21
22
  * describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
22
- * copied from `public/`); `strict: true` fails the build instead.
23
+ * copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
24
+ * of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
23
25
  */
24
26
  export declare function llmsInk(options: LlmsFilesOptions): Plugin;
25
27
  export default llmsInk;
package/dist/vite.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output.
2
+ * Vite plugin: writes llms.txt (and llms-full.txt) from llms.ink into the build output, and with
3
+ * `markdownPages: true` a Markdown version next to every built HTML page.
3
4
  *
4
5
  * @example
5
6
  * ```ts
@@ -18,7 +19,8 @@ import { llmsFilesWriter } from "./files.js";
18
19
  * After `vite build` (not the dev server, not SSR builds), fetches llms.txt — and llms-full.txt with
19
20
  * `full: true` — for `site` and writes them into `build.outDir`. llms.ink reads the live site, so the files
20
21
  * describe the previous deploy. When llms.ink can't be reached it warns and keeps any existing file (e.g. one
21
- * copied from `public/`); `strict: true` fails the build instead.
22
+ * copied from `public/`); `strict: true` fails the build instead. `markdownPages: true` converts each `.html` file
23
+ * of this build (not the deploy) through `POST /api/v0/markdown` and writes `about.md` / `docs/index.md` next to it.
22
24
  */
23
25
  export function llmsInk(options) {
24
26
  const write = llmsFilesWriter(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llms-ink",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Typed client for llms.ink, plus a Vite plugin, an Astro integration and middleware that serve the files from your site. No dependencies.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,9 +31,9 @@
31
31
  "scripts": {
32
32
  "build": "rm -rf dist && tsc -p tsconfig.json",
33
33
  "prepublishOnly": "npm run build && npm test",
34
- "test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs",
34
+ "test": "npm run build && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs test/traffic.test.mjs test/inspect.test.mjs",
35
35
  "test:live": "npm run build && node --test test/live.test.mjs",
36
- "test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs",
36
+ "test:bun": "npm run build && bun test ./test/client.test.mjs ./test/plugins.test.mjs ./test/middleware.test.mjs ./test/traffic.test.mjs ./test/inspect.test.mjs",
37
37
  "test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs"
38
38
  },
39
39
  "keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware"],
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^22.20.5",
53
- "astro": "^7.4.1",
53
+ "astro": "^7.4.7",
54
54
  "typescript": "^5.9.3",
55
55
  "vite": "^8.3.8"
56
56
  }