@better-fs-tools/read 0.0.0-tegami-trusted-publish-setup → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +800 -6
- package/dist/authorize/ask-user.d.ts +7 -0
- package/dist/authorize/ask-user.js +25 -0
- package/dist/authorize/authorizers.d.ts +7 -0
- package/dist/authorize/authorizers.js +34 -0
- package/dist/authorize/deny-paths.d.ts +7 -0
- package/dist/authorize/deny-paths.js +25 -0
- package/dist/authorize/glob.d.ts +7 -0
- package/dist/authorize/glob.js +46 -0
- package/dist/authorize/index.d.ts +5 -0
- package/dist/authorize/index.js +5 -0
- package/dist/authorize/shared.d.ts +9 -0
- package/dist/authorize/shared.js +12 -0
- package/dist/authorize/size-ceiling.d.ts +10 -0
- package/dist/authorize/size-ceiling.js +34 -0
- package/dist/budget/chars-per-token.d.ts +9 -0
- package/dist/budget/chars-per-token.js +15 -0
- package/dist/budget/index.d.ts +2 -0
- package/dist/budget/index.js +1 -0
- package/dist/classifiers/binary.d.ts +5 -0
- package/dist/classifiers/binary.js +13 -0
- package/dist/classifiers/extension.d.ts +17 -0
- package/dist/classifiers/extension.js +45 -0
- package/dist/classifiers/image.d.ts +5 -0
- package/dist/classifiers/image.js +22 -0
- package/dist/classifiers/index.d.ts +15 -0
- package/dist/classifiers/index.js +32 -0
- package/dist/classifiers/notebook.d.ts +5 -0
- package/dist/classifiers/notebook.js +14 -0
- package/dist/classifiers/office.d.ts +5 -0
- package/dist/classifiers/office.js +30 -0
- package/dist/classifiers/pdf.d.ts +4 -0
- package/dist/classifiers/pdf.js +8 -0
- package/dist/classifiers/shared.d.ts +17 -0
- package/dist/classifiers/shared.js +57 -0
- package/dist/classifiers/utf8.d.ts +8 -0
- package/dist/classifiers/utf8.js +61 -0
- package/dist/contract/base.d.ts +126 -0
- package/dist/contract/base.js +0 -0
- package/dist/contract/classify.d.ts +50 -0
- package/dist/contract/classify.js +0 -0
- package/dist/contract/context.d.ts +13 -0
- package/dist/contract/context.js +0 -0
- package/dist/contract/deps.d.ts +60 -0
- package/dist/contract/deps.js +0 -0
- package/dist/contract/digest.d.ts +13 -0
- package/dist/contract/digest.js +0 -0
- package/dist/contract/extensions.d.ts +115 -0
- package/dist/contract/extensions.js +0 -0
- package/dist/contract/format.d.ts +16 -0
- package/dist/contract/format.js +0 -0
- package/dist/contract/input.d.ts +16 -0
- package/dist/contract/input.js +0 -0
- package/dist/contract/json.d.ts +6 -0
- package/dist/contract/json.js +0 -0
- package/dist/contract/limits.d.ts +20 -0
- package/dist/contract/limits.js +0 -0
- package/dist/contract/messages.d.ts +120 -0
- package/dist/contract/messages.js +0 -0
- package/dist/contract/result.d.ts +153 -0
- package/dist/contract/result.js +0 -0
- package/dist/contract/state.d.ts +36 -0
- package/dist/contract/state.js +0 -0
- package/dist/converters/directory-listing.d.ts +13 -0
- package/dist/converters/directory-listing.js +63 -0
- package/dist/converters/image.d.ts +7 -0
- package/dist/converters/image.js +25 -0
- package/dist/converters/index.d.ts +8 -0
- package/dist/converters/index.js +4 -0
- package/dist/converters/notebook.d.ts +12 -0
- package/dist/converters/notebook.js +106 -0
- package/dist/converters/shared.d.ts +5 -0
- package/dist/converters/shared.js +18 -0
- package/dist/converters/text.d.ts +12 -0
- package/dist/converters/text.js +24 -0
- package/dist/core/authorize.d.ts +8 -0
- package/dist/core/authorize.js +76 -0
- package/dist/core/budget.d.ts +7 -0
- package/dist/core/budget.js +23 -0
- package/dist/core/call-scope.d.ts +36 -0
- package/dist/core/call-scope.js +112 -0
- package/dist/core/classify.d.ts +9 -0
- package/dist/core/classify.js +27 -0
- package/dist/core/convert-source.d.ts +29 -0
- package/dist/core/convert-source.js +85 -0
- package/dist/core/convert.d.ts +23 -0
- package/dist/core/convert.js +133 -0
- package/dist/core/converted.d.ts +18 -0
- package/dist/core/converted.js +111 -0
- package/dist/core/create-read-tool.d.ts +7 -0
- package/dist/core/create-read-tool.js +18 -0
- package/dist/core/cursor.d.ts +14 -0
- package/dist/core/cursor.js +93 -0
- package/dist/core/deps.d.ts +6 -0
- package/dist/core/deps.js +121 -0
- package/dist/core/directory.d.ts +16 -0
- package/dist/core/directory.js +113 -0
- package/dist/core/extension-error.d.ts +9 -0
- package/dist/core/extension-error.js +18 -0
- package/dist/core/format.d.ts +8 -0
- package/dist/core/format.js +43 -0
- package/dist/core/hooks.d.ts +3 -0
- package/dist/core/hooks.js +140 -0
- package/dist/core/input.d.ts +10 -0
- package/dist/core/input.js +45 -0
- package/dist/core/limits.d.ts +9 -0
- package/dist/core/limits.js +43 -0
- package/dist/core/messages.d.ts +8 -0
- package/dist/core/messages.js +81 -0
- package/dist/core/notes.d.ts +16 -0
- package/dist/core/notes.js +114 -0
- package/dist/core/observation.d.ts +11 -0
- package/dist/core/observation.js +51 -0
- package/dist/core/open.d.ts +16 -0
- package/dist/core/open.js +33 -0
- package/dist/core/outcomes.d.ts +27 -0
- package/dist/core/outcomes.js +172 -0
- package/dist/core/pipeline.d.ts +4 -0
- package/dist/core/pipeline.js +175 -0
- package/dist/core/record.d.ts +3 -0
- package/dist/core/record.js +37 -0
- package/dist/core/resolve.d.ts +15 -0
- package/dist/core/resolve.js +43 -0
- package/dist/core/same.d.ts +2 -0
- package/dist/core/same.js +22 -0
- package/dist/core/sample.d.ts +6 -0
- package/dist/core/sample.js +24 -0
- package/dist/core/scan.d.ts +26 -0
- package/dist/core/scan.js +95 -0
- package/dist/core/scanner.d.ts +29 -0
- package/dist/core/scanner.js +124 -0
- package/dist/core/suggest.d.ts +6 -0
- package/dist/core/suggest.js +41 -0
- package/dist/core/text-outcome.d.ts +19 -0
- package/dist/core/text-outcome.js +73 -0
- package/dist/core/verify.d.ts +7 -0
- package/dist/core/verify.js +15 -0
- package/dist/digest/sha256.d.ts +7 -0
- package/dist/digest/sha256.js +131 -0
- package/dist/formats/deep-agents.d.ts +7 -0
- package/dist/formats/deep-agents.js +23 -0
- package/dist/formats/hashline.d.ts +6 -0
- package/dist/formats/hashline.js +16 -0
- package/dist/formats/hermes.d.ts +9 -0
- package/dist/formats/hermes.js +62 -0
- package/dist/formats/index.d.ts +4 -0
- package/dist/formats/index.js +4 -0
- package/dist/formats/opencode.d.ts +7 -0
- package/dist/formats/opencode.js +35 -0
- package/dist/formats/shared.d.ts +4 -0
- package/dist/formats/shared.js +9 -0
- package/dist/formatters/eof.d.ts +7 -0
- package/dist/formatters/eof.js +20 -0
- package/dist/formatters/file-hash.d.ts +7 -0
- package/dist/formatters/file-hash.js +13 -0
- package/dist/formatters/hashline.d.ts +11 -0
- package/dist/formatters/hashline.js +18 -0
- package/dist/formatters/index.d.ts +10 -0
- package/dist/formatters/index.js +7 -0
- package/dist/formatters/json.d.ts +17 -0
- package/dist/formatters/json.js +36 -0
- package/dist/formatters/layout.d.ts +15 -0
- package/dist/formatters/layout.js +33 -0
- package/dist/formatters/line-number.d.ts +11 -0
- package/dist/formatters/line-number.js +23 -0
- package/dist/formatters/plain.d.ts +7 -0
- package/dist/formatters/plain.js +14 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/redact.d.ts +14 -0
- package/dist/hooks/redact.js +53 -0
- package/dist/hooks/repeat-read-guard.d.ts +13 -0
- package/dist/hooks/repeat-read-guard.js +49 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +15 -0
- package/dist/resolve/expand-home.d.ts +5 -0
- package/dist/resolve/expand-home.js +17 -0
- package/dist/resolve/index.d.ts +5 -0
- package/dist/resolve/index.js +5 -0
- package/dist/resolve/path-resolvers.d.ts +7 -0
- package/dist/resolve/path-resolvers.js +38 -0
- package/dist/resolve/reanchor-leading-slash.d.ts +9 -0
- package/dist/resolve/reanchor-leading-slash.js +23 -0
- package/dist/resolve/strip-prefixes.d.ts +6 -0
- package/dist/resolve/strip-prefixes.js +30 -0
- package/dist/resolve/unicode-repair.d.ts +9 -0
- package/dist/resolve/unicode-repair.js +38 -0
- package/dist/signature/contract.d.ts +18 -0
- package/dist/signature/contract.js +0 -0
- package/dist/signature/index.d.ts +4 -0
- package/dist/signature/index.js +3 -0
- package/dist/signature/line-range.d.ts +7 -0
- package/dist/signature/line-range.js +59 -0
- package/dist/signature/messages.d.ts +4 -0
- package/dist/signature/messages.js +9 -0
- package/dist/signature/offset-limit.d.ts +13 -0
- package/dist/signature/offset-limit.js +59 -0
- package/dist/signature/schema.d.ts +10 -0
- package/dist/signature/schema.js +80 -0
- package/dist/state/index.d.ts +15 -0
- package/dist/state/index.js +77 -0
- package/dist/suggest/canonicalize.d.ts +6 -0
- package/dist/suggest/canonicalize.js +12 -0
- package/dist/suggest/default-suggest.d.ts +3 -0
- package/dist/suggest/default-suggest.js +12 -0
- package/dist/suggest/index.d.ts +3 -0
- package/dist/suggest/index.js +3 -0
- package/dist/suggest/suggest-file-names.d.ts +2 -0
- package/dist/suggest/suggest-file-names.js +46 -0
- package/docs/architecture.md +249 -0
- package/docs/result-schema.md +222 -0
- package/package.json +51 -2
package/README.md
CHANGED
|
@@ -1,6 +1,800 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
# @better-fs-tools/read
|
|
2
|
+
|
|
3
|
+
A bounded file reader for TypeScript agent tools. You can change every part of it.
|
|
4
|
+
|
|
5
|
+
A read returns an exact, limited view of a file. You get structured lines, explicit truncation, a continuation that the model can copy, and content for the model. The package does not summarize. The core owns the safety rules. Your host owns everything else: the filesystem, path handling, permissions, file conversion, output format, limits, and the shape of the tool that the model sees.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @better-fs-tools/read @better-fs-tools/fs
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`read` depends on `@better-fs-tools/fs`. Install `fs` yourself when your code imports from it, for example `memoryFileSystem` or the `FileSystem` type.
|
|
14
|
+
|
|
15
|
+
Most hosts also install one adapter package. Each adapter lists its own peers:
|
|
16
|
+
|
|
17
|
+
| Package | Use it for | Install |
|
|
18
|
+
| -------------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
|
19
|
+
| `@better-fs-tools/read` | The core, the helpers, and the `./signature` and `./formats` subpaths | `npm install @better-fs-tools/read` |
|
|
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` |
|
|
22
|
+
| `@better-fs-tools/ai-sdk` | An AI SDK 7 tool | `npm install @better-fs-tools/ai-sdk @better-fs-tools/read ai` |
|
|
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
|
+
| `@better-fs-tools/cloudflare-shell` | A filesystem over a Cloudflare Shell Workspace | `npm install @better-fs-tools/cloudflare-shell @better-fs-tools/read` |
|
|
25
|
+
| `@better-fs-tools/cloudflare-computer` | A filesystem over a Cloudflare Computer workspace (experimental) | `npm install @better-fs-tools/cloudflare-computer @better-fs-tools/read` |
|
|
26
|
+
| `@better-fs-tools/just-bash` | A filesystem over a just-bash `IFileSystem` | `npm install @better-fs-tools/just-bash @better-fs-tools/read just-bash` |
|
|
27
|
+
|
|
28
|
+
All packages have the same version. Install matching versions.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
On Node or Bun, `createNodeReadTool()` gives a working local reader with no options:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
36
|
+
import { textOf } from "@better-fs-tools/read";
|
|
37
|
+
|
|
38
|
+
// Rooted at process.cwd(), with SHA-256 observations.
|
|
39
|
+
const read = createNodeReadTool();
|
|
40
|
+
|
|
41
|
+
const result = await read({ path: "package.json", limit: 2 });
|
|
42
|
+
console.log(result.status); // "ok"
|
|
43
|
+
console.log(textOf(result));
|
|
44
|
+
// 1|{
|
|
45
|
+
// 2| "name": "my-app",
|
|
46
|
+
//
|
|
47
|
+
// [read:continue] Output stopped at the line limit. Continue with {"path":"package.json","offset":3,"limit":2}.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`createNodeReadTool()` uses `nodeFileSystem` rooted at `process.cwd()`, and `nodeDigest()` for content hashes. It passes every other option to `createReadTool()`.
|
|
51
|
+
|
|
52
|
+
`createReadTool()` is the core. It needs a filesystem, and it works with any `FileSystem`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { memoryFileSystem } from "@better-fs-tools/fs";
|
|
56
|
+
import { createReadTool, textOf } from "@better-fs-tools/read";
|
|
57
|
+
|
|
58
|
+
const read = createReadTool({
|
|
59
|
+
fs: memoryFileSystem({ files: { "/notes.txt": "one\ntwo\nthree\n" } }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await read({ path: "/notes.txt", offset: 2 });
|
|
63
|
+
console.log(result.content); // [{ type: "text", text: "2|two\n3|three" }]
|
|
64
|
+
console.log(textOf(result)); // "2|two\n3|three"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The result has `content`, the parts that the model sees. `textOf(result)` joins the text parts with `"\n"`. There is no `result.text`.
|
|
68
|
+
|
|
69
|
+
## Contents
|
|
70
|
+
|
|
71
|
+
- [How a read works](#how-a-read-works)
|
|
72
|
+
- [What you can change](#what-you-can-change)
|
|
73
|
+
- [Tool signature and docs](#tool-signature-and-docs)
|
|
74
|
+
- [Path resolution](#path-resolution)
|
|
75
|
+
- [Suggestions on a miss](#suggestions-on-a-miss)
|
|
76
|
+
- [Permissions](#permissions)
|
|
77
|
+
- [Binary detection](#binary-detection)
|
|
78
|
+
- [File conversion, images, and directories](#file-conversion-images-and-directories)
|
|
79
|
+
- [Limits and the token budget](#limits-and-the-token-budget)
|
|
80
|
+
- [After-read hooks](#after-read-hooks)
|
|
81
|
+
- [Output format](#output-format)
|
|
82
|
+
- [Messages and notes](#messages-and-notes)
|
|
83
|
+
- [The result](#the-result)
|
|
84
|
+
- [Host context and state](#host-context-and-state)
|
|
85
|
+
- [Security guarantees](#security-guarantees)
|
|
86
|
+
- [Host adapters](#host-adapters)
|
|
87
|
+
- [Exports](#exports)
|
|
88
|
+
- [A host that changes everything](#a-host-that-changes-everything)
|
|
89
|
+
- [Writing a filesystem adapter](#writing-a-filesystem-adapter)
|
|
90
|
+
- [Runtime](#runtime)
|
|
91
|
+
|
|
92
|
+
## How a read works
|
|
93
|
+
|
|
94
|
+
The core function always takes the same input: `read({ path, offset?, limit? }, ctx?)`. The tool that the model sees can use other names and another range model. The adapter translates them (see [Tool signature and docs](#tool-signature-and-docs)).
|
|
95
|
+
|
|
96
|
+
Each read goes through these steps in this order:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
model input
|
|
100
|
+
│ signature (adapter) ...... tool name, schema, parameter names, range model
|
|
101
|
+
▼
|
|
102
|
+
read({ path, offset, limit }, ctx)
|
|
103
|
+
│ input ................... strict validation, limit clamp
|
|
104
|
+
│ fs(call) ................ your filesystem, or a factory called once for each read
|
|
105
|
+
│ resolve ................. rewrite the path, or report not found
|
|
106
|
+
│ fs.open ................. roots, deny roots, realpath, type check: one open for each read
|
|
107
|
+
│ ├─ on a miss: suggest one bounded listing, names only
|
|
108
|
+
│ └─ on a directory .... a directory converter lists it, else NOT_A_FILE
|
|
109
|
+
│ authorize ............... host policy on the open file, before the core reads content
|
|
110
|
+
│ sample + classifiers .... text, image, PDF, notebook, binary, ...
|
|
111
|
+
│ converters .............. other formats to text or media parts
|
|
112
|
+
│ scan .................... lines, clamping, view bytes, token budget, scan limit
|
|
113
|
+
│ verify .................. size check and change detection
|
|
114
|
+
│ hooks ................... redaction, repeat-read guard, your own
|
|
115
|
+
│ state ................... record the observation
|
|
116
|
+
│ formatter ............... text or content parts for the model
|
|
117
|
+
▼
|
|
118
|
+
ReadResult
|
|
119
|
+
```
|
|
120
|
+
|
|
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.
|
|
122
|
+
|
|
123
|
+
## What you can change
|
|
124
|
+
|
|
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:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
149
|
+
import { defaultClassifiers, extensionClassifier } from "@better-fs-tools/read";
|
|
150
|
+
|
|
151
|
+
export const read = createNodeReadTool({
|
|
152
|
+
// A list dependency replaces the default. Include the default to add to it.
|
|
153
|
+
classifiers: [
|
|
154
|
+
extensionClassifier({ unsupported: { ".parquet": { code: "BINARY" } } }),
|
|
155
|
+
...defaultClassifiers(),
|
|
156
|
+
],
|
|
157
|
+
// limits and messages merge over their defaults key by key.
|
|
158
|
+
limits: { maxLines: 500 },
|
|
159
|
+
messages: { notFound: ({ request }) => `No file at ${request.path}.` },
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
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`.
|
|
164
|
+
|
|
165
|
+
## Tool signature and docs
|
|
166
|
+
|
|
167
|
+
The model sees a tool name, a description, a parameter schema, and parameter descriptions. These belong to the adapter, and you set them with a `ReadSignature` from `@better-fs-tools/read/signature`. The core never sees the names that the model uses.
|
|
168
|
+
|
|
169
|
+
`ReadSignature` extends the `ToolSignature` base that the write and bash signatures share. A signature is a JSON Schema plus pure functions:
|
|
170
|
+
|
|
171
|
+
- `toInput(input)` checks the model input and maps it to `{ path, offset, limit }`. It throws `TypeError` with the host's parameter names.
|
|
172
|
+
- `param(name)` gives the host name of a canonical parameter, or `""` when the signature has none.
|
|
173
|
+
- `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
|
+
|
|
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.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
import { defaultReadSignature, lineRangeSignature } from "@better-fs-tools/read/signature";
|
|
179
|
+
|
|
180
|
+
// The default schema, with your own descriptions.
|
|
181
|
+
export const documented = defaultReadSignature({
|
|
182
|
+
description: "Read a file in the repository.",
|
|
183
|
+
describe: { path: "Path relative to the repository root." },
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// The same range model with other names.
|
|
187
|
+
export const renamed = defaultReadSignature({
|
|
188
|
+
name: "read_file",
|
|
189
|
+
names: { path: "file_path", offset: "start", limit: "max_lines" },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Inclusive start_line and end_line in place of offset and limit.
|
|
193
|
+
export const lineRange = lineRangeSignature({
|
|
194
|
+
name: "read_file",
|
|
195
|
+
names: { path: "file_path", start: "start_line", end: "end_line" },
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
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 }`.
|
|
200
|
+
|
|
201
|
+
`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
|
+
The core has no alias repair. The default signature refuses `file_path` or `start_line`. To accept those names, choose a signature that uses them. For another shape, write your own `ReadSignature`. Keep `toInput` pure and synchronous. The core still validates what `toInput` returns, so a signature cannot skip the path checks or the `maxLines` clamp.
|
|
204
|
+
|
|
205
|
+
The tool reads one path for each call. For multi-file reads or globs, call the core `read()` from your own tool.
|
|
206
|
+
|
|
207
|
+
## Path resolution
|
|
208
|
+
|
|
209
|
+
`resolve` rewrites the requested path before the filesystem opens it. A resolver returns one path, or reports "not found". It cannot grant access. The filesystem still checks roots, deny roots, realpath, and file type on the result.
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
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";
|
|
215
|
+
|
|
216
|
+
const cwd = process.cwd();
|
|
217
|
+
const home = homedir();
|
|
218
|
+
|
|
219
|
+
export const read = createNodeReadTool({
|
|
220
|
+
fs: nodeFileSystem({ cwd, allowedRoots: [cwd, `${home}/.config/app`] }),
|
|
221
|
+
resolve: pathResolvers(
|
|
222
|
+
stripPrefixes(), // "file:///x" and "@src/x"
|
|
223
|
+
expandHome({ home }), // "~" and "~/x"
|
|
224
|
+
unicodeRepair(), // macOS U+202F screenshot names
|
|
225
|
+
),
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
| Resolver | Effect |
|
|
230
|
+
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| `stripPrefixes({ fileUrl, at })` | Removes a `file://` prefix (percent-decoded) or a leading `@`. Both default to `true`. |
|
|
232
|
+
| `expandHome({ home })` | Expands `~` and `~/…`. Other `~user` forms pass through. A path outside the allowed roots still fails with `OUTSIDE_ALLOWED_ROOTS`. |
|
|
233
|
+
| `reanchorLeadingSlash({ firstSegmentExists })` | Reads `/src/x` as `src/x` when your callback says that `src` exists under the tool root. |
|
|
234
|
+
| `unicodeRepair({ note })` | Lists the parent folder. When exactly one entry matches by Unicode form, narrow or non-breaking space, or typographic quote, it opens that entry. `note: true` (the default) adds a `path-repaired` note. `note: false` repairs with no note. |
|
|
235
|
+
| `pathResolvers(...steps)` | Runs resolvers left to right. The first "not found" stops the chain. |
|
|
236
|
+
|
|
237
|
+
A read gets one bounded directory listing for its resolvers, through `ctx.list()`. `authorize` checks that listing with `action: "list"`. A second `ctx.list()` in the same read gets a refused outcome. `unicodeRepair()` uses the listing on every read, also when the file exists, so it is off by default.
|
|
238
|
+
|
|
239
|
+
When a resolver changes the path, `result.file.resolvedFrom` holds the path that the model sent. It is `null` when nothing changed. `file.requestedPath` is always the model's path. Retries and continuations use the model's path, so the resolver runs again on the next call.
|
|
240
|
+
|
|
241
|
+
`stripPrefixes()` also strips the `@` of a scoped name such as `@types/node/index.d.ts`. Pass `stripPrefixes({ at: false })` if your paths start with `@`.
|
|
242
|
+
|
|
243
|
+
To write your own resolver:
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import type { PathResolver } from "@better-fs-tools/read";
|
|
247
|
+
|
|
248
|
+
export const workspaceAlias: PathResolver<unknown> = {
|
|
249
|
+
id: "workspace-alias",
|
|
250
|
+
resolve: (path) =>
|
|
251
|
+
path.startsWith("$WS/")
|
|
252
|
+
? { kind: "path", path: `/workspace/${path.slice(4)}` }
|
|
253
|
+
: { kind: "path", path },
|
|
254
|
+
};
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
A resolver that throws gives `EXTENSION_FAILED`.
|
|
258
|
+
|
|
259
|
+
`PathResolver` takes a `ToolResolveContext`: `tool`, `messages`, `digest`, `clock`, `call`, `paths`, and `list()`. It does not see the read request, so the same resolver works for the write tools. The read core passes its full context, with `request` and `limits`, and `ctx.tool` is `"read"`.
|
|
260
|
+
|
|
261
|
+
## Suggestions on a miss
|
|
262
|
+
|
|
263
|
+
When a file does not exist, the core lists the parent folder once, calls `suggest`, and returns `NOT_FOUND`. The names are in the note text and in `note.data.suggestions`. The core never opens a suggested name. The model must send a new call.
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
[read:not-found] docs/Screen Shot 10.42 AM.png was not found. Nearby names: "Screen Shot 10.42 AM.png".
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
| Behaviour you want | Config |
|
|
270
|
+
| ---------------------------------------- | --------------------------------------------------------------------- |
|
|
271
|
+
| Suggestions only (Claude Code, OpenCode) | the default |
|
|
272
|
+
| Repair with a note | `resolve: unicodeRepair()` |
|
|
273
|
+
| Repair with no note (Pi) | `resolve: unicodeRepair({ note: false })` |
|
|
274
|
+
| No listing on a miss | `suggest: null` |
|
|
275
|
+
| Your own ranking | `suggest: (ctx) => myRanker(ctx.name, ctx.entries).slice(0, ctx.max)` |
|
|
276
|
+
|
|
277
|
+
`defaultSuggest()` puts Unicode-equivalent names first, then close spellings. `limits.maxSuggestions` caps the list. The default does not repair: a miss that differs only by Unicode form returns `NOT_FOUND` with the correct name first, and the model retries once.
|
|
278
|
+
|
|
279
|
+
The listing goes through `authorize` with `action: "list"`. A denied listing, or a listing that fails, gives a plain `NOT_FOUND` with no names. A `suggest` function that throws gives `EXTENSION_FAILED`.
|
|
280
|
+
|
|
281
|
+
## Permissions
|
|
282
|
+
|
|
283
|
+
The filesystem owns the root policy: allowed roots, deny roots, and symlinks. `authorize` adds your own policy. The core calls it with `action: "read"` after the file is open and before the core reads any content byte. It also calls it with `action: "list"` before every directory listing.
|
|
284
|
+
|
|
285
|
+
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
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
289
|
+
import { askUser, readAuthorizers, denyPaths, sizeCeiling } from "@better-fs-tools/read";
|
|
290
|
+
|
|
291
|
+
// Your own prompt, for example a dialog in your UI.
|
|
292
|
+
declare function confirmInUi(question: string): Promise<boolean>;
|
|
293
|
+
|
|
294
|
+
export const read = createNodeReadTool({
|
|
295
|
+
authorize: readAuthorizers(
|
|
296
|
+
denyPaths(["**/.env", "**/.env.*", "**/*.pem"]),
|
|
297
|
+
sizeCeiling({ maxBytes: 256 * 1024, unrangedOnly: true }),
|
|
298
|
+
askUser(async (target) => confirmInUi(`Read ${target.displayPath}?`)),
|
|
299
|
+
),
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
| Authorizer | Effect |
|
|
304
|
+
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
305
|
+
| `denyPaths(globs)` | Refuses every action whose `resolvedPath` matches a glob (`**`, `*`, `?`). For a read, `resolvedPath` is the realpath, so a symlink `config -> .env` is refused too. It is a `ToolAuthorizer`, so it fits every tool. On the bash tool, `denyPaths()` sees the working directory, not the files a command touches, so it does not stop `cat .env`. |
|
|
306
|
+
| `sizeCeiling({ maxBytes, unrangedOnly })` | Refuses a read of a file larger than `maxBytes`. With `unrangedOnly: true`, a read with an explicit `offset` or `limit` is allowed, and the refusal note offers a ranged retry. A file with an unknown size is allowed. |
|
|
307
|
+
| `askUser(prompt)` | Calls `prompt(target, ctx)` for each read, not for listings. Only `true` allows. `false` or a throw refuses. |
|
|
308
|
+
| `readAuthorizers(...steps)` | Runs in order. The first refusal wins. Allow notes from every step are kept. |
|
|
309
|
+
|
|
310
|
+
A custom authorizer returns `{ allow: true, notes? }` or `{ allow: false, note? }`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import type { ReadAuthorizer } from "@better-fs-tools/read";
|
|
314
|
+
|
|
315
|
+
export const noLockfiles: ReadAuthorizer<unknown> = {
|
|
316
|
+
id: "no-lockfiles",
|
|
317
|
+
authorize: (target, ctx) =>
|
|
318
|
+
target.action === "read" && target.resolvedPath.endsWith(".lock")
|
|
319
|
+
? {
|
|
320
|
+
allow: false,
|
|
321
|
+
note: {
|
|
322
|
+
code: "denied",
|
|
323
|
+
severity: "warning",
|
|
324
|
+
message: ctx.messages.denied({
|
|
325
|
+
path: target.requestedPath,
|
|
326
|
+
detail: "lockfiles are off",
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
: { allow: true },
|
|
331
|
+
};
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`ctx.messages.denied({ path, detail })` gives the host's refusal text. It takes a path, not the read request, so tool-neutral authorizers can use it. A `ToolAuthorizer` sees only `action`, `requestedPath`, `resolvedPath`, and `displayPath`, and a context with no read request. It fits the read tool's `ReadAuthorizer` and the write tools' authorizers. Every authorizer type declares `authorize` as a function property, so TypeScript refuses a read authorizer such as `sizeCeiling()` in a write or bash tool, also through a `ToolAuthorizer` variable. `compileGlob(pattern)` is the matcher `denyPaths` uses.
|
|
335
|
+
|
|
336
|
+
A refusal always gives `DENIED`. The authorizer's note replaces the default `denied` message: the error note keeps its message, data, and retry, takes the code `denied` and severity `warning`, and has the authorizer's own code in `data.source`, as in write and bash. A `DENIED` result has `file: null`, so it does not show the real path behind a link. An authorizer that throws gives `EXTENSION_FAILED`.
|
|
337
|
+
|
|
338
|
+
An authorizer can only refuse. It never sees a file that the filesystem refused. `askUser` has no memory of earlier answers. To remember an answer, keep it in your host, for example through `ctx.call.host` (see [Host context and state](#host-context-and-state)).
|
|
339
|
+
|
|
340
|
+
While a prompt waits, the file stays open. If the file changes during the wait, the read returns `CHANGED_DURING_READ`. If the call is aborted, the read returns `ABORTED` and closes the file, also when the prompt ignores `ctx.call.signal`.
|
|
341
|
+
|
|
342
|
+
## Binary detection
|
|
343
|
+
|
|
344
|
+
`classifiers` is an ordered list. Each classifier sees a bounded sample of the file (`limits.sampleBytes`). The first classifier with an opinion wins.
|
|
345
|
+
|
|
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`.
|
|
347
|
+
|
|
348
|
+
To detect by extension, or to change a refusal message:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
import { memoryFileSystem } from "@better-fs-tools/fs";
|
|
352
|
+
import { createReadTool, defaultClassifiers, extensionClassifier } from "@better-fs-tools/read";
|
|
353
|
+
|
|
354
|
+
export const read = createReadTool({
|
|
355
|
+
fs: memoryFileSystem(),
|
|
356
|
+
classifiers: [
|
|
357
|
+
extensionClassifier({
|
|
358
|
+
unsupported: {
|
|
359
|
+
".sqlite": { code: "BINARY" },
|
|
360
|
+
".png": { code: "IMAGE", mimeType: "image/png" },
|
|
361
|
+
},
|
|
362
|
+
text: [".lock"],
|
|
363
|
+
}),
|
|
364
|
+
...defaultClassifiers({ notes: { PDF: { message: "PDFs are not supported here." } } }),
|
|
365
|
+
],
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
A classifier only decides what a file is. A converter changes the content.
|
|
370
|
+
|
|
371
|
+
## File conversion, images, and directories
|
|
372
|
+
|
|
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`.
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
377
|
+
import {
|
|
378
|
+
directoryListing,
|
|
379
|
+
imageConverter,
|
|
380
|
+
notebookConverter,
|
|
381
|
+
textConverter,
|
|
382
|
+
} from "@better-fs-tools/read";
|
|
383
|
+
|
|
384
|
+
// Your own PDF extraction, for example a pdftotext process that reads stdin.
|
|
385
|
+
declare function pdfToText(source: AsyncIterable<Uint8Array>): AsyncIterable<string>;
|
|
386
|
+
|
|
387
|
+
export const read = createNodeReadTool({
|
|
388
|
+
converters: [
|
|
389
|
+
imageConverter(),
|
|
390
|
+
notebookConverter({ outputs: false }),
|
|
391
|
+
textConverter({
|
|
392
|
+
id: "pdftotext",
|
|
393
|
+
accepts: (match) =>
|
|
394
|
+
match.classification.kind === "unsupported" && match.classification.code === "PDF",
|
|
395
|
+
mimeType: "text/plain",
|
|
396
|
+
run: (source) => pdfToText(source),
|
|
397
|
+
}),
|
|
398
|
+
directoryListing({ trailingSlash: true }),
|
|
399
|
+
],
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
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"`. |
|
|
409
|
+
|
|
410
|
+
A converter returns one of these outcomes:
|
|
411
|
+
|
|
412
|
+
- `{ kind: "text", text, mimeType, notes? }`: a string, or an async stream of strings.
|
|
413
|
+
- `{ kind: "media", parts, notes? }`: content parts, for example an image. The result status is `media`.
|
|
414
|
+
- `{ kind: "refuse", code, note }`: an `unsupported` result with that code.
|
|
415
|
+
|
|
416
|
+
`accepts` is the only place where a converter can decline. It is synchronous.
|
|
417
|
+
|
|
418
|
+
Converted text goes through the normal scanner. `offset`, `limit`, line clamping, the byte limits, the budget, and continuation work on the converted lines. For a directory they work on entries. `ReadOk.conversion` names the converter. `classification` keeps what the classifier said, so a converted notebook still has `code: "NOTEBOOK"`.
|
|
419
|
+
|
|
420
|
+
A file converter reads the file through a stream that the core owns. It starts at byte 0 and stops at `limits.maxConvertBytes`. A larger file gives `unsupported` with code `TOO_LARGE`, also when the converter catches the error. Media parts larger than `limits.maxMediaBytes` in total also give `TOO_LARGE`. When a converter stops early, the core still reads the rest of the source within the cap. So `observation.contentId` is always the hash of the whole source, and the change check runs after every conversion. A converter never gets the path to open.
|
|
421
|
+
|
|
422
|
+
A directory listing goes through `authorize` with `action: "list"`, on the realpath that `fs.open()` reported. It stops at `limits.maxDirectoryEntries`, with a `directory-truncated` note. A directory result has `observation: null`, because a directory has no content to verify.
|
|
423
|
+
|
|
424
|
+
Conversion runs again on each continuation read. Cache inside your converter if it is slow. A converter that runs a process needs a process runtime, so it does not work in a Cloudflare Worker.
|
|
425
|
+
|
|
426
|
+
## Limits and the token budget
|
|
427
|
+
|
|
428
|
+
| Limit | Default | Meaning |
|
|
429
|
+
| --------------------- | ------- | ----------------------------------------------------- |
|
|
430
|
+
| `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 |
|
|
432
|
+
| `maxCharsPerLine` | 2,000 | Characters in one line before it is clamped |
|
|
433
|
+
| `maxScanBytes` | 64 MiB | Bytes scanned before totals and content identity stop |
|
|
434
|
+
| `sampleBytes` | 8 KiB | Bytes given to classifiers. At most `maxScanBytes`. |
|
|
435
|
+
| `maxDirectoryEntries` | 200 | Entries in one listing |
|
|
436
|
+
| `maxSuggestions` | 5 | Suggested names on a miss |
|
|
437
|
+
| `maxConvertBytes` | 64 MiB | Source bytes that a converter can read |
|
|
438
|
+
| `maxMediaBytes` | 5 MiB | Total bytes of media parts in one result |
|
|
439
|
+
|
|
440
|
+
These are defaults, not ceilings. You can set any positive safe integer.
|
|
441
|
+
|
|
442
|
+
A `limit` over `maxLines` is cut to `maxLines`, with a `clamped` info note. A `sampleBytes` that you set above `maxScanBytes` throws `TypeError`. When you set only `maxScanBytes` below 8 KiB, the default `sampleBytes` is lowered to it. The same limits rule holds in read, write, and bash.
|
|
443
|
+
|
|
444
|
+
Every tool has one parse helper with the same pattern: `parseReadInput`, `parseEditInput`, `parseWriteInput`, `parseApplyPatchInput`, and `parseBashInput`. Each takes the canonical input and the resolved limits, returns the request, and throws `TypeError` for input it refuses. The tool turns that throw into `INVALID_INPUT`. A clamp is not an error: the helper returns the clamped value, and the tool adds the `clamped` note.
|
|
445
|
+
|
|
446
|
+
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
|
+
|
|
448
|
+
```ts
|
|
449
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
450
|
+
import { charsPerToken } from "@better-fs-tools/read";
|
|
451
|
+
|
|
452
|
+
export const read = createNodeReadTool({
|
|
453
|
+
limits: { maxLines: 1_000, maxViewBytes: 64 * 1024 },
|
|
454
|
+
budget: charsPerToken({ ratio: 4, max: 25_000 }),
|
|
455
|
+
});
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
`charsPerToken({ ratio, max })` costs each line `ceil(text.length / ratio) + 1`. You can write your own `ViewBudget` with a `measure(text)` function. It must be synchronous and fast, because the scanner calls it for each line. A budget can only stop the view earlier. It never stops the first line of a view, so a very long line cannot make the model repeat the same call. That line is still bounded by `maxViewBytes` and `maxCharsPerLine`.
|
|
459
|
+
|
|
460
|
+
For a size ceiling that refuses a whole file, use the `sizeCeiling` authorizer.
|
|
461
|
+
|
|
462
|
+
## After-read hooks
|
|
463
|
+
|
|
464
|
+
`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
|
+
|
|
466
|
+
```ts
|
|
467
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
468
|
+
import { memoryStore, redact, repeatReadGuard } from "@better-fs-tools/read";
|
|
469
|
+
|
|
470
|
+
// createNodeReadTool sets digest to nodeDigest(), which repeatReadGuard needs.
|
|
471
|
+
export const read = createNodeReadTool({
|
|
472
|
+
state: memoryStore(),
|
|
473
|
+
hooks: [repeatReadGuard(), redact({ patterns: [/AKIA[0-9A-Z]{16}/g] })],
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
| Hook | Effect |
|
|
478
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
479
|
+
| `repeatReadGuard({ message })` | When the model reads the same unchanged range again, it replaces the view with an empty one and adds a `repeat-read` note. Needs `state` and a `digest`. A record from a write tool never fires it. |
|
|
480
|
+
| `redact({ patterns, replacement })` | Replaces matches in the view lines and text parts before the model or the store sees them. Each pattern needs the `g` flag. The default replacement is `[REDACTED]`. |
|
|
481
|
+
|
|
482
|
+
A hook gets the outcome and a context with `previous`, the stored record from before this read (or `null`). It returns an outcome. A hook can change the view lines, the notes, and the content parts. It can also replace the view with an empty one. It cannot change `request`, `file`, `classification`, `conversion`, `truncation`, `continuation`, `totals`, or `observation`. It cannot turn an error or an `unsupported` result into a success. A hook that breaks a rule, or throws, gives `EXTENSION_FAILED`.
|
|
483
|
+
|
|
484
|
+
When a hook changes the view, the core adds a `view-modified` note that names the hook, recomputes `observation.viewId`, and sets `observation.wholeFileVisible` to `false`. A later write tool then does not treat a redacted view as the whole file.
|
|
485
|
+
|
|
486
|
+
Hooks run for every outcome that has a request, including failures and refusals. They do not run for `INVALID_INPUT` or `ABORTED`.
|
|
487
|
+
|
|
488
|
+
Hooks run as host code. If a hook reads other files, it does so with your own permissions, and the tool does not check those reads. For example, this hook adds a nearby `AGENTS.md` as a note:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { readFile } from "node:fs/promises";
|
|
492
|
+
import { dirname, join } from "node:path";
|
|
493
|
+
import type { ReadHook } from "@better-fs-tools/read";
|
|
494
|
+
|
|
495
|
+
// Host code: it reads AGENTS.md with the host's own permissions.
|
|
496
|
+
export const agentsNote: ReadHook<unknown> = {
|
|
497
|
+
id: "agents-note",
|
|
498
|
+
async afterRead(outcome) {
|
|
499
|
+
if (outcome.status !== "ok") return outcome;
|
|
500
|
+
const path = join(dirname(outcome.file.resolvedPath), "AGENTS.md");
|
|
501
|
+
const text = await readFile(path, "utf8").catch(() => null);
|
|
502
|
+
if (text === null) return outcome;
|
|
503
|
+
const note = { code: "agents-md", severity: "info" as const, message: text.slice(0, 4_000) };
|
|
504
|
+
return { ...outcome, notes: [...outcome.notes, note] };
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
```
|
|
508
|
+
|
|
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.
|
|
510
|
+
|
|
511
|
+
## Output format
|
|
512
|
+
|
|
513
|
+
The formatter turns the structured outcome into what the model sees. It returns a string or a list of content parts.
|
|
514
|
+
|
|
515
|
+
The default is `lineNumberFormatter()`:
|
|
516
|
+
|
|
517
|
+
```text
|
|
518
|
+
1|import { createReadTool } from "./index.js";
|
|
519
|
+
2|export { createReadTool };
|
|
520
|
+
|
|
521
|
+
[read:continue] Output stopped at the line limit. Continue with {"path":"src/a.ts","offset":3,"limit":2}.
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
Its options:
|
|
525
|
+
|
|
526
|
+
| Option | Use |
|
|
527
|
+
| ---------------------- | ---------------------------------------------------------------------- |
|
|
528
|
+
| `gutter(line, ctx)` | Line prefix. Default `1\|`. |
|
|
529
|
+
| `clampMarker(line)` | Text after a clamped line. Default `… [line truncated at 2000 chars]`. |
|
|
530
|
+
| `header(outcome, ctx)` | A line before the body, or `null` for none |
|
|
531
|
+
| `footer(outcome, ctx)` | A line after the body, or `null` for none |
|
|
532
|
+
| `notes(note)` | Rewrite a note for display, or return `null` to hide it |
|
|
533
|
+
| `noteLine(note)` | The text of one note line. Default `[read:<code>] <message>`. |
|
|
534
|
+
|
|
535
|
+
Helpers for common formats:
|
|
536
|
+
|
|
537
|
+
```ts
|
|
538
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
539
|
+
import {
|
|
540
|
+
eofFooter,
|
|
541
|
+
fileHashHeader,
|
|
542
|
+
hashlineGutter,
|
|
543
|
+
lineNumberFormatter,
|
|
544
|
+
} from "@better-fs-tools/read";
|
|
545
|
+
|
|
546
|
+
export const read = createNodeReadTool({
|
|
547
|
+
formatter: lineNumberFormatter({
|
|
548
|
+
gutter: hashlineGutter({ width: 2 }), // "12:a3|text"
|
|
549
|
+
header: fileHashHeader(), // "file-hash: sha256:..."
|
|
550
|
+
footer: eofFooter((n) => `(End of file - total ${n} lines)`),
|
|
551
|
+
notes: (note) => (note.code === "weak-identity" ? null : note),
|
|
552
|
+
}),
|
|
553
|
+
});
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
- `hashlineGutter({ width })` adds a short hash of each line, for example `12:a3|`. The hash comes from the `digest`, so it is stable across reads and changes when the line changes. With no digest it falls back to `12|`.
|
|
557
|
+
- `fileHashHeader()` prints `file-hash: <contentId>`. It prints nothing when there is no digest, or when the scan limit stopped before the end of the file.
|
|
558
|
+
- `eofFooter(text)` prints a line when the view reaches the end of the file. It skips directory listings.
|
|
559
|
+
- `plainFormatter()` gives the source with no gutter. A model can paste it back into an edit call.
|
|
560
|
+
- `jsonFormatter({ pick, notes, space })` gives JSON text. `pick` chooses the fields. `notes: "after"` puts note lines after the JSON. Media bytes are left out of the JSON and returned as media parts.
|
|
561
|
+
|
|
562
|
+
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
|
+
|
|
564
|
+
```ts
|
|
565
|
+
import { createNodeReadTool } from "@better-fs-tools/node";
|
|
566
|
+
import { opencodeFormat } from "@better-fs-tools/read/formats";
|
|
567
|
+
|
|
568
|
+
export const read = createNodeReadTool({ formatter: opencodeFormat() });
|
|
569
|
+
```
|
|
570
|
+
|
|
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()`.
|
|
572
|
+
|
|
573
|
+
## Messages and notes
|
|
574
|
+
|
|
575
|
+
Every note has a `code`, a `severity`, and a `message`. It can also have `data`, and a `retry` for notes that continue a read:
|
|
576
|
+
|
|
577
|
+
```text
|
|
578
|
+
{ code: "continue", severity: "info", message: "Output stopped at the line limit. Continue with ...",
|
|
579
|
+
retry: { path: "src/a.ts", offset: 51, limit: 50 }, data: { reason: "lines" } }
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Key your code on `code`, not on the wording. You can change notes in four places:
|
|
583
|
+
|
|
584
|
+
| To change | Use |
|
|
585
|
+
| ---------------------------------------- | ------------------------------------------------------- |
|
|
586
|
+
| The wording of a core note | `messages` |
|
|
587
|
+
| How a retry call is printed | `messages.retry`, or `readSignatureMessages(signature)` |
|
|
588
|
+
| The wording of a format refusal | `defaultClassifiers({ notes })` |
|
|
589
|
+
| How a note looks in the text, or hide it | formatter `notes` or `noteLine` |
|
|
590
|
+
| The note in the structured result | an `afterRead` hook that rewrites `outcome.notes` |
|
|
591
|
+
|
|
592
|
+
No default message names `offset` or `limit`. Every message that suggests a retry prints it with `messages.retry`. The structured `truncation` and `continuation` fields always stay in the result. If you hide the `continue` note, your code can still read `result.continuation`.
|
|
593
|
+
|
|
594
|
+
## The result
|
|
595
|
+
|
|
596
|
+
`ReadResult` is the outcome plus `content`, the formatter's parts for the model. Every result has `tool: "read"` and a `status`. The outcome is a union on `status`: only the `error` variant has `error: { code, phase, message, data? }`, the same shape as the write and bash errors.
|
|
597
|
+
|
|
598
|
+
| Status | Meaning |
|
|
599
|
+
| ------------- | ----------------------------------------------------------------------------------------------- |
|
|
600
|
+
| `ok` | A text view. `view.lines` has the structured lines. An empty file is `ok` with an `empty` note. |
|
|
601
|
+
| `media` | Content parts from a converter, for example an image. |
|
|
602
|
+
| `unsupported` | A classifier or converter refused the format. `code` is open, for example `PDF` or `TOO_LARGE`. |
|
|
603
|
+
| `error` | `error.code` is one of thirteen `ReadErrorCode` values. |
|
|
604
|
+
|
|
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" }`.
|
|
606
|
+
|
|
607
|
+
An `ok` result has structured lines, and an `error` result has its error:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
import { memoryFileSystem } from "@better-fs-tools/fs";
|
|
611
|
+
import { createReadTool } from "@better-fs-tools/read";
|
|
612
|
+
|
|
613
|
+
const read = createReadTool({ fs: memoryFileSystem({ files: { "/a.ts": "const a = 1;\n" } }) });
|
|
614
|
+
const result = await read({ path: "/a.ts" });
|
|
615
|
+
|
|
616
|
+
if (result.status === "ok") {
|
|
617
|
+
console.log(result.view.lines); // [{ number: 1, text: "const a = 1;", clamped: false, sourceChars: null }]
|
|
618
|
+
console.log(result.view.bytes); // 12: source bytes, before the gutter
|
|
619
|
+
console.log(result.continuation); // { available: false, next: null }
|
|
620
|
+
console.log(result.totals); // { lines: 1, exact: true, bytes: 13 }
|
|
621
|
+
console.log(result.file.resolvedFrom); // null: no resolver changed the path
|
|
622
|
+
} else if (result.status === "error") {
|
|
623
|
+
console.log(result.error.code, result.error.phase); // only the error variant has `error`
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
A content part is `{ type: "text", text }` or `{ type: "media", mediaType, data, name? }`. `data` is a `Uint8Array`.
|
|
628
|
+
|
|
629
|
+
[docs/result-schema.md](docs/result-schema.md) lists every field, every error code with its phase, and every note code. `docs/result-schema.md` in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write) has one table of the statuses and error codes of all five tools.
|
|
630
|
+
|
|
631
|
+
## Host context and state
|
|
632
|
+
|
|
633
|
+
Each call can carry a context: `{ signal?, callId?, host? }`. Adapters build it. The AI SDK adapter puts its `ToolExecutionOptions` in `host`, and the Pi adapter puts Pi's `ExtensionContext` there. The core passes the same `call` object to every stage and host function (`ctx.call`). The core never reads `host`, and `host` never appears in the result, the stored record, notes, or content.
|
|
634
|
+
|
|
635
|
+
With a host type, the context and its `host` are required. With no host type, the context is optional:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
import { memoryFileSystem } from "@better-fs-tools/fs";
|
|
639
|
+
import { nodeDigest } from "@better-fs-tools/node";
|
|
640
|
+
import { askUser, createReadTool, memoryStore } from "@better-fs-tools/read";
|
|
641
|
+
import type { ReadStateStore } from "@better-fs-tools/read";
|
|
642
|
+
|
|
643
|
+
interface Session {
|
|
644
|
+
readonly id: string;
|
|
645
|
+
confirm(question: string): Promise<boolean>;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const stores = new Map<string, ReadStateStore>();
|
|
649
|
+
|
|
650
|
+
const read = createReadTool<Session>({
|
|
651
|
+
fs: memoryFileSystem({ files: { "/a.txt": "alpha\n" } }),
|
|
652
|
+
// Called at most once for each read, and only when the core needs a store.
|
|
653
|
+
state: (call) => {
|
|
654
|
+
let store = stores.get(call.host.id);
|
|
655
|
+
if (store === undefined) stores.set(call.host.id, (store = memoryStore()));
|
|
656
|
+
return store;
|
|
657
|
+
},
|
|
658
|
+
// A state needs a digest: a record names the digest that made it.
|
|
659
|
+
digest: nodeDigest(),
|
|
660
|
+
authorize: askUser<Session>((target, ctx) =>
|
|
661
|
+
ctx.call.host.confirm(`Read ${target.displayPath}?`),
|
|
662
|
+
),
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// With a host type, the context and its host are required.
|
|
666
|
+
const session: Session = { id: "s1", confirm: async () => true };
|
|
667
|
+
await read({ path: "/a.txt" }, { host: session, callId: "call-1" });
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
- `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
|
+
- `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.
|
|
673
|
+
- `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.
|
|
675
|
+
- `result.file.version` is the backend's change token from `open()`, or `null`. It is kept when the backend has no identity capability.
|
|
676
|
+
- `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
|
+
- 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
|
+
|
|
679
|
+
## Security guarantees
|
|
680
|
+
|
|
681
|
+
Your dependencies can narrow access. They cannot widen it.
|
|
682
|
+
|
|
683
|
+
- 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
|
+
- Each read opens at most one file. A resolver only changes the path that goes into that open.
|
|
685
|
+
- `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.
|
|
687
|
+
- The scan is bounded. Converters get a capped stream from the open file, never a path.
|
|
688
|
+
- Change detection runs after the scan and after conversion. A file that changed returns `CHANGED_DURING_READ`.
|
|
689
|
+
- A suggested name is never opened.
|
|
690
|
+
- A hook cannot turn a failure into a success. A hook that changes the view marks the observation as partial.
|
|
691
|
+
- A `DENIED` result does not show the file behind the path.
|
|
692
|
+
|
|
693
|
+
## Host adapters
|
|
694
|
+
|
|
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.
|
|
696
|
+
|
|
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. |
|
|
702
|
+
|
|
703
|
+
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
|
+
|
|
705
|
+
`docs/hosts.md` in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write) lists the defaults of every host and bundle, and what each backend can do.
|
|
706
|
+
|
|
707
|
+
## Exports
|
|
708
|
+
|
|
709
|
+
| Entry | Contents |
|
|
710
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
711
|
+
| `@better-fs-tools/read` | `createReadTool`, `textOf`, `parseReadInput`, `memoryStore`, limits, messages, classifiers, resolvers, suggestions, authorizers, converters, hooks, the budget, formatters, and the types |
|
|
712
|
+
| `@better-fs-tools/read/signature` | `defaultReadSignature`, `lineRangeSignature`, `readSignatureMessages`, and the read signature types |
|
|
713
|
+
| `@better-fs-tools/read/formats` | `opencodeFormat`, `deepAgentsFormat`, `hashlineFormat`, `hermesFormat` |
|
|
714
|
+
|
|
715
|
+
The package has no peers and imports no `node:` module, so it runs in Node, Bun, browsers, and Cloudflare Workers. It does not re-export the `fs` types. Import `FileSystem` and the other filesystem types from `@better-fs-tools/fs`. `ReadStateStore` and `ReadRecord` come from `@better-fs-tools/read`.
|
|
716
|
+
|
|
717
|
+
The root entry also exports the tool-neutral base types that `@better-fs-tools/write` builds on: `ToolCallContext`, `ToolName`, `Note`, `ToolError`, `ToolMessages`, `ToolHookContext`, `ToolResolveContext`, `AccessTarget`, `AccessDecision`, `ToolAuthorizer`, `ToolSignature`, and `SignatureDocs`. `ReadContext` extends `ToolCallContext`, `ReadNote` extends `Note`, `ReadMessageCatalog` extends `ToolMessages`, and `ReadSignature` extends `ToolSignature`.
|
|
718
|
+
|
|
719
|
+
## A host that changes everything
|
|
720
|
+
|
|
721
|
+
This Pi host sets most dimensions. You will usually need only a few of these options.
|
|
722
|
+
|
|
723
|
+
```ts
|
|
724
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
725
|
+
import { createPiReadTool } from "@better-fs-tools/pi";
|
|
726
|
+
import { lineRangeSignature } from "@better-fs-tools/read/signature";
|
|
727
|
+
import {
|
|
728
|
+
askUser,
|
|
729
|
+
denyPaths,
|
|
730
|
+
directoryListing,
|
|
731
|
+
eofFooter,
|
|
732
|
+
imageConverter,
|
|
733
|
+
lineNumberFormatter,
|
|
734
|
+
memoryStore,
|
|
735
|
+
pathResolvers,
|
|
736
|
+
readAuthorizers,
|
|
737
|
+
redact,
|
|
738
|
+
repeatReadGuard,
|
|
739
|
+
sizeCeiling,
|
|
740
|
+
stripPrefixes,
|
|
741
|
+
unicodeRepair,
|
|
742
|
+
} from "@better-fs-tools/read";
|
|
743
|
+
|
|
744
|
+
const stores = new Map<string, ReturnType<typeof memoryStore>>();
|
|
745
|
+
|
|
746
|
+
export const read = createPiReadTool({
|
|
747
|
+
signature: lineRangeSignature({
|
|
748
|
+
name: "read",
|
|
749
|
+
names: { path: "file_path", start: "start_line", end: "end_line" },
|
|
750
|
+
}),
|
|
751
|
+
resolve: pathResolvers(stripPrefixes(), unicodeRepair({ note: false })),
|
|
752
|
+
authorize: readAuthorizers(
|
|
753
|
+
denyPaths(["**/.env", "**/.env.*"]),
|
|
754
|
+
sizeCeiling({ maxBytes: 256 * 1024, unrangedOnly: true }),
|
|
755
|
+
askUser<ExtensionContext>(async (target, ctx) => {
|
|
756
|
+
const pi = ctx.call.host;
|
|
757
|
+
if (!pi.hasUI) return false;
|
|
758
|
+
return pi.ui.confirm("Read file?", target.displayPath, { signal: ctx.call.signal });
|
|
759
|
+
}),
|
|
760
|
+
),
|
|
761
|
+
converters: [imageConverter(), directoryListing({ trailingSlash: true })],
|
|
762
|
+
state: (call) => {
|
|
763
|
+
const id = call.host.sessionManager.getSessionId();
|
|
764
|
+
let store = stores.get(id);
|
|
765
|
+
if (store === undefined) stores.set(id, (store = memoryStore()));
|
|
766
|
+
return store;
|
|
767
|
+
},
|
|
768
|
+
hooks: [repeatReadGuard(), redact({ patterns: [/AKIA[0-9A-Z]{16}/g] })],
|
|
769
|
+
formatter: lineNumberFormatter({ footer: eofFooter((n) => `(End of file, ${n} lines)`) }),
|
|
770
|
+
});
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
The repository keeps this host in `examples/custom-host.ts`, and CI type-checks it.
|
|
774
|
+
|
|
775
|
+
## Writing a filesystem adapter
|
|
776
|
+
|
|
777
|
+
Implement `FileSystem` from `@better-fs-tools/fs`. `open()` resolves the path, checks access and type, and returns one handle. The handle has a single-use `bytes()`, a `verify()` that reports whether the file changed, and a `close()`. An optional `list()` returns a bounded directory listing. When `open()` refuses a directory, FIFO, socket, or device, return a `not-a-file` error with its `kind` and, for a directory, its `target`.
|
|
778
|
+
|
|
779
|
+
Then check it with the conformance suite:
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
import { memoryFileSystem, runFileSystemConformance } from "@better-fs-tools/fs";
|
|
783
|
+
|
|
784
|
+
// Replace the memory filesystem with your own FileSystem.
|
|
785
|
+
const fs = memoryFileSystem({ files: { "/a.txt": "alpha\n" }, directories: ["/dir"] });
|
|
786
|
+
|
|
787
|
+
const report = await runFileSystemConformance(fs, {
|
|
788
|
+
existingFile: { path: "/a.txt", bytes: new TextEncoder().encode("alpha\n") },
|
|
789
|
+
missingPath: "/missing.txt",
|
|
790
|
+
directoryPath: "/dir",
|
|
791
|
+
listDirectory: "/",
|
|
792
|
+
});
|
|
793
|
+
console.log(report.passed); // true when the adapter keeps the contract
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
[docs/architecture.md](docs/architecture.md) explains what the core expects from a filesystem.
|
|
797
|
+
|
|
798
|
+
## Runtime
|
|
799
|
+
|
|
800
|
+
The build output is ESNext JavaScript modules. It needs a current runtime with `AbortSignal`, `TextEncoder`, `TextDecoder`, and async iterators, for example Node 24, Bun, a current browser, or Cloudflare Workers. This release supports POSIX paths only. The write tools are in [`@better-fs-tools/write`](https://www.npmjs.com/package/@better-fs-tools/write).
|