@better-fs-tools/read 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ Most hosts also install one adapter package. Each adapter lists its own peers:
18
18
  | -------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
19
19
  | `@better-fs-tools/read` | The core, the helpers, and the `./signature` and `./formats` subpaths | `npm install @better-fs-tools/read` |
20
20
  | `@better-fs-tools/fs` | The `FileSystem` contract, `memoryFileSystem`, and the conformance suite | `npm install @better-fs-tools/fs` |
21
- | `@better-fs-tools/node` | Local reads on Node 24 or Bun, and `createNodeReadTool()` | `npm install @better-fs-tools/node @better-fs-tools/read` |
21
+ | `@better-fs-tools/node` | The local filesystem on Node 24 or Bun, and `nodeDigest()` | `npm install @better-fs-tools/node @better-fs-tools/read` |
22
22
  | `@better-fs-tools/ai-sdk` | An AI SDK 7 tool | `npm install @better-fs-tools/ai-sdk @better-fs-tools/read ai` |
23
23
  | `@better-fs-tools/pi` | A Pi tool and a Pi extension | `npm install @better-fs-tools/pi @better-fs-tools/read @earendil-works/pi-coding-agent typebox` |
24
24
  | `@better-fs-tools/cloudflare-shell` | A filesystem over a Cloudflare Shell Workspace | `npm install @better-fs-tools/cloudflare-shell @better-fs-tools/read` |
@@ -29,14 +29,14 @@ All packages have the same version. Install matching versions.
29
29
 
30
30
  ## Quick start
31
31
 
32
- On Node or Bun, `createNodeReadTool()` gives a working local reader with no options:
32
+ On Node or Bun, give `createReadTool()` a `nodeFileSystem()`:
33
33
 
34
34
  ```ts
35
- import { createNodeReadTool } from "@better-fs-tools/node";
36
- import { textOf } from "@better-fs-tools/read";
35
+ import { nodeFileSystem } from "@better-fs-tools/node";
36
+ import { createReadTool, textOf } from "@better-fs-tools/read";
37
37
 
38
38
  // Rooted at process.cwd(), with SHA-256 observations.
39
- const read = createNodeReadTool();
39
+ const read = createReadTool({ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }) });
40
40
 
41
41
  const result = await read({ path: "package.json", limit: 2 });
42
42
  console.log(result.status); // "ok"
@@ -47,8 +47,6 @@ console.log(textOf(result));
47
47
  // [read:continue] Output stopped at the line limit. Continue with {"path":"package.json","offset":3,"limit":2}.
48
48
  ```
49
49
 
50
- `createNodeReadTool()` uses `nodeFileSystem` rooted at `process.cwd()`, and `nodeDigest()` for content hashes. It passes every other option to `createReadTool()`.
51
-
52
50
  `createReadTool()` is the core. It needs a filesystem, and it works with any `FileSystem`:
53
51
 
54
52
  ```ts
@@ -107,7 +105,7 @@ read({ path, offset, limit }, ctx)
107
105
  │ ├─ on a miss: suggest one bounded listing, names only
108
106
  │ └─ on a directory .... a directory converter lists it, else NOT_A_FILE
109
107
  │ authorize ............... host policy on the open file, before the core reads content
110
- │ sample + classifiers .... text, image, PDF, notebook, binary, ...
108
+ │ sample + classifiers .... text, image, PDF, Office, binary, ...
111
109
  │ converters .............. other formats to text or media parts
112
110
  │ scan .................... lines, clamping, view bytes, token budget, scan limit
113
111
  │ verify .................. size check and change detection
@@ -118,38 +116,39 @@ read({ path, offset, limit }, ctx)
118
116
  ReadResult
119
117
  ```
120
118
 
121
- You cannot remove the input validation, the scan, the limits, the type check, or change detection. Every other step is a dependency that you can replace. [docs/architecture.md](docs/architecture.md) explains each stage.
119
+ You cannot remove the input validation, the scan, the limits, the type check, or change detection. Every other step is an option that you can replace. [docs/architecture.md](docs/architecture.md) explains each stage.
122
120
 
123
121
  ## What you can change
124
122
 
125
- Pass dependencies to `createReadTool()`, `createNodeReadTool()`, or an adapter factory (`createAiSdkReadTool()`, `createPiReadTool()`). The adapter passes them to the core.
126
-
127
- | Dimension | Dependency | Default |
128
- | ------------------------------------------------------ | --------------------------- | ------------------------------------------------------------------------------------------------- |
129
- | Tool name, parameters, range model, descriptions | `signature` (adapters only) | `defaultReadSignature()`: `read` with `path`, `offset`, `limit` |
130
- | Filesystem, allowed roots, deny roots, symlinks | `fs` | Required. `createNodeReadTool()` uses `nodeFileSystem` over `process.cwd()` |
131
- | Path rewrites (`~`, `file://`, `@`, Unicode repair) | `resolve` | `null` (the path is used as given) |
132
- | Names to suggest when a file is missing | `suggest` | `defaultSuggest()` |
133
- | Per-path policy, size ceiling, user approval | `authorize` | `null` (allow) |
134
- | Binary and format detection | `classifiers` | `defaultClassifiers()` |
135
- | File conversion, images, directories | `converters` | `[]` |
136
- | Line, byte, scan, conversion, and media limits | `limits` | `defaultReadLimits` |
137
- | Token limit | `budget` | `null` |
138
- | Redaction, repeat-read guard, your own after-read code | `hooks` | `[]` |
139
- | Output text or content parts | `formatter` | `lineNumberFormatter()` |
140
- | Note and message wording | `messages` | `defaultReadMessages` |
141
- | Read records for later tools | `state` | `null` |
142
- | Content hashes and observations | `digest` | `null`. `createNodeReadTool()` and the Pi tool use `nodeDigest()`. `sha256Digest()` runs anywhere |
143
- | Timestamps | `clock` | `() => new Date()` |
144
-
145
- `limits` and `messages` merge over their defaults key by key. Every other dependency replaces its default. To add to a list, include the default yourself:
123
+ Pass options to `createReadTool()`, `readToolDefinition()`, or an integration factory (`createAiSdkReadTool()`, `createPiReadTool()`, `createAgentCoreReadTool()`). Every integration takes the same options with the same defaults, and passes them to the core.
124
+
125
+ | Dimension | Option | Default |
126
+ | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------------------------------------- |
127
+ | Tool name, parameters, range model, descriptions | `signature` (adapters only) | `defaultReadSignature()`: `read` with `path`, `offset`, `limit` |
128
+ | Filesystem, allowed roots, deny roots, symlinks | `fs` | Required. `nodeFileSystem()` from `@better-fs-tools/node` reads the local disk |
129
+ | Path rewrites (`~`, `file://`, `@`, Unicode repair) | `resolve` | `null` (the path is used as given) |
130
+ | Names to suggest when a file is missing | `suggest` | `defaultSuggest()` |
131
+ | Per-path policy, size ceiling, user approval | `authorize` | `null` (allow) |
132
+ | Binary and format detection | `classifiers` | `defaultClassifiers()` |
133
+ | File conversion, images, directories | `converters` | `[imageConverter()]`: an image becomes a media part. `[]` turns images off |
134
+ | Line, byte, scan, conversion, and media limits | `limits` | `defaultReadLimits` |
135
+ | Token limit | `budget` | `null` |
136
+ | Redaction, repeat-read guard, your own after-read code | `hooks` | `[]` |
137
+ | Output text or content parts | `formatter` | `lineNumberFormatter()` |
138
+ | Note and message wording | `messages` | `defaultReadMessages` |
139
+ | Read records for later tools | `state` | `null` |
140
+ | Content hashes and observations | `digest` | `sha256Digest()`, which runs anywhere. `nodeDigest()` is faster on Node. `null` turns it off |
141
+ | Timestamps | `clock` | `() => new Date()` |
142
+
143
+ `limits` and `messages` merge over their defaults key by key. Every other option replaces its default. To add to a list, include the default yourself:
146
144
 
147
145
  ```ts
148
- import { createNodeReadTool } from "@better-fs-tools/node";
149
- import { defaultClassifiers, extensionClassifier } from "@better-fs-tools/read";
146
+ import { nodeFileSystem } from "@better-fs-tools/node";
147
+ import { createReadTool, defaultClassifiers, extensionClassifier } from "@better-fs-tools/read";
150
148
 
151
- export const read = createNodeReadTool({
152
- // A list dependency replaces the default. Include the default to add to it.
149
+ export const read = createReadTool({
150
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
151
+ // A list option replaces the default. Include the default to add to it.
153
152
  classifiers: [
154
153
  extensionClassifier({ unsupported: { ".parquet": { code: "BINARY" } } }),
155
154
  ...defaultClassifiers(),
@@ -160,7 +159,7 @@ export const read = createNodeReadTool({
160
159
  });
161
160
  ```
162
161
 
163
- `createReadTool()` checks the dependencies once, when you call it. An unknown key, an empty classifier list, a limit that is not a positive safe integer, or a message that is not a function throws `TypeError`.
162
+ `createReadTool()` checks the options once, when you call it. An unknown key, an empty classifier list, a limit that is not a positive safe integer, or a message that is not a function throws `TypeError`.
164
163
 
165
164
  ## Tool signature and docs
166
165
 
@@ -172,7 +171,7 @@ The model sees a tool name, a description, a parameter schema, and parameter des
172
171
  - `param(name)` gives the host name of a canonical parameter, or `""` when the signature has none.
173
172
  - `fromInput(retry)` maps a canonical retry back to the model's names. Notes use it when they tell the model how to continue. Only the read signature has it.
174
173
 
175
- Two builders cover the common cases. Both take the shared `SignatureDocs` options: `name`, `description`, `describe`, and `names`. `describe` and `names` use the builder's own parameter names as keys.
174
+ Two builders make the signature. `defaultReadSignature()` gives `read({ path, offset?, limit? })`. `lineRangeSignature()` gives `read({ path, start?, end? })`, with one-based and inclusive lines. Both take the shared `SignatureDocs` options: `name`, `description`, `describe`, and `names`. `describe` and `names` use the builder's own parameter names as keys.
176
175
 
177
176
  ```ts
178
177
  import { defaultReadSignature, lineRangeSignature } from "@better-fs-tools/read/signature";
@@ -196,7 +195,9 @@ export const lineRange = lineRangeSignature({
196
195
  });
197
196
  ```
198
197
 
199
- With `lineRangeSignature`, a truncated read tells the model to continue with `{"file_path":"src/a.ts","start_line":101,"end_line":200}`. The structured result stays canonical: `result.continuation.next` is still `{ path, offset, limit }`.
198
+ With `renamed`, a truncated read tells the model to continue with `{"file_path":"src/a.ts","start":101,"max_lines":100}`. With `lineRange`, it says `{"file_path":"src/a.ts","start_line":101,"end_line":200}`. The structured result stays canonical: `result.continuation.next` is still `{ path, offset, limit }`.
199
+
200
+ For another input shape, write your own `ReadSignature`. It needs `name`, `description`, `schema`, `toInput`, `param`, and `fromInput`.
200
201
 
201
202
  `createAiSdkReadTool()` and `createPiReadTool()` wire the retry wording for you with `readSignatureMessages(signature)`. If you call the core yourself behind a signature, pass `messages: readSignatureMessages(signature)`. Otherwise the retry text uses `path`, `offset`, and `limit`.
202
203
 
@@ -210,13 +211,19 @@ The tool reads one path for each call. For multi-file reads or globs, call the c
210
211
 
211
212
  ```ts
212
213
  import { homedir } from "node:os";
213
- import { createNodeReadTool, nodeFileSystem } from "@better-fs-tools/node";
214
- import { expandHome, pathResolvers, stripPrefixes, unicodeRepair } from "@better-fs-tools/read";
214
+ import { nodeFileSystem } from "@better-fs-tools/node";
215
+ import {
216
+ createReadTool,
217
+ expandHome,
218
+ pathResolvers,
219
+ stripPrefixes,
220
+ unicodeRepair,
221
+ } from "@better-fs-tools/read";
215
222
 
216
223
  const cwd = process.cwd();
217
224
  const home = homedir();
218
225
 
219
- export const read = createNodeReadTool({
226
+ export const read = createReadTool({
220
227
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd, `${home}/.config/app`] }),
221
228
  resolve: pathResolvers(
222
229
  stripPrefixes(), // "file:///x" and "@src/x"
@@ -285,13 +292,20 @@ The filesystem owns the root policy: allowed roots, deny roots, and symlinks. `a
285
292
  What `open()` has fetched before `authorize` runs depends on the backend. `@better-fs-tools/cloudflare-shell` and `@better-fs-tools/just-bash` buffer the whole file inside `open()`, so those bytes have left the backend. `@better-fs-tools/cloudflare-computer` does not buffer: its `open()` starts a `readFile` stream, so the request reaches the backend, but it reads no chunk before `authorize`. `nodeFileSystem` streams, and `memoryFileSystem` already holds the bytes. In every case the core passes no byte to a classifier, a converter, or the model until `authorize` allows it. If a denied read must not reach the backend, refuse the path in the filesystem with a deny root, or leave it out of the allowed roots.
286
293
 
287
294
  ```ts
288
- import { createNodeReadTool } from "@better-fs-tools/node";
289
- import { askUser, readAuthorizers, denyPaths, sizeCeiling } from "@better-fs-tools/read";
295
+ import { nodeFileSystem } from "@better-fs-tools/node";
296
+ import {
297
+ askUser,
298
+ createReadTool,
299
+ denyPaths,
300
+ readAuthorizers,
301
+ sizeCeiling,
302
+ } from "@better-fs-tools/read";
290
303
 
291
304
  // Your own prompt, for example a dialog in your UI.
292
305
  declare function confirmInUi(question: string): Promise<boolean>;
293
306
 
294
- export const read = createNodeReadTool({
307
+ export const read = createReadTool({
308
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
295
309
  authorize: readAuthorizers(
296
310
  denyPaths(["**/.env", "**/.env.*", "**/*.pem"]),
297
311
  sizeCeiling({ maxBytes: 256 * 1024, unrangedOnly: true }),
@@ -343,7 +357,9 @@ While a prompt waits, the file stays open. If the file changes during the wait,
343
357
 
344
358
  `classifiers` is an ordered list. Each classifier sees a bounded sample of the file (`limits.sampleBytes`). The first classifier with an opinion wins.
345
359
 
346
- The default chain detects images, PDFs, Office documents, notebooks, binary content, and text encodings. It returns these `unsupported` codes: `IMAGE`, `PDF`, `OFFICE_DOCUMENT`, `NOTEBOOK`, `BINARY`, and `UNKNOWN_ENCODING`. Each refusal has a note with code `unsupported-<code>`, for example `unsupported-pdf`.
360
+ The default chain detects images, PDFs, Office documents, binary content, and text encodings. It returns these `unsupported` codes: `IMAGE`, `PDF`, `OFFICE_DOCUMENT`, `BINARY`, and `UNKNOWN_ENCODING`. Each refusal has a note with code `unsupported-<code>`, for example `unsupported-pdf`.
361
+
362
+ A Jupyter notebook is JSON, so the default chain reads it as text. `notebookClassifier()` is exported but not in the chain. Put it first, as `classifiers: [notebookClassifier(), ...defaultClassifiers()]`, to refuse notebooks with code `NOTEBOOK`, or to convert them with `notebookConverter()`. The write tools use the same classifiers, so the same list makes them refuse a notebook too.
347
363
 
348
364
  To detect by extension, or to change a refusal message:
349
365
 
@@ -370,13 +386,16 @@ A classifier only decides what a file is. A converter changes the content.
370
386
 
371
387
  ## File conversion, images, and directories
372
388
 
373
- `converters` is a list. It runs after classification. The first file converter whose `accepts` returns `true` handles the file. The first directory converter handles directories. With no converters, text is read, other formats are `unsupported`, and a directory gives `NOT_A_FILE`.
389
+ `converters` is a list. It runs after classification. The first file converter whose `accepts` returns `true` handles the file. The first directory converter handles directories. With no converters, text is read, other formats are `unsupported`, and a directory gives `NOT_A_FILE`. The core has no converters by default. The Pi and AI SDK hosts add `imageConverter()` when you pass no `converters`.
374
390
 
375
391
  ```ts
376
- import { createNodeReadTool } from "@better-fs-tools/node";
392
+ import { nodeFileSystem } from "@better-fs-tools/node";
377
393
  import {
394
+ createReadTool,
395
+ defaultClassifiers,
378
396
  directoryListing,
379
397
  imageConverter,
398
+ notebookClassifier,
380
399
  notebookConverter,
381
400
  textConverter,
382
401
  } from "@better-fs-tools/read";
@@ -384,7 +403,11 @@ import {
384
403
  // Your own PDF extraction, for example a pdftotext process that reads stdin.
385
404
  declare function pdfToText(source: AsyncIterable<Uint8Array>): AsyncIterable<string>;
386
405
 
387
- export const read = createNodeReadTool({
406
+ export const read = createReadTool({
407
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
408
+ // The default chain reads a notebook as JSON text. notebookClassifier()
409
+ // marks it NOTEBOOK, so notebookConverter() can take it.
410
+ classifiers: [notebookClassifier(), ...defaultClassifiers()],
388
411
  converters: [
389
412
  imageConverter(),
390
413
  notebookConverter({ outputs: false }),
@@ -400,12 +423,12 @@ export const read = createNodeReadTool({
400
423
  });
401
424
  ```
402
425
 
403
- | Converter | Result |
404
- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
405
- | `imageConverter({ transform })` | Accepts code `IMAGE`. Gives a `media` result with the image as one content part. `transform` can resize it. |
406
- | `notebookConverter({ outputs })` | Accepts code `NOTEBOOK`. Gives the cells as text. `outputs` defaults to `true`. Broken notebook JSON is refused as `INVALID_NOTEBOOK`. |
407
- | `textConverter({ id, accepts, mimeType, run })` | Builds a text converter from a stream function, for example `pdftotext`, `pandoc`, or `markitdown` in your host. |
408
- | `directoryListing({ trailingSlash, sort })` | A directory becomes text, one entry for each line. `sort` is `"name"` (the default) or `"type-then-name"`. |
426
+ | Converter | Result |
427
+ | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
428
+ | `imageConverter({ transform })` | Accepts code `IMAGE`. Gives a `media` result with the image as one content part. `transform` can resize it. |
429
+ | `notebookConverter({ outputs })` | Accepts code `NOTEBOOK`, so it needs `notebookClassifier()`. Gives the cells as text. `outputs` defaults to `true`. Broken notebook JSON is refused as `INVALID_NOTEBOOK`. |
430
+ | `textConverter({ id, accepts, mimeType, run })` | Builds a text converter from a stream function, for example `pdftotext`, `pandoc`, or `markitdown` in your host. |
431
+ | `directoryListing({ trailingSlash, sort })` | A directory becomes text, one entry for each line. `sort` is `"name"` (the default) or `"type-then-name"`. |
409
432
 
410
433
  A converter returns one of these outcomes:
411
434
 
@@ -428,7 +451,7 @@ Conversion runs again on each continuation read. Cache inside your converter if
428
451
  | Limit | Default | Meaning |
429
452
  | --------------------- | ------- | ----------------------------------------------------- |
430
453
  | `maxLines` | 2,000 | Lines in the view. Also the ceiling for `limit`. |
431
- | `maxViewBytes` | 128 KiB | UTF-8 bytes of source text in the view |
454
+ | `maxViewBytes` | 50 KiB | UTF-8 bytes of source text in the view |
432
455
  | `maxCharsPerLine` | 2,000 | Characters in one line before it is clamped |
433
456
  | `maxScanBytes` | 64 MiB | Bytes scanned before totals and content identity stop |
434
457
  | `sampleBytes` | 8 KiB | Bytes given to classifiers. At most `maxScanBytes`. |
@@ -446,10 +469,11 @@ Every tool has one parse helper with the same pattern: `parseReadInput`, `parseE
446
469
  For a token limit, set `budget`. The scanner stops at the last whole line that fits, and the result has a normal continuation. The truncation reason is `"budget"`.
447
470
 
448
471
  ```ts
449
- import { createNodeReadTool } from "@better-fs-tools/node";
450
- import { charsPerToken } from "@better-fs-tools/read";
472
+ import { nodeFileSystem } from "@better-fs-tools/node";
473
+ import { charsPerToken, createReadTool } from "@better-fs-tools/read";
451
474
 
452
- export const read = createNodeReadTool({
475
+ export const read = createReadTool({
476
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
453
477
  limits: { maxLines: 1_000, maxViewBytes: 64 * 1024 },
454
478
  budget: charsPerToken({ ratio: 4, max: 25_000 }),
455
479
  });
@@ -464,11 +488,12 @@ For a size ceiling that refuses a whole file, use the `sizeCeiling` authorizer.
464
488
  `hooks` is an ordered list of `afterRead` functions. They run after the scan and change detection, and before the result is recorded and formatted.
465
489
 
466
490
  ```ts
467
- import { createNodeReadTool } from "@better-fs-tools/node";
468
- import { memoryStore, redact, repeatReadGuard } from "@better-fs-tools/read";
491
+ import { nodeFileSystem } from "@better-fs-tools/node";
492
+ import { createReadTool, memoryStore, redact, repeatReadGuard } from "@better-fs-tools/read";
469
493
 
470
- // createNodeReadTool sets digest to nodeDigest(), which repeatReadGuard needs.
471
- export const read = createNodeReadTool({
494
+ // repeatReadGuard needs a digest: the default sha256Digest() is one.
495
+ export const read = createReadTool({
496
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
472
497
  state: memoryStore(),
473
498
  hooks: [repeatReadGuard(), redact({ patterns: [/AKIA[0-9A-Z]{16}/g] })],
474
499
  });
@@ -506,7 +531,7 @@ export const agentsNote: ReadHook<unknown> = {
506
531
  };
507
532
  ```
508
533
 
509
- A repeat read that the guard empties is recorded as it was shown, with `wholeFileVisible: false`. Redaction works line by line, so a secret split across two lines is not matched.
534
+ A repeat read that the guard empties has `observation.wholeFileVisible: false`. The stored record keeps the earlier read's `wholeFileVisible` and line ranges when the content is the same. Redaction works line by line, so a secret split across two lines is not matched.
510
535
 
511
536
  ## Output format
512
537
 
@@ -535,20 +560,22 @@ Its options:
535
560
  Helpers for common formats:
536
561
 
537
562
  ```ts
538
- import { createNodeReadTool } from "@better-fs-tools/node";
563
+ import { nodeFileSystem } from "@better-fs-tools/node";
539
564
  import {
565
+ createReadTool,
540
566
  eofFooter,
541
567
  fileHashHeader,
542
568
  hashlineGutter,
543
569
  lineNumberFormatter,
544
570
  } from "@better-fs-tools/read";
545
571
 
546
- export const read = createNodeReadTool({
572
+ export const read = createReadTool({
573
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
547
574
  formatter: lineNumberFormatter({
548
575
  gutter: hashlineGutter({ width: 2 }), // "12:a3|text"
549
576
  header: fileHashHeader(), // "file-hash: sha256:..."
550
577
  footer: eofFooter((n) => `(End of file - total ${n} lines)`),
551
- notes: (note) => (note.code === "weak-identity" ? null : note),
578
+ notes: (note) => (note.severity === "info" ? null : note),
552
579
  }),
553
580
  });
554
581
  ```
@@ -562,13 +589,17 @@ export const read = createNodeReadTool({
562
589
  Presets that copy the read output of other harnesses are in `@better-fs-tools/read/formats`: `opencodeFormat()`, `deepAgentsFormat()`, `hashlineFormat()`, and `hermesFormat()`. They take no options.
563
590
 
564
591
  ```ts
565
- import { createNodeReadTool } from "@better-fs-tools/node";
592
+ import { nodeFileSystem } from "@better-fs-tools/node";
593
+ import { createReadTool } from "@better-fs-tools/read";
566
594
  import { opencodeFormat } from "@better-fs-tools/read/formats";
567
595
 
568
- export const read = createNodeReadTool({ formatter: opencodeFormat() });
596
+ export const read = createReadTool({
597
+ fs: nodeFileSystem({ allowedRoots: [process.cwd()] }),
598
+ formatter: opencodeFormat(),
599
+ });
569
600
  ```
570
601
 
571
- A custom formatter implements `format(outcome, ctx)`. It is synchronous and pure over the outcome. `ctx` has the `digest`, the `limits`, the `call`, and a `mode`. The mode is `"model"` for model output and `"view"` for the body only, with no notes, header, or footer. The Pi adapter uses `"view"` for its truncation details. If your formatter returns parts in `"view"` mode, Pi leaves those details out. A formatter sees only the finished outcome. It cannot read the file. If the formatter throws or returns neither a string nor an array, the call still succeeds: the core adds an `extension-failed` warning and formats the result with `lineNumberFormatter()`.
602
+ A custom formatter implements `format(outcome, ctx)`. It is synchronous and pure over the outcome. `ctx` has the `digest`, the `limits`, the `call`, and a `mode`. The mode is `"model"` for model output and `"view"` for the body only, with no notes, header, or footer. The Pi adapter uses `"view"` for its truncation details. If your formatter returns parts in `"view"` mode, Pi leaves those details out. A formatter sees only the finished outcome. It cannot read the file. If the formatter throws or returns neither a string nor an array, the call still succeeds: the core formats the result with `lineNumberFormatter()` and adds no note.
572
603
 
573
604
  ## Messages and notes
574
605
 
@@ -602,7 +633,7 @@ No default message names `offset` or `limit`. Every message that suggests a retr
602
633
  | `unsupported` | A classifier or converter refused the format. `code` is open, for example `PDF` or `TOO_LARGE`. |
603
634
  | `error` | `error.code` is one of thirteen `ReadErrorCode` values. |
604
635
 
605
- The error codes are `INVALID_INPUT`, `NOT_FOUND`, `NOT_A_FILE`, `DANGEROUS_PATH`, `OUTSIDE_ALLOWED_ROOTS`, `PERMISSION_DENIED`, `DENIED`, `TOO_LARGE`, `CHANGED_DURING_READ`, `ABORTED`, `UNSUPPORTED_BACKEND`, `EXTENSION_FAILED`, and `IO_ERROR`. `TOO_LARGE` is an `error` when a backend byte ceiling refuses the file, and an `unsupported` code when a converter or media limit stops it. `EXTENSION_FAILED` means that host code threw or broke a rule. Its note data names the dependency and the stage, for example `{ extension: "hooks", phase: "hooks", id: "redact" }`.
636
+ The error codes are `INVALID_INPUT`, `NOT_FOUND`, `NOT_A_FILE`, `DANGEROUS_PATH`, `OUTSIDE_ALLOWED_ROOTS`, `PERMISSION_DENIED`, `DENIED`, `TOO_LARGE`, `CHANGED_DURING_READ`, `ABORTED`, `UNSUPPORTED_BACKEND`, `EXTENSION_FAILED`, and `IO_ERROR`. `TOO_LARGE` is an `error` when a backend byte ceiling refuses the file, and an `unsupported` code when a converter or media limit stops it. `EXTENSION_FAILED` means that host code threw or broke a rule. Its note data names the option and the stage, for example `{ extension: "hooks", phase: "hooks", id: "redact" }`.
606
637
 
607
638
  An `ok` result has structured lines, and an `error` result has its error:
608
639
 
@@ -669,36 +700,39 @@ await read({ path: "/a.txt" }, { host: session, callId: "call-1" });
669
700
 
670
701
  - `fs` can be a function of the call. It runs once for each read, before `resolve`. Use it to choose a backend for each call.
671
702
  - `sha256Digest()` is SHA-256 in plain JavaScript. It is synchronous and needs no Node module, so it works in a Cloudflare Worker. Its id and its values (`sha256:<hex>`) are the same as `nodeDigest()`, so records from either one match.
672
- - `state` needs a `digest`, because a record names the digest that made it. `createReadTool` throws `TypeError` for a `state` without a `digest`, and the dependency type (`StateNeedsDigest`) refuses it at compile time. `StateNeedsDigestOrDefault` is the same rule for a factory with its own default digest, such as the Node and Pi factories: leave `digest` out for the default, or pass `digest: null` with no `state`. The bundles (`createFsTools()` and the host bundles that wrap it) always have a digest, so they need neither.
703
+ - `state` needs a `digest`, because a record names the digest that made it. `digest` defaults to `sha256Digest()`, so a `state` alone works. `createReadTool` throws `TypeError` for a `state` with `digest: null`, and the options type (`StateNeedsDigest`) refuses it at compile time.
673
704
  - `state` can be a function of the call. It runs at most once for each read, and only when the core needs the store. Return `null` for no store. A factory that throws gives `EXTENSION_FAILED`. A store whose `get` or `put` fails never fails the read.
674
- - The record key is `file.resolvedPath`. A read stores a `ReadRecord` with `schema: 2` and `origin: "read"`. It holds the backend `version`, the `digest` id, the content and view ids, and the read range. The write tools store records with `origin: "write"` and `request: null` after a commit. A record of another schema counts as absent.
705
+ - The record key is `file.resolvedPath`. A read stores a `ReadRecord` with `schema: 2` and `origin: "read"`. It holds the backend `version`, the `digest` id, the content and view ids, the read range, and `seenLines`. `seenLines` joins the ranges that reads of the same content showed in full. When they run from line 1 to the last line, the record is `wholeFileVisible`, so reads in ranges count as a read of the whole file. The write tools store records with `origin: "write"` and `request: null` after a commit. A record of another schema counts as absent.
675
706
  - `result.file.version` is the backend's change token from `open()`, or `null`. It is kept when the backend has no identity capability.
676
707
  - `memoryStore()` from `@better-fs-tools/read` keeps records in memory, with a size cap and a TTL. Its `clock` option is the same `Clock` (`() => Date`) that the tools take, so one fake clock fits both. It sits on the root of the package that owns `ReadStateStore`, as `memoryLocks()` sits on the root of `@better-fs-tools/write`, which owns `LockManager`.
677
708
  - The helpers in this package are typed with `unknown` for the host, so they fit a tool with any host type. `askUser`, `imageConverter`, and `textConverter` can take your host type, as `askUser<Session>` does above.
678
709
 
679
710
  ## Security guarantees
680
711
 
681
- Your dependencies can narrow access. They cannot widen it.
712
+ Your options can narrow access. They cannot widen it.
682
713
 
683
714
  - The filesystem is the only place that grants access. It checks roots, deny roots, the realpath, and the type in one `open()` call. `resolve` runs before it and `authorize` runs after it.
684
715
  - Each read opens at most one file. A resolver only changes the path that goes into that open.
685
716
  - `nodeFileSystem` opens files with `O_NOFOLLOW | O_NONBLOCK` and checks the type first. It refuses directories, FIFOs, sockets, and devices before any content read. It refuses `/dev`, `/proc`, and `/sys` before it touches the filesystem.
686
- - The core reads no content byte before `authorize` allows it, so no content reaches a classifier, a converter, or the model. `@better-fs-tools/cloudflare-shell` and `@better-fs-tools/just-bash` (a result with a `buffered-backend` note) have already fetched the whole file in `open()`. `@better-fs-tools/cloudflare-computer` has started a `readFile` stream but read no chunk. If a denied read must not reach the backend, use a deny root, or leave the path out of the filesystem's allowed roots.
717
+ - The core reads no content byte before `authorize` allows it, so no content reaches a classifier, a converter, or the model. `@better-fs-tools/cloudflare-shell` and `@better-fs-tools/just-bash` have already fetched the whole file in `open()`. `@better-fs-tools/cloudflare-computer` has started a `readFile` stream but read no chunk. If a denied read must not reach the backend, use a deny root, or leave the path out of the filesystem's allowed roots.
687
718
  - The scan is bounded. Converters get a capped stream from the open file, never a path.
688
719
  - Change detection runs after the scan and after conversion. A file that changed returns `CHANGED_DURING_READ`.
689
720
  - A suggested name is never opened.
690
721
  - A hook cannot turn a failure into a success. A hook that changes the view marks the observation as partial.
691
722
  - A `DENIED` result does not show the file behind the path.
692
723
 
693
- ## Host adapters
724
+ ## Integrations
725
+
726
+ Each integration takes the options of `readToolDefinition()`: every core option, with the same default, plus `signature`. `fs` is required in every one. The integration owns abort forwarding and the mapping from `result.content` to the framework's content types.
694
727
 
695
- Each adapter owns the signature, abort forwarding, and the mapping from `result.content` to the framework's content types. The adapters accept every core dependency and pass it through.
728
+ | Package | Factory | Notes |
729
+ | ------------------------------------------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
730
+ | [`@better-fs-tools/ai-sdk`](https://www.npmjs.com/package/@better-fs-tools/ai-sdk) | `createAiSdkReadTool(options)` | `execute` returns the whole `ReadResult`. `toModelOutput` maps media parts to AI SDK `file` parts. |
731
+ | [`@better-fs-tools/pi`](https://www.npmjs.com/package/@better-fs-tools/pi) | `createPiReadTool(options)` | Adds the Pi prompt options. `piCwdFileSystem()` from `@better-fs-tools/pi/node` roots each call at Pi's `ctx.cwd`. |
732
+ | [`@better-fs-tools/pi`](https://www.npmjs.com/package/@better-fs-tools/pi) | `createAgentCoreReadTool(options)` from `./agent-core` | An `AgentTool` for `@earendil-works/pi-agent-core`. |
733
+ | [`@better-fs-tools/pi-durable`](https://www.npmjs.com/package/@better-fs-tools/pi-durable) | `createDurableFsTools(options)` | A bundle only: the tools register inside a pi-durable extension. |
696
734
 
697
- | Package | Factory | Notes |
698
- | ---------------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
699
- | [`@better-fs-tools/ai-sdk`](https://www.npmjs.com/package/@better-fs-tools/ai-sdk) | `createAiSdkReadTool(options)` | `execute` returns the whole `ReadResult`. `toModelOutput` maps media parts to AI SDK `file` parts. You pass `fs`. |
700
- | [`@better-fs-tools/pi`](https://www.npmjs.com/package/@better-fs-tools/pi) | `createPiReadTool(options)` | Each call is confined to Pi's `ctx.cwd`. `fs`, `cwd`, and `allowedRoots` are refused. The `pi.extensions` entry registers `read`, `edit`, `write`, and `apply_patch`. |
701
- | [`@better-fs-tools/node`](https://www.npmjs.com/package/@better-fs-tools/node) | `createNodeReadTool(options?)` | The core with Node defaults, for your own tool. |
735
+ For your own framework, call `readToolDefinition()` from `./signature`. It returns the name, description, and JSON Schema to declare, a `validate` for model input, and an `execute` that maps model input and runs the tool.
702
736
 
703
737
  Cloudflare Agents hosts use `@better-fs-tools/ai-sdk` with `cloudflareShellFileSystem` from [`@better-fs-tools/cloudflare-shell`](https://www.npmjs.com/package/@better-fs-tools/cloudflare-shell). [`@better-fs-tools/cloudflare-computer`](https://www.npmjs.com/package/@better-fs-tools/cloudflare-computer) and [`@better-fs-tools/just-bash`](https://www.npmjs.com/package/@better-fs-tools/just-bash) are filesystems. Use them with `createReadTool()` or with an adapter.
704
738
 
@@ -723,7 +757,8 @@ This Pi host sets most dimensions. You will usually need only a few of these opt
723
757
  ```ts
724
758
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
725
759
  import { createPiReadTool } from "@better-fs-tools/pi";
726
- import { lineRangeSignature } from "@better-fs-tools/read/signature";
760
+ import { piCwdFileSystem } from "@better-fs-tools/pi/node";
761
+ import { defaultReadSignature } from "@better-fs-tools/read/signature";
727
762
  import {
728
763
  askUser,
729
764
  denyPaths,
@@ -744,9 +779,9 @@ import {
744
779
  const stores = new Map<string, ReturnType<typeof memoryStore>>();
745
780
 
746
781
  export const read = createPiReadTool({
747
- signature: lineRangeSignature({
748
- name: "read",
749
- names: { path: "file_path", start: "start_line", end: "end_line" },
782
+ fs: piCwdFileSystem(),
783
+ signature: defaultReadSignature({
784
+ names: { path: "file_path", offset: "start_line", limit: "max_lines" },
750
785
  }),
751
786
  resolve: pathResolvers(stripPrefixes(), unicodeRepair({ note: false })),
752
787
  authorize: readAuthorizers(
@@ -8,8 +8,9 @@ export { officeClassifier } from "./office.ts";
8
8
  export { pdfClassifier } from "./pdf.ts";
9
9
  export { utf8Classifier } from "./utf8.ts";
10
10
  /**
11
- * The built-in chain in evaluation order: image, pdf, office, notebook,
12
- * binary, utf8. The first classifier with an opinion wins. utf8Classifier
13
- * runs last and is the one that returns text.
11
+ * The built-in chain in evaluation order: image, pdf, office, binary, utf8.
12
+ * The first classifier with an opinion wins. utf8Classifier runs last and is
13
+ * the one that returns text. A notebook is JSON text here. Put
14
+ * notebookClassifier() before the chain to refuse or convert notebooks.
14
15
  */
15
16
  export declare function defaultClassifiers(options?: DefaultClassifierOptions): Classifier[];
@@ -1,6 +1,5 @@
1
1
  import { binaryClassifier } from "./binary.js";
2
2
  import { imageClassifier } from "./image.js";
3
- import { notebookClassifier } from "./notebook.js";
4
3
  import { officeClassifier } from "./office.js";
5
4
  import { pdfClassifier } from "./pdf.js";
6
5
  import { utf8Classifier } from "./utf8.js";
@@ -12,9 +11,10 @@ export { officeClassifier } from "./office.js";
12
11
  export { pdfClassifier } from "./pdf.js";
13
12
  export { utf8Classifier } from "./utf8.js";
14
13
  /**
15
- * The built-in chain in evaluation order: image, pdf, office, notebook,
16
- * binary, utf8. The first classifier with an opinion wins. utf8Classifier
17
- * runs last and is the one that returns text.
14
+ * The built-in chain in evaluation order: image, pdf, office, binary, utf8.
15
+ * The first classifier with an opinion wins. utf8Classifier runs last and is
16
+ * the one that returns text. A notebook is JSON text here. Put
17
+ * notebookClassifier() before the chain to refuse or convert notebooks.
18
18
  */
19
19
  export function defaultClassifiers(options = {}) {
20
20
  const notes = options.notes ?? {};
@@ -22,7 +22,6 @@ export function defaultClassifiers(options = {}) {
22
22
  imageClassifier(withNote(notes.IMAGE)),
23
23
  pdfClassifier(withNote(notes.PDF)),
24
24
  officeClassifier(withNote(notes.OFFICE_DOCUMENT)),
25
- notebookClassifier(withNote(notes.NOTEBOOK)),
26
25
  binaryClassifier(withNote(notes.BINARY)),
27
26
  utf8Classifier(withNote(notes.UNKNOWN_ENCODING)),
28
27
  ];
@@ -107,15 +107,43 @@ export interface ToolSignature<TInput, TParam extends string = string> {
107
107
  * that names it.
108
108
  */
109
109
  param(name: TParam): string;
110
- /** Set on freeform signatures. Hosts that support grammar tools use it. */
110
+ /** A Lark grammar for hosts with grammar tools. freeformPatchSignature() sets CODEX_PATCH_GRAMMAR. */
111
111
  readonly grammar?: {
112
112
  readonly lark: string;
113
113
  };
114
114
  }
115
115
  /**
116
- * The options every signature preset takes. `TParam` is the preset's own
116
+ * A core tool bound to a signature: what a framework adapter needs to declare
117
+ * the tool and run it. The xxxToolDefinition() helpers in each `./signature`
118
+ * subpath build one, and every integration uses them.
119
+ */
120
+ export interface ToolDefinition<TInput, TResult, THost = undefined, TParam extends string = string> {
121
+ /** The tool name the model sees: `signature.name`. */
122
+ readonly name: string;
123
+ readonly description: string;
124
+ /** Plain JSON Schema of the model input: `signature.schema`. */
125
+ readonly inputSchema: JsonObject;
126
+ /** Set when the signature has a grammar. */
127
+ readonly grammar?: {
128
+ readonly lark: string;
129
+ };
130
+ readonly signature: ToolSignature<TInput, TParam>;
131
+ /**
132
+ * Checks model input with the signature and with the core input check at the
133
+ * tool's limits, and returns the canonical input. Throws TypeError that names
134
+ * the signature's parameters.
135
+ */
136
+ validate(modelInput: unknown): TInput;
137
+ /**
138
+ * Maps model input with `signature.toInput` and runs the core tool. Bad input
139
+ * rejects with TypeError. A tool error is a result, not a rejection.
140
+ */
141
+ execute(modelInput: unknown, ctx: ToolCallContext<THost>): Promise<TResult>;
142
+ }
143
+ /**
144
+ * The options every default signature takes. `TParam` is the signature's own
117
145
  * parameter names. `names` gives a parameter another name in the schema, and
118
- * `describe` replaces its description. Both are keyed by the preset's names.
146
+ * `describe` replaces its description. Both are keyed by the signature's names.
119
147
  */
120
148
  export interface SignatureDocs<TParam extends string> {
121
149
  /** The tool name. */
@@ -20,7 +20,10 @@ export interface ReadDependencies<THost = undefined> {
20
20
  readonly suggest: Suggest<THost> | null;
21
21
  /** Host access policy for the opened file ("read") and before every listing ("list"). null allows everything. */
22
22
  readonly authorize: ReadAuthorizer<THost> | null;
23
- /** File converters in order: the first that accepts runs. The first directory converter lists directories. */
23
+ /**
24
+ * File converters in order: the first that accepts runs. The first directory
25
+ * converter lists directories. Default [imageConverter()]. [] turns images off.
26
+ */
24
27
  readonly converters: readonly Converter<THost>[];
25
28
  /** Stops the view early, at a line boundary, when the next line would pass `max`. null has no budget. */
26
29
  readonly budget: ViewBudget | null;
@@ -28,32 +31,24 @@ export interface ReadDependencies<THost = undefined> {
28
31
  readonly hooks: readonly ReadHook<THost>[];
29
32
  /** A store, or a factory called at most once for each read, and only when needed. Needs `digest`. */
30
33
  readonly state: ReadStateStore | ((call: ReadContext<THost>) => ReadStateStore | null) | null;
34
+ /** Names content for the read state and the observations. Default sha256Digest(). null turns both off. */
31
35
  readonly digest: Digest | null;
32
36
  readonly clock: Clock;
33
37
  readonly formatter: ReadFormatter<THost>;
34
38
  }
35
39
  /**
36
40
  * `state` needs a `digest`, because a stored record names the digest that made
37
- * it. `state` without `digest` does not type-check, and throws TypeError at run
38
- * time.
41
+ * it. Leave `digest` out to get sha256Digest(). `digest: null` turns it off,
42
+ * and then `state` must be left out or null. A `state` with `digest: null`
43
+ * does not type-check, and throws TypeError at run time.
39
44
  */
40
45
  export type StateNeedsDigest = {
41
46
  readonly state?: null;
42
- } | {
43
- readonly digest: Digest;
44
- };
45
- /**
46
- * The same pairing for a factory with its own default digest, such as the
47
- * Node and Pi factories. Leave `digest` out to get the default. `digest: null`
48
- * turns it off, and then `state` must be left out or null.
49
- */
50
- export type StateNeedsDigestOrDefault = {
51
- readonly state?: null;
52
47
  readonly digest: null;
53
48
  } | {
54
49
  readonly digest?: Digest;
55
50
  };
56
- export type ReadToolDeps<THost = undefined> = {
51
+ export type ReadToolOptions<THost = undefined> = {
57
52
  readonly fs: ReadDependencies<THost>["fs"];
58
53
  readonly limits?: Partial<ReadLimits>;
59
54
  readonly messages?: Partial<ReadMessageCatalog>;
@@ -1,7 +1,7 @@
1
1
  export interface ReadLimits {
2
2
  /** Lines in the view. Default 2_000. */
3
3
  readonly maxLines: number;
4
- /** UTF-8 bytes of source text in the view. Default 128 KiB. */
4
+ /** UTF-8 bytes of source text in the view. Default 50 KiB (51 200). */
5
5
  readonly maxViewBytes: number;
6
6
  /** Characters per line before clamping. Default 2_000. */
7
7
  readonly maxCharsPerLine: number;
@@ -107,14 +107,4 @@ export interface ReadMessageCatalog extends ToolMessages {
107
107
  viewModified(c: {
108
108
  hook: string;
109
109
  }): string;
110
- weakIdentity(c: {
111
- backend: string;
112
- }): string;
113
- bufferedBackend(c: {
114
- backend: string;
115
- }): string;
116
- /** The formatter threw or returned neither a string nor an array. */
117
- formatterFailed(c: {
118
- formatter: string;
119
- }): string;
120
110
  }