Better FS Tools
Read tool

Hooks and state

Change results after a read with hooks, store a record of each read, and keep one store for each session.

Hooks

Set hooks to a list of hooks. The default is []. The tool runs each hook in list order after the read, and before the formatter. A hook gets the result and returns a new result.

Redact secrets

redact() replaces text that matches a pattern. Each pattern needs the g flag:

import { nodeFileSystem } from "@better-fs-tools/node";
import { createReadTool, redact } from "@better-fs-tools/read";

const cwd = process.cwd();
const fs = nodeFileSystem({ cwd, allowedRoots: [cwd] });
const read = createReadTool({
  fs,
  hooks: [redact({ patterns: [/AKIA[0-9A-Z]{16}/g] })],
});
1|export const region = "eu-west-1";
2|export const accessKeyId = "[REDACTED]";

[read:view-modified] The redact hook changed this view, so it is not the exact file text.

Set replacement to use other text. redact() works on one line at a time, so it does not find a secret that goes across two lines. When nothing matches, the result does not change.

Write your own hook

A hook is an object with an id and an afterRead(outcome, ctx) function. It can be async. This hook adds a note when a file is large:

import type { ReadHook } from "@better-fs-tools/read";

const sizeNote: ReadHook = {
  id: "size-note",
  afterRead(outcome) {
    const lines = outcome.status === "ok" ? outcome.totals.lines : null;
    if (lines === null || lines < 1_000) return outcome;
    const note = {
      code: "large-file",
      severity: "info" as const,
      message: `This file has ${lines} lines. Search it before you read more.`,
    };
    return { ...outcome, notes: [...outcome.notes, note] };
  },
};
1|2026-09-30T10:00:00Z request 0 ok
2|2026-09-30T10:00:01Z request 1 ok

[read:continue] Output stopped at the line limit. Continue with {"path":"logs/server.log","offset":3,"limit":2}.
[read:large-file] This file has 5000 lines. Search it before you read more.

A hook runs for every result that has a request, also for a failed read. It can change the lines, the notes, and the content parts. It cannot change the file or the range, and it cannot change a failed read into a successful one. When a hook changes the lines, the tool adds a view-modified note.

ctx.previous holds the stored record from the last read of the file, when you set state. ctx.call holds the call context.

If a hook throws or breaks these rules, the read fails with EXTENSION_FAILED:

[read:extension-failed] The hooks extension failed during the hooks phase while reading src/greet.ts; this is a host problem rather than a property of the file.

A hook runs as your own code. If it reads other files, the tool does not check those reads.

State

Set state to a store, and the tool stores a record of each read: the path, the range, and hashes of the content. state needs a digest to make the hashes. The default digest is sha256Digest(), so a store is enough:

import { memoryStore } from "@better-fs-tools/read";

const read = createReadTool({ fs, state: memoryStore() });

On Node, nodeDigest() from @better-fs-tools/node is faster. It gives the same values as sha256Digest(), so records made with one are valid for the other:

import { nodeDigest } from "@better-fs-tools/node";

const read = createReadTool({ fs, digest: nodeDigest(), state: memoryStore() });

digest: null turns the hashes off. Then state must be null too. The StateNeedsDigest type refuses a state with digest: null, and createReadTool throws:

TypeError: state needs a digest: records name the digest that made them

memoryStore() keeps records in memory, up to 1,000 records for 30 minutes. Set maxEntries and ttlMs to change this. To keep records somewhere else, write a store with get, put, and delete. See the read reference.

A failed store call does not fail a read. The store is a cache.

Stop repeat reads

repeatReadGuard() is a hook that uses the records. When the model reads the same range of an unchanged file again, the guard removes the lines and adds a note:

import { repeatReadGuard } from "@better-fs-tools/read";

const read = createReadTool({
  fs,
  digest: nodeDigest(),
  state: memoryStore(),
  hooks: [repeatReadGuard()],
});
[read:continue] Output stopped at the line limit. Continue with {"path":"src/greet.ts","offset":3,"limit":2}.
[read:repeat-read] src/greet.ts has not changed since it was read with the same range at 2026-10-09T22:22:36.495Z. That output is still current, so it is not repeated.
[read:view-modified] The repeat-read-guard hook changed this view, so it is not the exact file text.

A read of another range, or of a changed file, returns the lines as usual. Set message to change the note text. The function gets the stored record.

Share the store with the write tools

The edit, write, and apply_patch tools use the records to check that the model read a file before it changes it. Give all the tools the same store and digest. createFsTools({ fs, state: memoryStore() }) does this for you. The default state of the bundle is null, so the bundle does not check reads unless you set it. See Choose an integration.

A read that a hook changed, for example with redact(), does not count as a read of the whole file.

Use one store for each session

The second argument of read is the call context. Put your session in host. The tool gives it to your options as call.host, and to extensions as ctx.call.host. Set state to a function to pick a store for each call:

import type { ReadStateStore } from "@better-fs-tools/read";

interface Session {
  readonly id: string;
}

const stores = new Map<string, ReadStateStore>();
const read = createReadTool<Session>({
  fs,
  digest: nodeDigest(),
  state: (call) => {
    let store = stores.get(call.host.id);
    if (store === undefined) stores.set(call.host.id, (store = memoryStore()));
    return store;
  },
  hooks: [repeatReadGuard()],
});

await read({ path: "src/greet.ts" }, { host: { id: "session-1" } });

The type argument sets the type of host. With a host type, the call context is required. The tool calls the state function at most once for each read, and only when it needs the store. Return null for no store.

The same pattern works for other options. fs can be a function of the call, and askUser() can keep approved paths in the session. The AI SDK integration puts its tool call options in host, and the Pi integration puts the Pi extension context there.

On this page