llms-ink 0.4.0 → 0.6.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
@@ -3,7 +3,7 @@
3
3
  Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. A typed
4
4
  client for the [llms.ink](https://llms.ink) API with no dependencies. Runs on Node 18+, Bun, Deno, Cloudflare
5
5
  Workers and browsers. Also a [Vite plugin, an Astro integration and middleware](#serve-llmstxt-from-your-site) that
6
- serve llms.txt from your own site.
6
+ serve llms.txt from your own site, and [tools for the Vercel AI SDK](#vercel-ai-sdk-llms-inkai).
7
7
 
8
8
  ```sh
9
9
  npm install llms-ink # or: bun add llms-ink
@@ -84,6 +84,13 @@ fetched) resolves relative links. Up to 5 MB; nothing is stored.
84
84
  const md = await ink.markdown(await readFile('dist/pricing/index.html', 'utf8'), { url: 'https://example.com/pricing/' });
85
85
  ```
86
86
 
87
+ ### `ink.report(url, { signal? })` → `Promise<ReportResult>`
88
+
89
+ How AI crawlers and agents see a page (`GET /api/v0/report`): robots.txt per AI bot, firewall blocks, text without
90
+ JavaScript, what survives Markdown conversion, hidden instructions and agent files. `{ url, site, checkedAt,
91
+ summary: { pass, warn, fail, info }, sections: [{ id, title, checks: [{ id, status, title, detail, evidence?, fix? }] }] }`;
92
+ a `fix` has `kind`, `summary` and, when possible, a `patch: { file, content }`.
93
+
87
94
  ### `ink.stream(url, { mode?, fresh?, signal? })` → `AsyncGenerator<StreamEvent>`
88
95
 
89
96
  The same build as `llms()`, as it happens:
@@ -178,6 +185,65 @@ sent are lost when the process stops: call `await llms.flush()` on shutdown. Rea
178
185
  `classifyAgent(userAgent)` and `trafficCounter({ key, site })` (`record(request, ctx?)`, `flush()`) are exported for
179
186
  other servers.
180
187
 
188
+ ## Vercel AI SDK: `llms-ink/ai`
189
+
190
+ ```sh
191
+ npm install llms-ink ai zod
192
+ ```
193
+
194
+ ```ts
195
+ import { generateText, stepCountIs } from 'ai';
196
+ import { llmsInkTools } from 'llms-ink/ai';
197
+
198
+ const { text } = await generateText({
199
+ model: 'openai/gpt-5',
200
+ tools: llmsInkTools(),
201
+ stopWhen: stepCountIs(5),
202
+ prompt: 'How do I paginate with the Stripe API? Read docs.stripe.com first.',
203
+ });
204
+ ```
205
+
206
+ `llmsInkTools({ client?, maxChars?, include? })` returns AI SDK tools (`tool()` with zod `inputSchema`, AI SDK 5 and
207
+ later; tested with 7) for `generateText`, `streamText` and agents:
208
+
209
+ | Tool | Input | Returns |
210
+ |---|---|---|
211
+ | `llmsTxt` | `url`, `fresh?` | `{ url, title, kind, source, tokens, pages, cached, text, truncated }`: the llms.txt index |
212
+ | `llmsFullTxt` | `url`, `fresh?` | The same with llms-full.txt: up to 20 pages inlined, or a video's transcript |
213
+ | `checkLlmsTxt` | `url`, or `text` with `base?`, `links?` | `{ url, site, found, summary, text, truncated }`: each issue with its line |
214
+ | `agentView` | `url` | `{ url, site, summary, text, truncated }`: every check of `ink.report()` with its fix |
215
+
216
+ - `client`: an `LlmsInk`, or its options (`{ key, baseUrl, fetch }`; `key` defaults to `LLMS_INK_KEY`).
217
+ - `maxChars`: longest `text` a tool returns (default 40000). Longer text is cut at a line break and ends with
218
+ `[Truncated: showing N of M characters. …]`, with `truncated: true`.
219
+ - `include`: only these tools, e.g. `llmsInkTools({ include: ['llmsTxt', 'checkLlmsTxt'] })`. The return type
220
+ narrows to them.
221
+
222
+ API errors don't throw: the tool returns `{ error, code?, retryAfter? }` for the model to read, e.g.
223
+ `{ error: 'llms.ink rate limit: wait 42 seconds, then try again.', code: 'rate_limited', retryAfter: 42 }`.
224
+ An invalid API key (`InvalidKeyError`) and aborts (`abortSignal`) throw.
225
+
226
+ ### Or connect the MCP server
227
+
228
+ The AI SDK also takes tools from an MCP client. https://llms.ink/mcp gives the same tools plus `video_frame`,
229
+ `create_clip_link` and `generate_draft`, with snake_case names (`llms_txt`, `llms_full_txt`…) and text results:
230
+
231
+ ```ts
232
+ import { createMCPClient } from '@ai-sdk/mcp'; // AI SDK 5: @ai-sdk/mcp@ai-v5, as experimental_createMCPClient
233
+
234
+ const mcp = await createMCPClient({
235
+ transport: { type: 'http', url: 'https://llms.ink/mcp', headers: { authorization: `Bearer ${process.env.LLMS_INK_KEY}` } },
236
+ });
237
+ try {
238
+ const { text } = await generateText({ model: 'openai/gpt-5', tools: await mcp.tools(), stopWhen: stepCountIs(5), prompt });
239
+ } finally {
240
+ await mcp.close();
241
+ }
242
+ ```
243
+
244
+ Leave out `headers` to use it anonymously. `llms-ink/ai` needs no extra package or connection and returns
245
+ structured results cut to `maxChars`.
246
+
181
247
  ## Errors
182
248
 
183
249
  Every failure is an `LlmsInkError` with `status`, `code`, `title`, `detail`, `errors` (per-parameter issues of
@@ -211,3 +277,27 @@ gets its own daily quota (1000 new links by default) and 60 requests a minute;
211
277
  ## License
212
278
 
213
279
  MIT
280
+
281
+ ## Hosted Inspect (experimental, 0.5.0)
282
+
283
+ ```ts
284
+ // Server-side; requires llms-ink >=0.5.0.
285
+ import { LlmsInk } from 'llms-ink';
286
+
287
+ const ink = new LlmsInk({ key: process.env.LLMS_INK_INSPECT_KEY });
288
+ const receipt = await ink.inspect('Install the package and read the documentation.');
289
+ if (receipt.recommendation !== 'continue') {
290
+ throw new Error(`Withhold content: ${receipt.detection}`);
291
+ }
292
+ console.log(receipt.contentDigest);
293
+ ```
294
+
295
+ 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.
296
+
297
+ 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.
298
+
299
+ 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.
300
+
301
+ 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/).
302
+
303
+ JavaScript uses a 65-second total request deadline; pass `{ signal }` to cancel earlier.
package/dist/ai.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * llms.ink as Vercel AI SDK tools (`ai` 5 and later, with `zod`):
3
+ *
4
+ * ```ts
5
+ * import { generateText, stepCountIs } from 'ai';
6
+ * import { llmsInkTools } from 'llms-ink/ai';
7
+ *
8
+ * const { text } = await generateText({
9
+ * model: 'openai/gpt-5',
10
+ * tools: llmsInkTools(),
11
+ * stopWhen: stepCountIs(5),
12
+ * prompt: 'How do I paginate with the Stripe API? Read docs.stripe.com first.',
13
+ * });
14
+ * ```
15
+ *
16
+ * Results are cut to `maxChars` with a note saying so. API errors come back as `{ error, code, retryAfter? }`
17
+ * for the model to read; an invalid API key throws.
18
+ *
19
+ * @module
20
+ */
21
+ import { type Tool } from 'ai';
22
+ import { LlmsInk, type Kind, type LlmsInkOptions, type Source } from './index.ts';
23
+ /** The tools {@link llmsInkTools} can return, by name. */
24
+ export type LlmsInkToolName = 'llmsTxt' | 'llmsFullTxt' | 'checkLlmsTxt' | 'agentView';
25
+ /** Options for {@link llmsInkTools}. */
26
+ export interface LlmsInkToolsOptions<K extends LlmsInkToolName = LlmsInkToolName> {
27
+ /** A client, or options for a new one (`key` defaults to `LLMS_INK_KEY`). */
28
+ client?: LlmsInk | LlmsInkOptions;
29
+ /** Longest text a tool returns, in characters; longer text is cut and says so. Default 40000. */
30
+ maxChars?: number;
31
+ /** Only these tools. Default: all four. */
32
+ include?: readonly K[];
33
+ }
34
+ /** Input of `llmsTxt` and `llmsFullTxt`. */
35
+ export interface LinkInput {
36
+ url: string;
37
+ fresh?: boolean;
38
+ }
39
+ /** Input of `checkLlmsTxt`: `url`, or `text` with optional `base` and `links`. */
40
+ export interface CheckInput {
41
+ url?: string;
42
+ text?: string;
43
+ base?: string;
44
+ links?: boolean;
45
+ }
46
+ /** Input of `agentView`. */
47
+ export interface AgentViewInput {
48
+ url: string;
49
+ }
50
+ /** What a tool returns instead of throwing when llms.ink answers with an error or can't be reached. */
51
+ export interface LlmsInkToolError {
52
+ /** A sentence for the model, e.g. "llms.ink rate limit: wait 42 seconds, then try again." */
53
+ error: string;
54
+ /** The API's error code (`rate_limited`, `invalid_url`…); absent when llms.ink couldn't be reached. */
55
+ code?: string;
56
+ /** Seconds to wait before trying again, when llms.ink said so. */
57
+ retryAfter?: number;
58
+ }
59
+ /** `llmsTxt` / `llmsFullTxt` result. */
60
+ export interface LlmsTextOutput {
61
+ url: string;
62
+ title: string;
63
+ kind: Kind;
64
+ source: Source;
65
+ /** Approximate tokens of the whole file, before any cut. */
66
+ tokens: number;
67
+ /** Links in llms.txt, or pages inlined in llms-full.txt. */
68
+ pages: number;
69
+ cached: boolean;
70
+ text: string;
71
+ /** `text` was cut to `maxChars`; it ends with a note saying so. */
72
+ truncated: boolean;
73
+ }
74
+ /** `checkLlmsTxt` result: `text` is a short report with each issue and its line. */
75
+ export interface CheckOutput {
76
+ url: string | null;
77
+ site: string | null;
78
+ found: boolean;
79
+ summary: {
80
+ errors: number;
81
+ warnings: number;
82
+ };
83
+ text: string;
84
+ truncated: boolean;
85
+ }
86
+ /** `agentView` result: `text` lists every check with its status, evidence and fix. */
87
+ export interface AgentViewOutput {
88
+ url: string;
89
+ site: string;
90
+ summary: {
91
+ pass: number;
92
+ warn: number;
93
+ fail: number;
94
+ info: number;
95
+ };
96
+ text: string;
97
+ truncated: boolean;
98
+ }
99
+ /** Every tool, by name. */
100
+ export interface LlmsInkTools {
101
+ llmsTxt: Tool<LinkInput, LlmsTextOutput | LlmsInkToolError>;
102
+ llmsFullTxt: Tool<LinkInput, LlmsTextOutput | LlmsInkToolError>;
103
+ checkLlmsTxt: Tool<CheckInput, CheckOutput | LlmsInkToolError>;
104
+ agentView: Tool<AgentViewInput, AgentViewOutput | LlmsInkToolError>;
105
+ }
106
+ export declare const DEFAULT_MAX_CHARS = 40000;
107
+ /**
108
+ * llms.ink tools for `generateText` / `streamText` / agents of the Vercel AI SDK: `llmsTxt`, `llmsFullTxt`,
109
+ * `checkLlmsTxt` and `agentView`, or the ones in `include`.
110
+ */
111
+ export declare function llmsInkTools<K extends LlmsInkToolName = LlmsInkToolName>(options?: LlmsInkToolsOptions<K>): Pick<LlmsInkTools, K>;
package/dist/ai.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * llms.ink as Vercel AI SDK tools (`ai` 5 and later, with `zod`):
3
+ *
4
+ * ```ts
5
+ * import { generateText, stepCountIs } from 'ai';
6
+ * import { llmsInkTools } from 'llms-ink/ai';
7
+ *
8
+ * const { text } = await generateText({
9
+ * model: 'openai/gpt-5',
10
+ * tools: llmsInkTools(),
11
+ * stopWhen: stepCountIs(5),
12
+ * prompt: 'How do I paginate with the Stripe API? Read docs.stripe.com first.',
13
+ * });
14
+ * ```
15
+ *
16
+ * Results are cut to `maxChars` with a note saying so. API errors come back as `{ error, code, retryAfter? }`
17
+ * for the model to read; an invalid API key throws.
18
+ *
19
+ * @module
20
+ */
21
+ import { tool } from 'ai';
22
+ import { z } from 'zod';
23
+ import { InvalidKeyError, LlmsInk, LlmsInkError, QuotaExceededError, RateLimitedError, } from "./index.js";
24
+ export const DEFAULT_MAX_CHARS = 40_000;
25
+ const NAMES = ['llmsTxt', 'llmsFullTxt', 'checkLlmsTxt', 'agentView'];
26
+ /**
27
+ * llms.ink tools for `generateText` / `streamText` / agents of the Vercel AI SDK: `llmsTxt`, `llmsFullTxt`,
28
+ * `checkLlmsTxt` and `agentView`, or the ones in `include`.
29
+ */
30
+ export function llmsInkTools(options = {}) {
31
+ const max = options.maxChars ?? DEFAULT_MAX_CHARS;
32
+ if (!Number.isInteger(max) || max < 1)
33
+ throw new TypeError(`maxChars must be a positive integer, not ${max}`);
34
+ const include = options.include ?? NAMES;
35
+ for (const name of include) {
36
+ if (!NAMES.includes(name))
37
+ throw new TypeError(`Unknown llms.ink tool ${JSON.stringify(name)}: use ${NAMES.join(', ')}.`);
38
+ }
39
+ const given = options.client;
40
+ const ink = given && typeof given.llms === 'function' ? given : new LlmsInk(given);
41
+ const llmsTool = (mode, description, hint) => tool({
42
+ description,
43
+ inputSchema: z.object({
44
+ url: z.string().describe('Any public http(s) link, e.g. "docs.stripe.com/payments", "github.com/honojs/hono", a PDF or a video URL. The scheme is optional.'),
45
+ fresh: z.boolean().optional().describe('Ignore the 24h cache and rebuild. Default false.'),
46
+ }),
47
+ execute: (input, { abortSignal }) => attempt(async () => {
48
+ const r = await ink.llms(input.url, { mode, fresh: input.fresh, signal: abortSignal });
49
+ return { url: r.url, title: r.title, kind: r.kind, source: r.source, tokens: r.tokens, pages: r.pages, cached: r.cached, ...cut(r.text, max, hint) };
50
+ }),
51
+ });
52
+ const all = {
53
+ llmsTxt: llmsTool('llms', 'Get an llms.txt index for any link (site, docs section, page, GitHub repo, PDF or video): title, one-line summary, and sections of the most useful links with short descriptions. ' +
54
+ 'Returns the site’s own llms.txt when it has one. Cached 24h.', 'Open only the linked pages you need.'),
55
+ llmsFullTxt: llmsTool('full', 'Get llms-full.txt for any link: the llms.txt index followed by the full Markdown of up to 20 pages it links to ' +
56
+ '(or the page/document itself; for a video, its description and timestamped transcript). Can be large — prefer llmsTxt when you only need to find pages. Cached 24h.', 'Call llmsTxt for the index, then llmsFullTxt on the one page or section you need.'),
57
+ checkLlmsTxt: tool({
58
+ description: 'Check an llms.txt against the format (H1 title, blockquote summary, H2 sections of `- [name](url): notes` links) and size. ' +
59
+ 'Pass `url` to check the file a site publishes, including whether its first 25 links answer; or pass `text` to check a file you have (a draft, a local copy), ' +
60
+ 'with `base` for where it lives and `links` to also test its links. Returns a short report with each issue and its line, and the sections with their link counts.',
61
+ inputSchema: z.object({
62
+ url: z.string().optional().describe('A site, a docs section, or the llms.txt itself, e.g. "llmstxt.org" or "docs.astro.build/llms.txt". Use url or text.'),
63
+ text: z.string().optional().describe('The llms.txt itself (up to 2 MB), instead of url.'),
64
+ base: z.string().optional().describe('With text: the site or llms.txt link it belongs to, e.g. "example.com". Resolves relative links.'),
65
+ links: z.boolean().optional().describe('With text: also check that its first 25 links answer (and llms-full.txt next to base). Default false.'),
66
+ }),
67
+ execute: (input, { abortSignal }) => attempt(async () => {
68
+ if ((input.text === undefined) === !input.url) {
69
+ return { error: 'Pass url (a published llms.txt) or text (the file itself), not both.', code: 'invalid_request' };
70
+ }
71
+ const r = input.text === undefined
72
+ ? await ink.check(input.url, { signal: abortSignal })
73
+ : await ink.checkText(input.text, { base: input.base, links: input.links, signal: abortSignal });
74
+ return { url: r.url, site: r.site, found: r.found, summary: r.summary, ...cut(checkReport(r), max, 'Fix the issues shown, then check again.') };
75
+ }),
76
+ }),
77
+ agentView: tool({
78
+ description: 'Report how AI crawlers and agents see a page: robots.txt per AI bot (search, user fetches, training) and Content-Signal, firewall blocks found by fetching with bot User-Agents, text present without JavaScript, what survives Markdown conversion, ' +
79
+ 'title/first paragraph/facts/noindex/canonical/sitemap, hidden instructions, and llms.txt, .md URLs, Accept: text/markdown, Link headers, ard.json. Each check is pass/warn/fail/info with a fix. ' +
80
+ 'Use it to tell a site owner why assistants can’t read or cite a page and what to change.',
81
+ inputSchema: z.object({
82
+ url: z.string().describe('A site or any page on it, e.g. "example.com" or "docs.example.com/guide". The scheme is optional.'),
83
+ }),
84
+ execute: (input, { abortSignal }) => attempt(async () => {
85
+ const r = await ink.report(input.url, { signal: abortSignal });
86
+ return { url: r.url, site: r.site, summary: r.summary, ...cut(reportText(r), max, 'Fix the failures shown first.') };
87
+ }),
88
+ }),
89
+ };
90
+ const picked = {};
91
+ for (const name of include)
92
+ Object.assign(picked, { [name]: all[name] });
93
+ return picked;
94
+ }
95
+ /** `text` cut to `max` characters (at a line break when one is near), with a note saying what was left out. */
96
+ function cut(text, max, hint) {
97
+ if (text.length <= max)
98
+ return { text, truncated: false };
99
+ let end = max;
100
+ const last = text.charCodeAt(end - 1);
101
+ if (last >= 0xd800 && last <= 0xdbff)
102
+ end--; // don't split a surrogate pair
103
+ const newline = text.lastIndexOf('\n', end);
104
+ if (newline > end * 0.9)
105
+ end = newline;
106
+ return {
107
+ text: `${text.slice(0, end)}\n\n[Truncated: showing ${end} of ${text.length} characters. ${hint}]`,
108
+ truncated: true,
109
+ };
110
+ }
111
+ /** Runs a tool body; llms.ink errors become a {@link LlmsInkToolError}, an invalid key and anything else throw. */
112
+ async function attempt(run) {
113
+ try {
114
+ return await run();
115
+ }
116
+ catch (err) {
117
+ if (!(err instanceof LlmsInkError) || err instanceof InvalidKeyError)
118
+ throw err;
119
+ const wait = err.retryAfter;
120
+ const out = { error: errorText(err, wait) };
121
+ if (err.code)
122
+ out.code = err.code;
123
+ if (wait !== undefined)
124
+ out.retryAfter = wait;
125
+ return out;
126
+ }
127
+ }
128
+ function errorText(err, wait) {
129
+ if (err instanceof RateLimitedError)
130
+ return `llms.ink rate limit: wait ${wait ?? 60} seconds, then try again.`;
131
+ if (err instanceof QuotaExceededError) {
132
+ return `llms.ink daily quota of new links is used up${wait === undefined ? '' : `; it resets in ${wait} seconds (midnight UTC)`}. Cached links still work.`;
133
+ }
134
+ return `llms.ink: ${err.detail}`;
135
+ }
136
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
137
+ /** The check as a short report (the same as the MCP tool's). */
138
+ function checkReport(r) {
139
+ const lines = r.issues.map((i) => `- ${i.level}${i.line ? ` (line ${i.line})` : ''}: ${i.message}`);
140
+ if (!r.found)
141
+ return [`No llms.txt found for ${r.site ?? 'this text'}.`, '', ...lines].join('\n');
142
+ const facts = [
143
+ `${plural(r.summary.errors, 'error')}, ${plural(r.summary.warnings, 'warning')}`,
144
+ plural(r.sections, 'section'),
145
+ `${plural(r.links.total, 'link')} (${r.links.checked || !r.links.total ? `${r.links.checked} checked, ${r.links.broken.length} broken` : 'not checked'})`,
146
+ `~${r.tokens} tokens`,
147
+ ...(r.llmsFull === null ? [] : [`llms-full.txt ${r.llmsFull ? 'found' : 'not found'}`]),
148
+ ];
149
+ const outline = r.parsed?.sections.length
150
+ ? ['', `Sections: ${r.parsed.sections.map((s) => `${s.name} (${plural(s.links.length, 'link')})`).join(', ')}`]
151
+ : [];
152
+ const broken = r.links.broken.map((b) => `- broken link (${b.status || 'no answer'}): ${b.url}`);
153
+ return [`${r.url ?? 'llms.txt'}: ${facts.join(' · ')}`, ...outline, '', ...lines, ...broken].join('\n');
154
+ }
155
+ /** The agent's-eye report as text (the same as the MCP tool's). */
156
+ function reportText(r) {
157
+ const mark = { pass: '✓', warn: '!', fail: '✕', info: '·' };
158
+ const lines = [`${r.url}: ${r.summary.fail} fail, ${r.summary.warn} warn, ${r.summary.pass} pass, ${r.summary.info} info`];
159
+ for (const s of r.sections) {
160
+ lines.push('', `## ${s.title}`);
161
+ for (const c of s.checks) {
162
+ lines.push(`${mark[c.status]} ${c.status} · ${c.title}: ${c.detail}`);
163
+ if (c.evidence && c.status !== 'pass')
164
+ lines.push(...c.evidence.split('\n').slice(0, 8).map((l) => ` ${l}`));
165
+ if (c.fix && c.status !== 'pass')
166
+ lines.push(` fix (${c.fix.kind}): ${c.fix.summary}${c.fix.patch ? ` [patch for ${c.fix.patch.file}]` : ''}`);
167
+ }
168
+ }
169
+ return lines.join('\n');
170
+ }
package/dist/index.d.ts CHANGED
@@ -13,7 +13,9 @@
13
13
  *
14
14
  * @module
15
15
  */
16
- export declare const VERSION = "0.4.0";
16
+ import { type InspectionReceipt } from './inspect.ts';
17
+ export type { InspectionReceipt } from './inspect.ts';
18
+ export declare const VERSION = "0.6.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';
@@ -136,8 +138,55 @@ export interface CheckResult {
136
138
  /** ISO 8601 date-time. */
137
139
  checkedAt: string;
138
140
  }
141
+ /** `pass` is fine; `warn` is worth fixing; `fail` keeps agents out or empties the page for them; `info` is a fact. */
142
+ export type ReportStatus = 'pass' | 'warn' | 'fail' | 'info';
143
+ /** How to fix a check that didn't pass. */
144
+ export interface ReportFix {
145
+ /** Where the change goes. */
146
+ kind: 'robots' | 'markdown' | 'llms-txt' | 'indexnow' | 'content' | 'html' | 'headers' | 'other';
147
+ /** What to change, in a sentence. */
148
+ summary: string;
149
+ /** A concrete change, when one is possible: `file` from the site root and its new content or lines to add. */
150
+ patch?: {
151
+ file: string;
152
+ content: string;
153
+ };
154
+ }
155
+ /** One check of the agent's-eye report. */
156
+ export interface ReportCheck {
157
+ /** Stable name of the check, e.g. `bots-search`, `raw-text`. */
158
+ id: string;
159
+ status: ReportStatus;
160
+ title: string;
161
+ /** What was found, for people. */
162
+ detail: string;
163
+ /** What it's based on: lines, headers, snippets, sizes. */
164
+ evidence?: string;
165
+ fix?: ReportFix;
166
+ }
167
+ /** `GET /api/v0/report`: how AI crawlers and agents see a page, each problem with its fix. */
168
+ export interface ReportResult {
169
+ /** The page reported on, after normalization and redirects. */
170
+ url: string;
171
+ /** Its origin. */
172
+ site: string;
173
+ /** ISO 8601 date-time. */
174
+ checkedAt: string;
175
+ summary: {
176
+ pass: number;
177
+ warn: number;
178
+ fail: number;
179
+ info: number;
180
+ };
181
+ /** In order: access, rendering, extraction, retrieval, hidden instructions, agent files. */
182
+ sections: Array<{
183
+ id: string;
184
+ title: string;
185
+ checks: ReportCheck[];
186
+ }>;
187
+ }
139
188
  /** Every error code the API answers with (https://llms.ink/docs/#errors). */
140
- export type ErrorCode = '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';
189
+ 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
190
  /** `invalid_request` only: one invalid parameter. */
142
191
  export interface Issue {
143
192
  path: string;
@@ -236,6 +285,10 @@ export interface LlmsOptions {
236
285
  export interface CheckOptions {
237
286
  signal?: AbortSignal;
238
287
  }
288
+ /** Options for {@link LlmsInk.report}. */
289
+ export interface ReportOptions {
290
+ signal?: AbortSignal;
291
+ }
239
292
  /** Options for {@link LlmsInk.checkText}. */
240
293
  export interface CheckTextOptions {
241
294
  /** Public URL the text lives at: resolves relative links and is reported as `url` / `site`. */
@@ -268,8 +321,22 @@ export declare class LlmsInk {
268
321
  * any public link.
269
322
  */
270
323
  llms(url: string, options?: LlmsOptions): Promise<Result>;
324
+ /**
325
+ * Explicitly upload supplied text to the hosted Inspect pilot. Requires an enabled key.
326
+ * The receipt binds to these exact UTF-8 bytes; no finding is not a safety guarantee.
327
+ * No retries. 65-second default deadline. LLMS_INK_API is ignored; baseUrl must be explicit.
328
+ * Keep keys server-side. Malformed receipts and HTTP problems throw LlmsInkError.
329
+ */
330
+ inspect(text: string, options?: {
331
+ signal?: AbortSignal;
332
+ }): Promise<InspectionReceipt>;
271
333
  /** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
272
334
  check(url: string, options?: CheckOptions): Promise<CheckResult>;
335
+ /**
336
+ * How AI crawlers and agents see a page (`GET /api/v0/report`): robots.txt per AI bot, firewall blocks, text
337
+ * without JavaScript, Markdown conversion, hidden instructions and agent files, each problem with its fix.
338
+ */
339
+ report(url: string, options?: ReportOptions): Promise<ReportResult>;
273
340
  /** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
274
341
  checkText(text: string, options?: CheckTextOptions): Promise<CheckResult>;
275
342
  /**
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.4.0';
18
+ export const VERSION = '0.6.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,11 +105,57 @@ 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);
108
149
  return (await this.#json(res));
109
150
  }
151
+ /**
152
+ * How AI crawlers and agents see a page (`GET /api/v0/report`): robots.txt per AI bot, firewall blocks, text
153
+ * without JavaScript, Markdown conversion, hidden instructions and agent files, each problem with its fix.
154
+ */
155
+ async report(url, options = {}) {
156
+ const res = await this.#request('/api/v0/report', { url }, 'application/json', options.signal);
157
+ return (await this.#json(res));
158
+ }
110
159
  /** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
111
160
  async checkText(text, options = {}) {
112
161
  const params = {};
@@ -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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "llms-ink",
3
- "version": "0.4.0",
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.",
3
+ "version": "0.6.0",
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, and Vercel AI SDK tools. No dependencies.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -19,6 +19,10 @@
19
19
  "./middleware": {
20
20
  "types": "./dist/middleware.d.ts",
21
21
  "default": "./dist/middleware.js"
22
+ },
23
+ "./ai": {
24
+ "types": "./dist/ai.d.ts",
25
+ "default": "./dist/ai.js"
22
26
  }
23
27
  },
24
28
  "main": "./dist/index.js",
@@ -31,27 +35,33 @@
31
35
  "scripts": {
32
36
  "build": "rm -rf dist && tsc -p tsconfig.json",
33
37
  "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 test/traffic.test.mjs",
38
+ "test": "npm run build && tsc -p test/tsconfig.json && node --test test/client.test.mjs test/plugins.test.mjs test/middleware.test.mjs test/traffic.test.mjs test/inspect.test.mjs test/ai.test.mjs",
35
39
  "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 ./test/traffic.test.mjs",
40
+ "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 ./test/ai.test.mjs",
37
41
  "test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs"
38
42
  },
39
- "keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware"],
43
+ "keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware", "ai-sdk", "vercel-ai-sdk", "tools"],
40
44
  "homepage": "https://llms.ink/docs/",
41
45
  "author": "Lucas Espindola (https://lucasespindola.com)",
42
46
  "license": "MIT",
43
47
  "peerDependencies": {
48
+ "ai": ">=5",
44
49
  "astro": ">=4",
45
- "vite": ">=5"
50
+ "vite": ">=5",
51
+ "zod": "^3.25.76 || ^4.1.8"
46
52
  },
47
53
  "peerDependenciesMeta": {
54
+ "ai": { "optional": true },
48
55
  "astro": { "optional": true },
49
- "vite": { "optional": true }
56
+ "vite": { "optional": true },
57
+ "zod": { "optional": true }
50
58
  },
51
59
  "devDependencies": {
52
60
  "@types/node": "^22.20.5",
53
- "astro": "^7.4.1",
61
+ "ai": "^7.0.164",
62
+ "astro": "^7.4.7",
54
63
  "typescript": "^5.9.3",
55
- "vite": "^8.3.8"
64
+ "vite": "^8.3.8",
65
+ "zod": "^4.6.7"
56
66
  }
57
67
  }