llms-ink 0.5.0 → 0.7.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
@@ -235,3 +301,32 @@ Requests are limited to 128 KiB after JSON encoding and responses to 16 KiB. The
235
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/).
236
302
 
237
303
  JavaScript uses a 65-second total request deadline; pass `{ signal }` to cancel earlier.
304
+
305
+ ## Claude conversation guard (experimental, Node / POSIX)
306
+
307
+ `llms-ink/guard/claude` binds one Claude Agent SDK conversation to one authenticated local Guard session. Install the optional peer separately:
308
+
309
+ ```sh
310
+ npm install llms-ink@0.7.0 @anthropic-ai/claude-agent-sdk@0.3.319
311
+ ```
312
+
313
+ First [start the Python 0.10.0 local service](https://llms.ink/guard/service/) with a reviewed gateway configuration and existing Claude authentication. Then try the bundled terminal host:
314
+
315
+ ```sh
316
+ node node_modules/llms-ink/examples/guard-claude.mjs \
317
+ --owner-file /private/guard/owner.json \
318
+ --python /absolute/path/to/.venv/bin/python \
319
+ --cwd /absolute/path/to/workspace
320
+ ```
321
+
322
+ It requires an interactive terminal and displays the complete review for each permitted action. Type `approve` to send that call once; Enter declines. `/exit`, terminal close and handled termination revoke the session. The example escapes terminal control characters rather than interpreting them. `--model` and `--claude-executable` are optional.
323
+
324
+ For application hosts, import `createGuardedClaude` and pass absolute `ownerFile`, `python` and `cwd` paths plus `review: async ({message, signal}) => boolean`. Render `message` completely as plain text; abort the UI on `signal`. Only a fresh literal `true` accepts. The returned object exposes the non-secret `sessionId`, sequential `run(prompt, onMessage?)` and `close()`. Always await close in a finally block. Create a new instance for each independent conversation; there is no resume/fork interface. The owner file never enters the SDK's MCP configuration.
325
+
326
+ The default fixed session TTL is 900 seconds (`ttl`, maximum 86400), with no renewal. Each turn has a 120000 ms deadline (`turnTimeoutMs`, maximum 600000). Reviews stop after 55 seconds. The adapter configures no native tools, other MCP connections, plugins or local hooks. SDK `maxTurns` defaults to 12 and `maxBudgetUsd` to 1; SDK cost estimates are not a hard billing limit. Optional `model` and `claudeExecutable` select the host model/binary.
327
+
328
+ `GuardHostError` reports incomplete cleanup with `sessionId` and `credentialDirectory`. If revocation cannot be confirmed, the private credential file remains for recovery; retry `close()` or revoke through the owner CLI. A host process killed without cleanup can leave authority until the service's TTL. The owner process, SDK and approval UI are trusted; private files do not isolate malicious same-user code. This is not a process sandbox, exposure ledger or automatic desktop plugin. Compaction does not establish clean context.
329
+
330
+ Allowed results go to Claude using the host's authentication, after local inspection. The adapter does not call hosted Inspect. Other SDK entry points do not import the optional Claude dependency. [Guide](https://llms.ink/guard/claude/) · [Runnable example](examples/guard-claude.mjs).
331
+
332
+ Lifecycle tests use real service sockets and subprocess effects with a simulated SDK loop. Run them with `LLMS_INK_GUARD_TEST_PYTHON=/absolute/python npm run test:guard` after installing Python `llms-ink==0.10.0`. The separately opted-in `test:guard:live` requires `LLMS_INK_CLAUDE_LIVE=1`, existing Claude authentication and the optional local model when `LLMS_INK_HOST_REAL_MODEL=1`. Scripted fixture approvals are interoperability evidence, not independent human review or proof of attack prevention.
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
+ }
@@ -0,0 +1,44 @@
1
+ import { type SDKMessage, type SDKResultMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ export interface GuardReview {
3
+ /** Complete guard-authored review, including command, arguments and policy/definition hashes. Render as plain text. */
4
+ message: string;
5
+ /** Stop displaying the form when this signal aborts. Never reuse a decision. */
6
+ signal: AbortSignal;
7
+ }
8
+ export interface GuardedClaudeOptions {
9
+ /** Private owner credential. This path is used by the control process, never sent to Claude. */
10
+ ownerFile: string;
11
+ /** Absolute Python interpreter with llms-ink[guard] 0.10.0 installed. */
12
+ python: string;
13
+ /** Called for each fresh approval. Only literal true accepts. Errors, cancellation and timeout decline. */
14
+ review: (request: GuardReview) => Promise<boolean>;
15
+ cwd: string;
16
+ model?: string;
17
+ /** Optional absolute path to a supported Claude Code executable. */
18
+ claudeExecutable?: string;
19
+ /** Fixed lifetime, 1–86400 seconds. Default 900. No automatic renewal. */
20
+ ttl?: number;
21
+ /** Per-turn deadline, including reviews. Default 120000, maximum 600000. */
22
+ turnTimeoutMs?: number;
23
+ maxTurns?: number;
24
+ maxBudgetUsd?: number;
25
+ }
26
+ export interface GuardedClaudeConversation {
27
+ /** Non-secret guard session ID, distinct from Claude's conversation UUID. */
28
+ readonly sessionId: string;
29
+ /** Run one turn. Calls must be sequential; callbacks receive host output, never owner credentials. */
30
+ run(prompt: string, onMessage?: (message: SDKMessage) => void | Promise<void>): Promise<SDKResultMessage>;
31
+ /** Revoke authority and close the host. Idempotent after confirmed cleanup; retry on cleanup errors. */
32
+ close(): Promise<void>;
33
+ }
34
+ export declare class GuardHostError extends Error {
35
+ readonly sessionId: string | undefined;
36
+ readonly credentialDirectory: string | undefined;
37
+ constructor(message: string, sessionId?: string, credentialDirectory?: string);
38
+ }
39
+ /**
40
+ * Create one non-resumable, tool-scoped conversation. New conversations require new instances.
41
+ * The owner process, SDK and review callback are trusted. This is not an OS sandbox.
42
+ * A process crash leaves authority bounded by the fixed TTL; there is no crash-proof lease or exposure ledger.
43
+ */
44
+ export declare function createGuardedClaude(options: GuardedClaudeOptions): Promise<GuardedClaudeConversation>;
@@ -0,0 +1,280 @@
1
+ /** Node/POSIX host integration for explicitly scoped Claude Agent SDK conversations. */
2
+ import { execFile } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { mkdtemp, chmod, lstat, readFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { isAbsolute, join } from 'node:path';
7
+ import { query } from '@anthropic-ai/claude-agent-sdk';
8
+ export class GuardHostError extends Error {
9
+ sessionId;
10
+ credentialDirectory;
11
+ constructor(message, sessionId, credentialDirectory) {
12
+ super(message);
13
+ this.name = 'GuardHostError';
14
+ this.sessionId = sessionId;
15
+ this.credentialDirectory = credentialDirectory;
16
+ }
17
+ }
18
+ const idPattern = /^[a-f0-9]{32}$/;
19
+ const reviewSchema = { type: 'object', properties: { approve: { type: 'boolean', title: 'Approve this call once', default: false } }, required: ['approve'] };
20
+ function sameShape(a, b) {
21
+ if (a === b)
22
+ return true;
23
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object' || Array.isArray(a) !== Array.isArray(b))
24
+ return false;
25
+ const ak = Object.keys(a), bk = Object.keys(b);
26
+ return ak.length === bk.length && ak.every(k => Object.hasOwn(b, k) && sameShape(a[k], b[k]));
27
+ }
28
+ function control(python, args) {
29
+ return new Promise((resolve, reject) => execFile(python, ['-m', 'llms_ink.guard', ...args], { timeout: 30000, maxBuffer: 16384, encoding: 'utf8' }, (error, stdout) => {
30
+ if (error) {
31
+ reject(new GuardHostError('Guard control did not complete.'));
32
+ return;
33
+ }
34
+ try {
35
+ const value = JSON.parse(stdout);
36
+ if (!value || typeof value !== 'object' || Array.isArray(value))
37
+ throw new Error();
38
+ resolve(value);
39
+ }
40
+ catch {
41
+ reject(new GuardHostError('Guard control returned an invalid response.'));
42
+ }
43
+ }));
44
+ }
45
+ class Input {
46
+ pending;
47
+ wake;
48
+ ended = false;
49
+ send(prompt, session) {
50
+ if (this.ended || this.pending)
51
+ throw new GuardHostError('Conversation input is unavailable.');
52
+ this.pending = { type: 'user', session_id: session, message: { role: 'user', content: prompt }, parent_tool_use_id: null };
53
+ this.wake?.();
54
+ }
55
+ close() { this.ended = true; this.wake?.(); }
56
+ async *[Symbol.asyncIterator]() {
57
+ while (!this.ended) {
58
+ if (!this.pending)
59
+ await new Promise(resolve => { this.wake = resolve; });
60
+ this.wake = undefined;
61
+ if (this.ended)
62
+ return;
63
+ const next = this.pending;
64
+ this.pending = undefined;
65
+ if (next)
66
+ yield next;
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Create one non-resumable, tool-scoped conversation. New conversations require new instances.
72
+ * The owner process, SDK and review callback are trusted. This is not an OS sandbox.
73
+ * A process crash leaves authority bounded by the fixed TTL; there is no crash-proof lease or exposure ledger.
74
+ */
75
+ export async function createGuardedClaude(options) {
76
+ options = { ...options };
77
+ if (process.platform === 'win32')
78
+ throw new GuardHostError('Guard sessions require macOS or Linux.');
79
+ const { python, ownerFile, review, cwd, claudeExecutable } = options;
80
+ const ttl = options.ttl ?? 900, timeout = options.turnTimeoutMs ?? 120000;
81
+ if (!isAbsolute(python) || !isAbsolute(ownerFile) || !isAbsolute(cwd) || (claudeExecutable && !isAbsolute(claudeExecutable)) || typeof review !== 'function'
82
+ || !Number.isInteger(ttl) || ttl < 1 || ttl > 86400 || !Number.isInteger(timeout) || timeout < 1 || timeout > 600000
83
+ || (options.maxTurns !== undefined && (!Number.isInteger(options.maxTurns) || options.maxTurns < 1 || options.maxTurns > 100))
84
+ || (options.maxBudgetUsd !== undefined && (!Number.isFinite(options.maxBudgetUsd) || options.maxBudgetUsd <= 0)))
85
+ throw new GuardHostError('Absolute paths, a review callback and bounded lifetimes are required.');
86
+ const directory = await mkdtemp(join(tmpdir(), 'ink-host-'));
87
+ await chmod(directory, 0o700);
88
+ const file = join(directory, 'session.json');
89
+ let sessionId;
90
+ async function revoke() {
91
+ if (!sessionId)
92
+ return;
93
+ try {
94
+ const result = await control(python, ['session-revoke', '--owner-file', ownerFile, '--session-id', sessionId]);
95
+ if (result.ok !== true)
96
+ throw new Error();
97
+ }
98
+ catch {
99
+ try {
100
+ const state = await control(python, ['service-status', '--owner-file', ownerFile]);
101
+ if (state.ok !== true || !Array.isArray(state.sessions) || state.sessions.some(s => !s || typeof s !== 'object' || typeof s.sessionId !== 'string' || (s.sessionId === sessionId && s.active !== false)))
102
+ throw new Error();
103
+ }
104
+ catch {
105
+ throw new GuardHostError('Session revocation could not be confirmed. Keep the credential directory and retry close or revoke it with the owner CLI.', sessionId, directory);
106
+ }
107
+ }
108
+ }
109
+ try {
110
+ const result = await control(python, ['session-create', '--owner-file', ownerFile, '--output', file, '--ttl', String(ttl)]);
111
+ if (typeof result.sessionId !== 'string' || !idPattern.test(result.sessionId))
112
+ throw new Error();
113
+ sessionId = result.sessionId;
114
+ const info = await lstat(file);
115
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())
116
+ throw new Error();
117
+ }
118
+ catch {
119
+ // A terminated CLI may have written a capability before printing its ID.
120
+ if (!sessionId) {
121
+ try {
122
+ const saved = JSON.parse(await readFile(file, 'utf8'));
123
+ if (typeof saved.sessionId === 'string' && idPattern.test(saved.sessionId))
124
+ sessionId = saved.sessionId;
125
+ }
126
+ catch { /* no recoverable credential */ }
127
+ }
128
+ await revoke();
129
+ await rm(directory, { recursive: true, force: true });
130
+ throw new GuardHostError('Could not create a guarded conversation.');
131
+ }
132
+ const guardId = sessionId;
133
+ const hostId = randomUUID(), input = new Input(), abort = new AbortController();
134
+ let closing = false, closed = false, busy = false, initialized = false, closeWork;
135
+ let host;
136
+ let expiresTimer;
137
+ async function whileOpen(work) {
138
+ if (abort.signal.aborted)
139
+ throw new GuardHostError('Conversation closed.');
140
+ let cancel;
141
+ try {
142
+ return await Promise.race([work, new Promise((_, reject) => {
143
+ cancel = () => reject(new GuardHostError('Conversation closed.'));
144
+ abort.signal.addEventListener('abort', cancel, { once: true });
145
+ })]);
146
+ }
147
+ finally {
148
+ if (cancel)
149
+ abort.signal.removeEventListener('abort', cancel);
150
+ }
151
+ }
152
+ async function close() {
153
+ if (closed)
154
+ return;
155
+ if (closeWork)
156
+ return closeWork;
157
+ closing = true;
158
+ input.close();
159
+ abort.abort();
160
+ clearTimeout(expiresTimer);
161
+ closeWork = (async () => {
162
+ let failure;
163
+ try {
164
+ await revoke();
165
+ }
166
+ catch (error) {
167
+ failure = error;
168
+ }
169
+ try {
170
+ host?.close();
171
+ }
172
+ catch {
173
+ failure ??= new GuardHostError('Claude host cleanup did not complete. Retry close.', guardId, directory);
174
+ }
175
+ if (failure)
176
+ throw failure;
177
+ await rm(directory, { recursive: true, force: true });
178
+ closed = true;
179
+ })();
180
+ try {
181
+ await closeWork;
182
+ }
183
+ finally {
184
+ closeWork = undefined;
185
+ }
186
+ }
187
+ async function elicit(request, context) {
188
+ if (closing || !busy || context.signal.aborted || request.serverName !== 'guarded' || (request.mode ?? 'form') !== 'form'
189
+ || typeof request.message !== 'string' || Buffer.byteLength(request.message) > 8192 || !sameShape(request.requestedSchema, reviewSchema))
190
+ return { action: 'decline' };
191
+ const lifetime = new AbortController();
192
+ const cancel = () => lifetime.abort();
193
+ abort.signal.addEventListener('abort', cancel, { once: true });
194
+ context.signal.addEventListener('abort', cancel, { once: true });
195
+ const timer = setTimeout(cancel, 55000);
196
+ try {
197
+ const answer = await Promise.race([
198
+ Promise.resolve().then(() => review(Object.freeze({ message: request.message, signal: lifetime.signal }))),
199
+ new Promise(resolve => lifetime.signal.addEventListener('abort', () => resolve(false), { once: true })),
200
+ ]);
201
+ return answer === true && !lifetime.signal.aborted && !closing ? { action: 'accept', content: { approve: true } } : { action: 'decline' };
202
+ }
203
+ catch {
204
+ return { action: 'decline' };
205
+ }
206
+ finally {
207
+ clearTimeout(timer);
208
+ cancel();
209
+ abort.signal.removeEventListener('abort', cancel);
210
+ context.signal.removeEventListener('abort', cancel);
211
+ }
212
+ }
213
+ try {
214
+ const sdkOptions = {
215
+ abortController: abort, sessionId: hostId, persistSession: false, settingSources: [], plugins: [], tools: [],
216
+ strictMcpConfig: true, mcpServers: { guarded: { command: python, args: ['-m', 'llms_ink.guard', 'connect', '--session-file', file] } },
217
+ canUseTool: async (name) => name.startsWith('mcp__guarded__') && initialized && busy && !closing
218
+ ? { behavior: 'allow' } : { behavior: 'deny', message: 'Only guarded tools are available.' },
219
+ settings: { disableAllHooks: true, disableClaudeAiConnectors: true, disabledMcpServers: ['claude-in-chrome'] },
220
+ extraArgs: { 'disable-slash-commands': null, 'no-chrome': null },
221
+ cwd, model: options.model, pathToClaudeCodeExecutable: claudeExecutable,
222
+ maxTurns: options.maxTurns ?? 12, maxBudgetUsd: options.maxBudgetUsd ?? 1, onElicitation: elicit,
223
+ };
224
+ host = query({ prompt: input, options: sdkOptions });
225
+ // Revocation on idle expiry does not wait for another prompt. Failures remain
226
+ // observable through close(); the service independently enforces its TTL.
227
+ expiresTimer = setTimeout(() => { void close().catch(() => { }); }, ttl * 1000);
228
+ expiresTimer.unref();
229
+ }
230
+ catch {
231
+ await close();
232
+ throw new GuardHostError('Claude host startup failed.');
233
+ }
234
+ return Object.freeze({ sessionId: guardId,
235
+ async run(prompt, onMessage) {
236
+ if (closing || busy)
237
+ throw new GuardHostError('The conversation is closed or another turn is running.');
238
+ if (typeof prompt !== 'string' || !prompt.trim() || prompt.trimStart().startsWith('/') || Buffer.byteLength(prompt) > 131072)
239
+ throw new GuardHostError('Provide a text prompt up to 128 KiB; host commands require a new conversation.');
240
+ busy = true;
241
+ const timer = setTimeout(() => { void close().catch(() => { }); }, timeout);
242
+ try {
243
+ const state = await control(python, ['service-status', '--owner-file', ownerFile]);
244
+ if (closing || state.ok !== true || !Array.isArray(state.sessions) || !state.sessions.some(s => s?.sessionId === guardId && s.active === true))
245
+ throw new Error();
246
+ input.send(prompt, hostId);
247
+ while (true) {
248
+ const next = await whileOpen(host.next());
249
+ if (next.done || closing)
250
+ throw new Error();
251
+ const message = next.value;
252
+ if ('session_id' in message && message.session_id !== hostId)
253
+ throw new Error();
254
+ if (message.type === 'system' && message.subtype === 'init') {
255
+ if (message.mcp_servers.length !== 1 || message.mcp_servers[0].name !== 'guarded' || message.mcp_servers[0].status !== 'connected'
256
+ || !message.tools.length || message.tools.some(t => !t.startsWith('mcp__guarded__')))
257
+ throw new Error();
258
+ initialized = true;
259
+ }
260
+ if (!initialized && message.type !== 'system')
261
+ throw new Error();
262
+ if (onMessage)
263
+ await whileOpen(Promise.resolve().then(() => onMessage(message)));
264
+ if (message.type === 'result') {
265
+ if (message.is_error)
266
+ throw new Error();
267
+ return message;
268
+ }
269
+ }
270
+ }
271
+ catch {
272
+ await close();
273
+ throw new GuardHostError('Guarded turn did not complete; the conversation was closed.');
274
+ }
275
+ finally {
276
+ clearTimeout(timer);
277
+ busy = false;
278
+ }
279
+ }, close, });
280
+ }
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { type InspectionReceipt } from './inspect.ts';
17
17
  export type { InspectionReceipt } from './inspect.ts';
18
- export declare const VERSION = "0.5.0";
18
+ export declare const VERSION = "0.7.0";
19
19
  export declare const DEFAULT_BASE_URL = "https://llms.ink";
20
20
  /** `llms` for the index (llms.txt), `full` for llms-full.txt. */
21
21
  export type Mode = 'llms' | 'full';
@@ -138,6 +138,53 @@ export interface CheckResult {
138
138
  /** ISO 8601 date-time. */
139
139
  checkedAt: string;
140
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
+ }
141
188
  /** Every error code the API answers with (https://llms.ink/docs/#errors). */
142
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';
143
190
  /** `invalid_request` only: one invalid parameter. */
@@ -238,6 +285,10 @@ export interface LlmsOptions {
238
285
  export interface CheckOptions {
239
286
  signal?: AbortSignal;
240
287
  }
288
+ /** Options for {@link LlmsInk.report}. */
289
+ export interface ReportOptions {
290
+ signal?: AbortSignal;
291
+ }
241
292
  /** Options for {@link LlmsInk.checkText}. */
242
293
  export interface CheckTextOptions {
243
294
  /** Public URL the text lives at: resolves relative links and is reported as `url` / `site`. */
@@ -281,6 +332,11 @@ export declare class LlmsInk {
281
332
  }): Promise<InspectionReceipt>;
282
333
  /** Checks a site's published llms.txt against the llmstxt.org format and that its links answer. */
283
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>;
284
340
  /** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
285
341
  checkText(text: string, options?: CheckTextOptions): Promise<CheckResult>;
286
342
  /**
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { prepareInspection, inspectionJson, inspectionReceipt } from "./inspect.js";
17
17
  import { env } from "./env.js";
18
- export const VERSION = '0.5.0';
18
+ export const VERSION = '0.7.0';
19
19
  export const DEFAULT_BASE_URL = 'https://llms.ink';
20
20
  /**
21
21
  * An API error, or llms.ink couldn't be reached (then `status` and `code` are undefined).
@@ -148,6 +148,14 @@ export class LlmsInk {
148
148
  const res = await this.#request('/api/v0/check', { url }, 'application/json', options.signal);
149
149
  return (await this.#json(res));
150
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
+ }
151
159
  /** Parses and checks llms.txt text you already have (`POST /api/v0/check`); nothing is published. */
152
160
  async checkText(text, options = {}) {
153
161
  const params = {};
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // Run from an installed llms-ink package with the optional Claude SDK peer installed.
3
+ import {parseArgs} from 'node:util';
4
+ import {createInterface} from 'node:readline/promises';
5
+ import {createGuardedClaude} from '../dist/guard-claude.js';
6
+ const {values}=parseArgs({options:{'owner-file':{type:'string'},python:{type:'string'},cwd:{type:'string'},model:{type:'string'},'claude-executable':{type:'string'}}});
7
+ if(!process.stdin.isTTY || !process.stdout.isTTY) throw Error('This example requires an interactive terminal for each approval.');
8
+ if(!values['owner-file'] || !values.python || !values.cwd) throw Error('Pass --owner-file, --python and --cwd with absolute paths.');
9
+ const terminal=createInterface({input:process.stdin,output:process.stdout});
10
+ // Preserve complete text while making terminal controls and bidi controls visible.
11
+ const plain=text=>text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,c=>'\\u'+c.charCodeAt(0).toString(16).padStart(4,'0'));
12
+ let conversation,stopping=false;
13
+ const stop=()=>{stopping=true;terminal.close();void conversation?.close().catch(()=>{});};
14
+ terminal.on('SIGINT',stop);terminal.on('close',()=>{stopping=true;void conversation?.close().catch(()=>{});});process.on('SIGTERM',stop);
15
+ try{
16
+ conversation=await createGuardedClaude({ownerFile:values['owner-file'],python:values.python,cwd:values.cwd,model:values.model,claudeExecutable:values['claude-executable'],
17
+ review:async({message,signal})=>{
18
+ process.stdout.write('\n'+plain(message)+'\n\n');
19
+ try{return(await terminal.question('Type approve to send this call once; Enter declines: ',{signal}))==='approve';}catch{return false;}
20
+ }});
21
+ console.log('Guarded conversation. /exit closes and revokes this session.');
22
+ while(!stopping){
23
+ const prompt=await terminal.question('\nYou: ');
24
+ if(prompt.trim()==='/exit')break;
25
+ if(!prompt.trim())continue;
26
+ await conversation.run(prompt,message=>{
27
+ if(message.type==='assistant')for(const part of message.message.content)if(part.type==='text')process.stdout.write(plain(part.text)+'\n');
28
+ });
29
+ }
30
+ }catch(error){if(!stopping){console.error(error.message);process.exitCode=1;}}
31
+ finally{
32
+ terminal.close();process.removeListener('SIGTERM',stop);
33
+ try{await conversation?.close();}catch(error){console.error(error.message);if(error.sessionId)console.error('Session ID:',error.sessionId);if(error.credentialDirectory)console.error('Credential directory:',error.credentialDirectory);process.exitCode=1;}
34
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "llms-ink",
3
- "version": "0.5.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.7.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,39 +19,93 @@
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"
26
+ },
27
+ "./guard/claude": {
28
+ "types": "./dist/guard-claude.d.ts",
29
+ "default": "./dist/guard-claude.js"
22
30
  }
23
31
  },
24
32
  "main": "./dist/index.js",
25
33
  "types": "./dist/index.d.ts",
26
- "files": ["dist"],
34
+ "files": [
35
+ "dist",
36
+ "examples/guard-claude.mjs"
37
+ ],
27
38
  "sideEffects": false,
28
- "repository": { "type": "git", "url": "git+https://github.com/lukax/llms-ink-js.git" },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/lukax/llms-ink-js.git"
42
+ },
29
43
  "bugs": "https://github.com/lukax/llms-ink-js/issues",
30
- "engines": { "node": ">=18" },
44
+ "engines": {
45
+ "node": ">=18"
46
+ },
31
47
  "scripts": {
32
48
  "build": "rm -rf dist && tsc -p tsconfig.json",
33
49
  "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 test/inspect.test.mjs",
50
+ "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
51
  "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 ./test/inspect.test.mjs",
37
- "test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs"
52
+ "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",
53
+ "test:bun:live": "npm run build && bun test --timeout 300000 ./test/live.test.mjs",
54
+ "test:guard": "node --experimental-test-module-mocks --test test/guard-claude.test.mjs",
55
+ "test:guard:live": "node --test test/guard-claude-live.test.mjs"
38
56
  },
39
- "keywords": ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "sdk", "llms-ink", "vite-plugin", "astro-integration", "middleware"],
57
+ "keywords": [
58
+ "llms.txt",
59
+ "llms-full.txt",
60
+ "llm",
61
+ "ai",
62
+ "markdown",
63
+ "docs",
64
+ "context",
65
+ "rag",
66
+ "sdk",
67
+ "llms-ink",
68
+ "vite-plugin",
69
+ "astro-integration",
70
+ "middleware",
71
+ "ai-sdk",
72
+ "vercel-ai-sdk",
73
+ "tools"
74
+ ],
40
75
  "homepage": "https://llms.ink/docs/",
41
76
  "author": "Lucas Espindola (https://lucasespindola.com)",
42
77
  "license": "MIT",
43
78
  "peerDependencies": {
79
+ "ai": ">=5",
44
80
  "astro": ">=4",
45
- "vite": ">=5"
81
+ "vite": ">=5",
82
+ "zod": "^3.25.76 || ^4.1.8",
83
+ "@anthropic-ai/claude-agent-sdk": "^0.3.319"
46
84
  },
47
85
  "peerDependenciesMeta": {
48
- "astro": { "optional": true },
49
- "vite": { "optional": true }
86
+ "ai": {
87
+ "optional": true
88
+ },
89
+ "astro": {
90
+ "optional": true
91
+ },
92
+ "vite": {
93
+ "optional": true
94
+ },
95
+ "zod": {
96
+ "optional": true
97
+ },
98
+ "@anthropic-ai/claude-agent-sdk": {
99
+ "optional": true
100
+ }
50
101
  },
51
102
  "devDependencies": {
103
+ "@anthropic-ai/claude-agent-sdk": "0.3.319",
52
104
  "@types/node": "^22.20.5",
105
+ "ai": "^7.0.164",
53
106
  "astro": "^7.4.7",
54
107
  "typescript": "^5.9.3",
55
- "vite": "^8.3.8"
108
+ "vite": "^8.3.8",
109
+ "zod": "^4.6.7"
56
110
  }
57
111
  }