agent-file-stash 0.4.0 → 0.5.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -39
  3. package/dist/cli.mjs +769 -180
  4. package/package.json +4 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Atilio Villalba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -6,23 +6,23 @@
6
6
 
7
7
  Agents waste most of their token budget re-reading files they've already seen. agent-file-stash fixes this: on first read it stashes the file, on subsequent reads it returns either "unchanged" (one line instead of the whole file) or a compact diff of what changed.
8
8
 
9
- agent-file-stash is based on [cachebro](https://github.com/glommer/cachebro), an earlier tool with the same goal. cachebro required [Turso](https://turso.tech) as an external database dependency and lacked per-line-range stashing, meaning partial reads always returned the full file. agent-file-stash replaces Turso with `node:sqlite` — a built-in available since Node.js 24 — eliminating all external runtime dependencies. It also tracks partial reads independently by line range, so a re-read of lines 50–60 returns `[unchanged]` even if line 200 was edited. Additional improvements include a `readFileFull` method to force a full re-read and reset session tracking, proactive cache eviction when files are deleted via an optional file watcher, and a more accurate token estimate (`ceil(chars / 4)` vs the original `chars * 0.75`).
9
+ agent-file-stash is based on [cachebro](https://github.com/glommer/cachebro), an earlier tool with the same goal. cachebro required [Turso](https://turso.tech) as an external database dependency and lacked per-line-range stashing, meaning partial reads always returned the full file. agent-file-stash replaces Turso with `node:sqlite` — a built-in available since Node.js 24 — eliminating all external runtime dependencies. It also records which line ranges were delivered to the model, so a re-read of lines 50–60 returns `[unchanged]` only if those lines were already delivered, even when line 200 was edited. Additional improvements include a `readFileFull` method to force a full re-read and reset session tracking, proactive cache eviction when files are deleted via an optional file watcher, and a more accurate token estimate (`ceil(chars / 4)` vs the original `chars * 0.75`).
10
10
 
11
11
  ```
12
12
  First read: agent reads src/auth.ts → stashes content + hash → returns full file
13
- Second read: agent reads src/auth.ts → hash unchanged → returns "[unchanged, 245 lines, 1,837 tokens saved]"
13
+ Second read: agent reads src/auth.ts → hash unchanged → returns "[filestash: unchanged, 245 lines, 1837 tokens saved]"
14
14
  After edit: agent reads src/auth.ts → hash changed → returns unified diff (only changed lines)
15
- Partial read: agent reads lines 50-60 → edit changed line 200 → returns "[unchanged in lines 50-60]"
15
+ Partial read: agent reads lines 50-60 → edit changed line 200 → returns "[filestash: unchanged in lines 50-60, changes elsewhere in file, N tokens saved]"
16
16
  ```
17
17
 
18
18
  The stash persists in a local SQLite database (Node.js built-in `node:sqlite`, WAL mode). Content hashing (SHA-256) detects changes. No network, no external services, no configuration beyond a file path.
19
19
 
20
20
  ## Highlights
21
21
 
22
- - **50% fewer tokens** on repeated file reads — verified on real codebases
22
+ - **Up to ~50% fewer tokens** on repeated reads in the two-pass simulation, 24–26% on a real codebase (see [Benchmark](#benchmark)). Savings only apply to re-reads, see [When it saves tokens](#when-it-saves-tokens-and-when-it-does-not)
23
23
  - **Zero config** — one command auto-configures Claude Code, Cursor, and OpenCode
24
24
  - **No external services** — SQLite backed by Node.js 24 built-ins, no network required
25
- - **Partial-read aware** — stashes line ranges independently; returns `[unchanged in lines 50-59]` when only other parts changed
25
+ - **Partial-read aware** — tracks which line ranges were delivered to the model; returns an "unchanged in lines 50-59" label only for lines it already delivered, when only other parts of the file changed
26
26
  - **Agents adopt it on their own** — tool descriptions alone are enough; no explicit instructions needed
27
27
  ## Prerequisites
28
28
 
@@ -57,10 +57,12 @@ The MCP server exposes 4 tools that agents discover and use automatically:
57
57
 
58
58
  | Tool | Description |
59
59
  |------|-------------|
60
- | `read_file` | Read a file with stashing. Returns full content on first read, `[unchanged]` label or diff on subsequent reads. Supports `offset`/`limit` for partial reads. |
61
- | `read_files` | Batch read multiple files at once with stashing. |
62
- | `stash_status` | Show stats: files tracked, tokens saved (total and per session). |
63
- | `stash_clear` | Reset the stash (clears all cached content and stats). |
60
+ | `read_file` | Read a file with stashing. Returns full content on first read, an "unchanged" label or diff on subsequent reads. Parameters: `path` (required), `offset` (1-based start line), `limit` (max lines), `force` (bypass the stash and return full content). |
61
+ | `read_files` | Batch read multiple files at once with stashing. Parameter: `paths` (array of file paths). |
62
+ | `stash_status` | Show files tracked and this session's accounting: reads, tokens a plain read would have sent, tokens actually sent, gross saved, tool-definition overhead (est.) and net saved (est.), plus lifetime gross saved. |
63
+ | `stash_clear` | Clear all stashed content, read tracking and stats. |
64
+
65
+ Paths must be inside the server's working directory (symlinks are resolved before the check); anything outside is rejected with an error.
64
66
 
65
67
  ### As a CLI
66
68
 
@@ -78,15 +80,19 @@ Detects installed editors and writes the MCP server entry into each config file
78
80
  | Cursor | `~/.cursor/mcp.json` |
79
81
  | OpenCode | `$XDG_CONFIG_HOME/opencode/opencode.json` |
80
82
 
81
- If the `agent-file-stash` key already exists in a config, that entry is left unchanged and reported as "already configured". After running, restart your editor to pick up the new server.
83
+ `init` registers the server under the key `filestash` (inside `mcpServers`, or `mcp` for OpenCode). Only editors whose config directory exists are touched. If the `filestash` key already exists in a config, that entry is left unchanged and reported as "already configured". After running, restart your editor to pick up the new server.
82
84
 
83
85
  ```
84
86
  Claude Code: configured (/Users/you/.claude.json)
85
87
  OpenCode: already configured
86
88
 
87
- Done! Restart your editor to pick up filestash.
89
+ Done! Restart your editor to pick up agent-file-stash.
88
90
  ```
89
91
 
92
+ When no supported editor is detected, `init` prints the manual MCP snippet instead.
93
+
94
+ `init --hooks` also merges the context-reset hook (see [Context resets](#context-resets)) into `~/.claude/settings.json`. It is idempotent, keeps all existing settings, and saves the previous file as `settings.json.bak`. Without the flag, `init` only prints the snippet.
95
+
90
96
  #### `serve`
91
97
 
92
98
  ```bash
@@ -94,7 +100,7 @@ npx agent-file-stash serve
94
100
  # or just: npx agent-file-stash
95
101
  ```
96
102
 
97
- Starts the MCP server over stdio. This is the command editors invoke automatically — you don't normally run it yourself. The server registers four tools (`read_file`, `read_files`, `stash_status`, `stash_clear`) and keeps the stash database open for the lifetime of the process.
103
+ Starts the MCP server over stdio. This is the command editors invoke automatically — you don't normally run it yourself. The server registers four tools (`read_file`, `read_files`, `stash_status`, `stash_clear`) and keeps the stash database open for the lifetime of the process. Each server process is one [session](#how-sessions-work), and it watches its working directory to evict deleted files from the stash.
98
104
 
99
105
  The stash database is created at `$FILESTASH_DIR/stash.db` (defaults to `.file-stash/stash.db` relative to the working directory the editor uses when launching the server).
100
106
 
@@ -104,7 +110,7 @@ The stash database is created at `$FILESTASH_DIR/stash.db` (defaults to `.file-s
104
110
  npx agent-file-stash status
105
111
  ```
106
112
 
107
- Prints lifetime stats from the local stash database. Exits with a message if no database exists yet.
113
+ Prints files tracked and the lifetime tokens saved from the local stash database (`$FILESTASH_DIR/stash.db`, default `.file-stash/stash.db`). Exits with a message if no database exists yet. Per-session figures are only available from the `stash_status` tool.
108
114
 
109
115
  ```
110
116
  filestash status:
@@ -112,7 +118,7 @@ filestash status:
112
118
  Tokens saved (total): ~53,851
113
119
  ```
114
120
 
115
- When each project keeps its own stash (for example with `FILESTASH_DIR=.vscode/file-stash`), sum all of them with `--all`. It scans the given directory (default: home) for `.file-stash/` and `file-stash/` folders:
121
+ When each project keeps its own stash (for example with `FILESTASH_DIR=.vscode/file-stash`), sum all of them with `--all`. It scans the given directory (default: home) for `.file-stash/` and `file-stash/` folders containing a `stash.db` (up to 8 levels deep, skipping `node_modules`, `.git`, `build`, `dist`, `target`, `bin` and `obj`):
116
122
 
117
123
  ```bash
118
124
  npx agent-file-stash status --all ~/Projects
@@ -128,6 +134,14 @@ filestash status (3 databases under /Users/me/Projects):
128
134
 
129
135
  Savings come from re-reads within a session (unchanged files and diffs). A new session always receives full content, since the file is not in its context yet.
130
136
 
137
+ #### `reset`
138
+
139
+ ```bash
140
+ npx agent-file-stash reset
141
+ ```
142
+
143
+ Forgets what each session has read, so the next read of every file returns the full content. Stats and stashed versions are kept. It resolves `FILESTASH_DIR` like the server does and exits 0 if no database exists. Meant to be run by a Claude Code hook (see [Context resets](#context-resets)); `--from-hook` reads the hook JSON from stdin, resolves a relative `FILESTASH_DIR` against its `cwd`, and stays silent.
144
+
131
145
  #### `help`
132
146
 
133
147
  ```bash
@@ -141,17 +155,89 @@ Prints a short usage summary with all available commands.
141
155
  | Variable | Default | Description |
142
156
  |---|---|---|
143
157
  | `FILESTASH_DIR` | `.file-stash/` | Directory where the stash database is stored |
158
+ | `FILESTASH_EXCLUDE` | (none) | Comma-separated basename globs (`*`, `?`) that are never stored, added to the defaults |
159
+ | `FILESTASH_MAX_LINES` | `2000` | Maximum lines returned by one read; longer reads are truncated |
160
+ | `FILESTASH_MAX_CHARS` | `100000` | Maximum characters returned by one read; longer reads are truncated |
144
161
 
145
- ### As an SDK
162
+ ### Privacy
146
163
 
147
- Install and import directly if you want to embed stashing in your own tooling:
164
+ Files that commonly hold secrets are read normally but never written to the stash database. The agent still gets the full content on every read; nothing is persisted, and no tokens are counted as saved for them.
148
165
 
149
- ```bash
150
- npm install agent-file-stash
166
+ Excluded by default (case-insensitive): `.env`, `.env.*`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.keystore`, `id_rsa*`, `id_ed25519*`, `id_ecdsa*`, `.npmrc`, `.netrc`, `credentials*`, `secrets.*`, and anything inside a `.ssh`, `.aws` or `.gnupg` directory. Templates such as `.env.example`, `.env.sample`, `.env.template`, `.env.dist` and public keys (`*.pub`) are still stashed.
167
+
168
+ Add your own patterns with `FILESTASH_EXCLUDE=*.secret,vault.json` (SDK users: the `exclude` option). On startup, rows for paths that match the denylist are deleted from databases created by older versions. The stash directory is created with mode `0700` and the database files are restricted to `0600`.
169
+
170
+ Add the stash folder (`.file-stash/` by default) to your `.gitignore`.
171
+
172
+ ### Read limits
173
+
174
+ A single read never returns more than `FILESTASH_MAX_LINES` lines (default 2000) or `FILESTASH_MAX_CHARS` characters (default 100000), whichever is hit first, so one huge file cannot flood the context. The cap applies to every read: normal, `force`, partial `offset`/`limit` (a larger `limit` is capped too), excluded files, degraded mode and each file of `read_files`. Values must be positive integers; anything else is ignored with one line on stderr and the default is used. SDK users: the `maxLines` and `maxChars` options.
175
+
176
+ A truncated read ends with `[filestash: truncated, showing lines A-B of N; continue with offset=B+1]`. The cut is made at a line boundary; a single line longer than the character cap is cut at the cap and the notice names the next line to continue from (the rest of that line is not reachable). Only the delivered lines `A-B` count as delivered, so the continuation read returns real content, and repeating the same capped read can be answered `unchanged`. Token accounting uses the capped text (notice included) as the plain-read baseline, so truncation never shows up as savings.
177
+
178
+ - **Binary files:** if the first 8192 characters contain a NUL byte the file is treated as binary and the read returns `[filestash: binary file (<bytes> bytes), not shown]`. Nothing is stored. The check only looks at the start of the file: a text-looking file with a NUL after 8 KB is not detected and is read as text.
179
+ - **Large files:** files over 1,000,000 bytes (SDK option `maxStoreBytes`) are served capped like any other read but never stored, so they produce no savings. Files over 64 MiB are not read at all and return `[filestash: file too large (<bytes> bytes); not read]`.
180
+
181
+ ### Context resets
182
+
183
+ The server answers a repeat read of an unchanged file with a short "unchanged" note, assuming the model still has the content. After `/clear` or `/compact` it does not, and the server has no way to notice. Run `reset` on those events with a Claude Code `SessionStart` hook (`npx agent-file-stash init --hooks` adds it, or paste it into `~/.claude/settings.json`):
184
+
185
+ ```json
186
+ {
187
+ "hooks": {
188
+ "SessionStart": [
189
+ {
190
+ "matcher": "clear|compact",
191
+ "hooks": [{ "type": "command", "command": "npx agent-file-stash reset --from-hook" }]
192
+ }
193
+ ]
194
+ }
195
+ }
151
196
  ```
152
197
 
198
+ Limitation: subagents that use the MCP server by name share the parent's server process, so they share its read tracking. A subagent can be told "unchanged" for a file only the parent has seen; have it pass `force=true` on its first read of each file.
199
+
200
+ ### How sessions work
201
+
202
+ A session is one MCP server process: each `serve` start generates a random session id and registers it with its process id in the `sessions` table. Read tracking is per session. A file is "unchanged" or a diff only relative to what that session last returned for it, so two editors (or two projects sharing one stash database) never see each other's reads.
203
+
204
+ When a server starts, it prunes closed sessions: any other session whose process is no longer alive is deleted, together with its read pointers and per-session counters, and stashed file versions that no remaining session points at are removed. The lifetime counter (`Tokens saved (total)`, "Gross saved (all sessions)") is kept in a separate table and survives pruning. A server also removes its own `sessions` row on a clean shutdown. `reset` and `stash_clear` act on all sessions.
205
+
206
+ ### When it saves tokens and when it does not
207
+
208
+ - Savings only come from re-reads of a file that is still in the model's context. The first read of any file in a session costs the same as a plain read, and a new session always starts with full content.
209
+ - Tiny files, and small partial ranges, are returned as plain content when the "unchanged" label would not be shorter.
210
+ - When a diff is not smaller than the file (for example after a big rewrite), the full content is returned instead of the diff.
211
+ - A partial read whose range was edited returns that range as plain content, not a diff.
212
+ - "Unchanged", a diff and "changes elsewhere" are only answered for lines the model was already given in this session. A range that was never delivered (or only partly delivered) is returned as real content and added to what is recorded as delivered; adjacent and overlapping ranges merge, so reading 1-100 and then 101-200 makes a later read of 1-200 unchanged. If a file changed after only part of it was delivered, the requested lines are returned as plain content instead of a diff.
213
+ - Excluded secret files (see [Privacy](#privacy)) are read normally and never stored, so they never produce savings.
214
+ - The net figure subtracts an estimate of what the four tool definitions cost per session, so a session with few re-reads can show a negative net. All token counts use `ceil(characters / 4)` and are estimates.
215
+
216
+ ### Known limitations
217
+
218
+ - Clients do not send a per-conversation id, so the server cannot tell that you ran `/clear` or `/compact`. Without the [reset hook](#context-resets) it can answer "unchanged" for content the model no longer has; `force=true` always returns full content.
219
+ - Subagents that use the MCP server by name share the parent's server process and therefore its read tracking.
220
+ - The secret denylist matches on file names only: symlinks are not resolved, so a link with an innocent name pointing at a secret file is stashed, and secrets in files with ordinary names are not detected.
221
+ - Extra patterns in `FILESTASH_EXCLUDE` match basenames only, not directories or full paths.
222
+ - Node.js 24 or later is required (`node:sqlite`).
223
+ - The server only reads files inside its working directory.
224
+
225
+ ### Degraded mode
226
+
227
+ The stash never prevents a file from being read. If the database or the stash directory cannot be used, the server keeps running and reads files normally.
228
+
229
+ - A database that is corrupt (not a database, malformed) is treated as a disposable cache: it is renamed to `stash.db.corrupt-<unix-ms>` next to the original (with any `-wal`/`-shm` files given the same suffix), a fresh database is created, and one line is written to stderr. `stash_status` shows `Recovered: corrupt database moved to <path>`. The renamed file can be deleted. Recovery is attempted once per process and is serialised across processes with a `stash.db.recover.lock` file next to the database, so servers starting together on the same corrupt file recover it once.
230
+ - If the stash directory cannot be created or written, or the database cannot be opened or locked, or any database call fails during a session, the stash switches to degraded mode for the rest of the process: reads return the plain file content, nothing is stashed, and one line is written to stderr with the reason. `stash_status` starts with `Mode: DEGRADED (<reason>) - files are read normally, nothing is stashed`, its metadata carries `degraded` and `degradedReason`, and `stash_clear` reports that there is nothing to clear.
231
+ - Errors about the file being read (missing, unreadable, a directory) are still reported as errors.
232
+ - If file watching cannot start (for example the OS watcher limit is reached) or fails later, one line is written to stderr and the server continues without it.
233
+ - `status` and `reset` print a one-line error and exit 1 on an unreadable database (`reset --from-hook` exits 0); `status --all` skips it and continues.
234
+
235
+ ### As an SDK
236
+
237
+ The SDK lives in `packages/sdk` (workspace package `filestash-sdk`). It is bundled into the CLI and is not published to npm separately: `npm install agent-file-stash` installs the CLI/MCP server only and does not expose a library entry point. To embed it today, depend on the workspace package from a clone of this repository. The example below uses that package name.
238
+
153
239
  ```typescript
154
- import { createStash } from "agent-file-stash";
240
+ import { createStash } from "filestash-sdk";
155
241
 
156
242
  const { stash, watcher } = createStash({
157
243
  dbPath: "./my-stash.db",
@@ -180,7 +266,7 @@ const r3 = await stash.readFile("src/auth.ts");
180
266
 
181
267
  // Partial read — only the lines you need
182
268
  const r4 = await stash.readFile("src/auth.ts", { offset: 50, limit: 10 });
183
- // Returns lines 50-59, or "[unchanged in lines 50-59]" if nothing changed there
269
+ // Returns lines 50-59, or an "unchanged in lines 50-59" label if nothing changed there
184
270
 
185
271
  // Force a full re-read (bypasses stash, resets session tracking for this file)
186
272
  const r5 = await stash.readFileFull("src/auth.ts");
@@ -188,7 +274,8 @@ const r5 = await stash.readFileFull("src/auth.ts");
188
274
 
189
275
  // Stats
190
276
  const stats = await stash.getStats();
191
- // { filesTracked: 12, tokensSaved: 53851, sessionTokensSaved: 33205 }
277
+ // { filesTracked: 12, tokensSaved: 53851, sessionTokensSaved: 33205,
278
+ // sessionReads: 80, sessionBaselineTokens: 61000, sessionSentTokens: 27795 }
192
279
 
193
280
  // Cleanup
194
281
  watcher.close();
@@ -202,9 +289,30 @@ await stash.close();
202
289
  | `stash.init()` | Initialize the database (called automatically on first read) |
203
290
  | `stash.readFile(path, opts?)` | Read with stashing. Options: `{ offset?: number; limit?: number }` |
204
291
  | `stash.readFileFull(path)` | Always return full content and reset session tracking for this file |
205
- | `stash.getStats()` | Return `{ filesTracked, tokensSaved, sessionTokensSaved }` |
206
- | `stash.clear()` | Wipe all stashed content and stats |
207
- | `stash.close()` | Close the database connection |
292
+ | `stash.getStats()` | Return `{ filesTracked, tokensSaved, sessionTokensSaved, sessionReads, sessionBaselineTokens, sessionSentTokens, degraded, degradedReason?, recoveredFrom? }` |
293
+ | `stash.clear()` | Wipe all stashed content, read tracking and stats |
294
+ | `stash.resetReads()` | Forget read tracking for all sessions; next reads return full content |
295
+ | `stash.onFileDeleted(path)` | Drop stashed versions and read pointers for a path (called by `FileWatcher`) |
296
+ | `stash.isDegraded` / `stash.degradedReason` | Whether the stash is unavailable (see [Degraded mode](#degraded-mode)) and why |
297
+ | `stash.close()` | Remove this session's registration and close the database connection |
298
+
299
+ **Public API for 1.0.** Stable: `createStash(config)`, the `StashStore` methods in the table above, `FileWatcher` (`watch(paths)`, `close()`), `isExcludedPath(absPath, extraPatterns?)` and the types `StashConfig`, `StashStats` and `FileReadResult`. Internal, not covered by compatibility guarantees: the database schema and file layout, the exact text of the "unchanged" labels, `computeDiff` (exported from the SDK index but used internally by `StashStore`), the `FileWatcher` debounce constructor argument, and everything not exported from `packages/sdk/src/index.ts`.
300
+
301
+ ### Reading the numbers
302
+
303
+ `stash_status` (and the `[filestash: net ~N tokens this session (est.)]` footer appended to `read_file` results served from the stash, and to `read_files` results once the session has saved anything) reports net savings: gross saved (what a plain read of the same content would have returned minus what the stash actually returned) minus an estimate of the tokens the four tool definitions cost every session. Net can be negative, and it is shown as such. Savings only appear when files are re-read within a session; a session that reads each file once pays the tool-definition overhead and saves nothing. All figures use the same `ceil(characters / 4)` estimate and are approximate.
304
+
305
+ ```
306
+ filestash status:
307
+ Files tracked: 12
308
+ This session: 80 reads
309
+ Would have sent (plain reads): ~61,000 tokens
310
+ Actually sent: ~27,795 tokens
311
+ Gross saved: ~33,205 tokens
312
+ Tool definitions overhead: ~277 tokens (est.)
313
+ Net saved: ~32,928 tokens (est.)
314
+ Gross saved (all sessions): ~53,851 tokens
315
+ ```
208
316
 
209
317
  ## Benchmark
210
318
 
@@ -246,15 +354,17 @@ _Run `pnpm benchmark` to reproduce._
246
354
  ```
247
355
  packages/
248
356
  ├── sdk/src/
249
- │ ├── index.ts Public exports: createStash, StashStore, FileWatcher, computeDiff, types
250
- │ ├── stash.ts StashStore — SQLite-backed content-addressed stash with per-session read tracking
357
+ │ ├── index.ts Exports: createStash, StashStore, FileWatcher, computeDiff, isExcludedPath, types
358
+ │ ├── stash.ts StashStore — SQLite-backed content-addressed stash with per-session read tracking and pruning
251
359
  │ ├── differ.ts computeDiff — line-based LCS diff (unified format, LCS capped at 5 000 lines)
360
+ │ ├── exclude.ts isExcludedPath — secret-file denylist and FILESTASH_EXCLUDE patterns
252
361
  │ ├── watcher.ts FileWatcher — debounced fs.watch wrapper that evicts deleted files from the stash
253
362
  │ └── types.ts StashConfig, FileReadResult, StashStats type definitions
254
363
  │
255
364
  └── cli/src/
256
- ├── index.ts CLI entry point — init, serve, status, help commands
257
- └── mcp.ts MCP server — registers read_file, read_files, stash_status, stash_clear tools
365
+ ├── index.ts CLI entry point — init, serve, status, reset, help commands
366
+ ├── mcp.ts MCP server — registers read_file, read_files, stash_status, stash_clear tools
367
+ └── scan.ts findStashDatabases — locates stash databases for status --all
258
368
 
259
369
  test/
260
370
  ├── smoke.test.ts End-to-end flows: first read, stash hit, diff on change, partial reads, multi-session isolation
@@ -263,6 +373,14 @@ test/
263
373
  ├── watcher.test.ts FileWatcher: deletion detection, debounce coalescence, close() cancellation
264
374
  ├── mcp-tools.test.ts Unit tests for isPathAllowed (path traversal guard) and formatReadResult
265
375
  ├── mcp-meta.test.ts Validates the _meta field format and reverse-DNS namespace convention
376
+ ├── diff-guard.test.ts Full content is returned when a diff is not smaller than the file
377
+ ├── prune.test.ts Pruning of closed sessions and their data
378
+ ├── scan.test.ts findStashDatabases (status --all)
379
+ ├── secret-denylist.test.ts Secret files and FILESTASH_EXCLUDE patterns are never stored
380
+ ├── session-reset.test.ts resetReads, the reset command, init --hooks, MCP integration
381
+ ├── savings-regression.test.ts Session accounting identity, savings workload, tool definition overhead
382
+ ├── e2e.test.ts End-to-end suite with real servers: concurrent servers, crash safety, secrets, path restriction, shutdown
383
+ ├── docs.test.ts README mentions every CLI command, FILESTASH_* variable and MCP tool
266
384
  └── benchmark.ts Reproducible two-pass simulation across generated TypeScript files (pnpm benchmark)
267
385
  ```
268
386
 
@@ -270,26 +388,30 @@ The SDK has no external dependencies — it uses only Node.js built-ins (`node:s
270
388
 
271
389
  ## Architecture
272
390
 
273
- **Database:** Single SQLite file (`node:sqlite`, WAL mode) with four tables:
391
+ **Database:** Single SQLite file (`node:sqlite`, WAL mode) with six tables:
274
392
 
275
393
  | Table | Purpose |
276
394
  |---|---|
277
395
  | `file_versions` | Content-addressed storage, keyed by `(path, hash)` |
278
396
  | `session_reads` | Per-session read pointers — tracks which version each session last saw |
279
- | `stats` | Global token-savings counter |
280
- | `session_stats` | Per-session token-savings counter |
397
+ | `session_ranges` | Per-session, per-path line intervals already delivered for the current hash (merged) |
398
+ | `sessions` | One row per live server process: session id and pid, used to prune closed sessions |
399
+ | `stats` | Lifetime token-savings counter (survives pruning) |
400
+ | `session_stats` | Per-session counters: reads, baseline tokens, sent tokens, tokens saved |
401
+
402
+ `file_versions` is content-addressed: each row is a unique `(path, hash)` pair storing the full file content, its line count and a creation timestamp. When a file is read, its current content is hashed. If a matching row exists, no new version is written. If the hash is new, a new row is inserted. Diffs are not stored: on a changed re-read the diff is computed between the version the session last saw and the current content.
281
403
 
282
- `file_versions` is content-addressed: each row is a unique `(path, hash)` pair storing the full file content and its diff relative to the previous version at that path. When a file is read, its current content is hashed. If a matching row exists, no write occurs — the read is free. If the hash is new, a new row is inserted and the diff is computed and stored alongside it.
404
+ `session_reads` is a lightweight pointer table. Each row is a `(sessionId, path, hash)` triple recording which version of a file a given session last saw. On re-read, the engine joins `session_reads` against `file_versions` to decide what to return: same hash → "unchanged" label; different hash → computed diff; no prior entry → full content. `session_ranges` holds the merged `(start_line, end_line)` intervals the session was given for the hash in `session_reads`. A re-read is answered with the "unchanged" label only when the requested lines fall inside those intervals; otherwise the real lines are returned and the interval is added. A diff or "changes elsewhere" label additionally requires that the whole previous version was delivered. Databases created before this table existed have no range rows, so the first read after upgrading returns real content. This means two agents running in parallel, or an agent reading across a branch switch, each get correct diffs scoped to their own session.
283
405
 
284
- `session_reads` is a lightweight pointer table. Each row is a `(sessionId, path, hash)` triple recording which version of a file a given session last saw. On re-read, the engine joins `session_reads` against `file_versions` to decide what to return: same hash → `[unchanged]` label; different hash → stored diff; no prior entry → full content. This means two agents running in parallel, or an agent reading across a branch switch, each get correct diffs scoped to their own session.
406
+ WAL mode is enabled with a 5-second busy timeout so several servers can share one database and readers do not block the writer.
285
407
 
286
- WAL mode is enabled so concurrent reads never block each other and reads never block writes — important when multiple MCP tool calls fire in quick succession.
408
+ **Pruning:** on startup each server deletes sessions whose pid is no longer alive, their read pointers, delivered ranges and counters, and any `file_versions` row no remaining session points at. Rows for paths matching the secret denylist are removed at the same time. See [How sessions work](#how-sessions-work).
287
409
 
288
410
  **Change detection:** On every read, the current file content is hashed (SHA-256, truncated to 16 hex chars). Same hash = unchanged. Different hash = compute diff, update stash. No polling or watchers required for correctness — the hash is the source of truth. File watchers are optional and only used to proactively evict deleted files.
289
411
 
290
- **Diff algorithm:** Line-based unified diff (`computeDiff`). Groups changed lines into hunks with context lines, identical to the output of `git diff`. Diffs are stored as strings and returned verbatim to the agent.
412
+ **Diff algorithm:** Line-based unified diff (`computeDiff`). Groups changed lines into hunks with context lines, in unified format with 3 lines of context. The diff is returned verbatim to the agent, unless it is not smaller than the file, in which case the full content is returned.
291
413
 
292
- **Token estimation:** `ceil(characters / 4)`. Rough but directionally correct for code. Used only for the "tokens saved" metric — never affects correctness.
414
+ **Token estimation:** `ceil(characters / 4)`. Rough but directionally correct for code. Used for the token metrics and to decide whether a label or diff is actually shorter than the plain content; it never changes what the file contains.
293
415
 
294
416
  ## Uninstall
295
417
 
@@ -303,9 +425,13 @@ Remove the `agent-file-stash` entry from each config file where `init` added it:
303
425
  | Cursor | `~/.cursor/mcp.json` |
304
426
  | OpenCode | `$XDG_CONFIG_HOME/opencode/opencode.json` |
305
427
 
306
- Delete the `"agent-file-stash"` key from the `mcpServers` object in each file, then restart your editor.
428
+ Delete the `"filestash"` key (the one `init` adds; also `"agent-file-stash"` if you configured it by hand) from the `mcpServers` object in each file (the `mcp` object for OpenCode), then restart your editor.
429
+
430
+ **2. Remove the Claude Code hook**
431
+
432
+ If you ran `init --hooks` (or pasted the snippet from [Context resets](#context-resets)), remove the `SessionStart` entry whose command is `npx agent-file-stash reset --from-hook` from `~/.claude/settings.json`. `init --hooks` also left a backup of the previous file at `~/.claude/settings.json.bak`, which you can delete.
307
433
 
308
- **2. Remove the stash database**
434
+ **3. Remove the stash database**
309
435
 
310
436
  ```bash
311
437
  rm -rf .file-stash/
@@ -313,7 +439,7 @@ rm -rf .file-stash/
313
439
 
314
440
  This deletes the SQLite database and all cached content. If you set a custom `FILESTASH_DIR`, remove that directory instead.
315
441
 
316
- **3. Remove the package** _(if installed globally)_
442
+ **4. Remove the package** _(if installed globally)_
317
443
 
318
444
  ```bash
319
445
  npm uninstall -g agent-file-stash