agent-file-stash 0.4.0 → 0.6.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 +215 -41
- package/dist/cli.mjs +1435 -312
- 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
|
|
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,
|
|
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
|
|
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** —
|
|
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,
|
|
61
|
-
| `read_files` | Batch read multiple files at once with stashing. |
|
|
62
|
-
| `stash_status` | Show
|
|
63
|
-
| `stash_clear` |
|
|
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), `agent` (read-tracking scope, normally set by the [subagent hook](#subagents)). |
|
|
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 `
|
|
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
|
|
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)) and the subagent scope hook (see [Subagents](#subagents)) 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
|
|
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,32 @@ 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
|
+
|
|
145
|
+
#### `hook`
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx agent-file-stash hook subagent-scope
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A Claude Code `PreToolUse` hook, not meant to be run by hand (see [Subagents](#subagents)). It reads the hook JSON from stdin and, when the call comes from a subagent to `read_file` or `read_files`, prints the same arguments plus an `agent` argument so the server tracks that subagent separately. For every other call it prints nothing and exits 0. It never opens the database and never grants or changes permissions.
|
|
152
|
+
|
|
153
|
+
#### `doctor`
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx agent-file-stash doctor
|
|
157
|
+
npx agent-file-stash doctor --json
|
|
158
|
+
npx agent-file-stash doctor --check-updates
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Read-only diagnostics that tell you whether the install works and what to fix. It checks the Node version, the stash directory and database (schema version, integrity, permissions, leftover recovery files), the MCP registration in Claude Code, Cursor and OpenCode, the context-reset hook, the subagent scope hook, and the `FILESTASH_*` limits. It never creates, changes or deletes any file or setting (when a running server holds the database, SQLite may refresh the timestamp of its shared-memory index file `stash.db-shm`, as any reader does) and makes no network call unless you pass `--check-updates`, which compares the installed version with `npm view`. Each line is `[ok]`, `[warn]`, `[error]` or `[info]`, followed by a fix hint for warnings and errors; `--json` prints the same results as a JSON array and nothing else. The exit code is 1 when any check reports an error and 0 otherwise.
|
|
162
|
+
|
|
131
163
|
#### `help`
|
|
132
164
|
|
|
133
165
|
```bash
|
|
@@ -141,17 +173,113 @@ Prints a short usage summary with all available commands.
|
|
|
141
173
|
| Variable | Default | Description |
|
|
142
174
|
|---|---|---|
|
|
143
175
|
| `FILESTASH_DIR` | `.file-stash/` | Directory where the stash database is stored |
|
|
176
|
+
| `FILESTASH_EXCLUDE` | (none) | Comma-separated basename globs (`*`, `?`) that are never stored, added to the defaults |
|
|
177
|
+
| `FILESTASH_MAX_LINES` | `2000` | Maximum lines returned by one read; longer reads are truncated |
|
|
178
|
+
| `FILESTASH_MAX_CHARS` | `100000` | Maximum characters returned by one read; longer reads are truncated |
|
|
144
179
|
|
|
145
|
-
###
|
|
180
|
+
### Privacy
|
|
146
181
|
|
|
147
|
-
|
|
182
|
+
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
183
|
|
|
149
|
-
|
|
150
|
-
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
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`.
|
|
187
|
+
|
|
188
|
+
Add the stash folder (`.file-stash/` by default) to your `.gitignore`.
|
|
189
|
+
|
|
190
|
+
### Read limits
|
|
191
|
+
|
|
192
|
+
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.
|
|
193
|
+
|
|
194
|
+
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.
|
|
195
|
+
|
|
196
|
+
- **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.
|
|
197
|
+
- **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]`.
|
|
198
|
+
|
|
199
|
+
### Context resets
|
|
200
|
+
|
|
201
|
+
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`):
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"hooks": {
|
|
206
|
+
"SessionStart": [
|
|
207
|
+
{
|
|
208
|
+
"matcher": "clear|compact",
|
|
209
|
+
"hooks": [{ "type": "command", "command": "npx agent-file-stash reset --from-hook" }]
|
|
210
|
+
}
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
151
214
|
```
|
|
152
215
|
|
|
216
|
+
### Subagents
|
|
217
|
+
|
|
218
|
+
A subagent that uses the MCP server by name shares the parent's server process. Without help, the parent and every subagent share one read tracking: a subagent can be told "unchanged" for a file only the parent has seen, the parent can be told that for a file only a subagent read, and parallel subagents can do it to each other.
|
|
219
|
+
|
|
220
|
+
`init --hooks` also registers a `PreToolUse` hook that fixes this (or paste it into `~/.claude/settings.json`):
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"hooks": {
|
|
225
|
+
"PreToolUse": [
|
|
226
|
+
{
|
|
227
|
+
"matcher": "mcp__(filestash|agent-file-stash)__read_files?",
|
|
228
|
+
"hooks": [{ "type": "command", "command": "npx agent-file-stash hook subagent-scope" }]
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
For a call made by a subagent, Claude Code gives the hook the subagent's `agent_id`, and the hook passes it to the tool as the optional `agent` argument. The server then tracks delivered line ranges per `agent` value: a subagent's first read of a file returns real content, a repeat read by the same subagent is "unchanged", and nothing a subagent reads is counted as delivered to the parent or to another subagent. Calls by the main agent carry no `agent_id` and are left untouched. Token accounting (`stash_status`, the read footer) stays per session.
|
|
236
|
+
|
|
237
|
+
The hook only adds an argument. It emits no permission decision, so it never grants or bypasses a permission, and a `permissions.deny` rule still blocks the tool. Without the hook, subagents share the parent's tracking as before, and `force=true` on a subagent's first read of each file is the workaround. `doctor` reports a missing hook.
|
|
238
|
+
|
|
239
|
+
Each session keeps at most 32 `agent` scopes; a read from a new scope beyond that deletes the tracking of the least recently used one, which then gets real content on its next read. Scoped tracking is removed together with its session when that session closes. SDK users pass `scope` to `readFile` and `readFileFull`; a value that is not 1-64 characters of `A-Za-z0-9_.-` is ignored, and session ids must not contain `::`.
|
|
240
|
+
|
|
241
|
+
### How sessions work
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
### When it saves tokens and when it does not
|
|
248
|
+
|
|
249
|
+
- 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.
|
|
250
|
+
- Tiny files, and small partial ranges, are returned as plain content when the "unchanged" label would not be shorter.
|
|
251
|
+
- When a diff is not smaller than the file (for example after a big rewrite), the full content is returned instead of the diff.
|
|
252
|
+
- A partial read whose range was edited returns that range as plain content, not a diff.
|
|
253
|
+
- "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.
|
|
254
|
+
- Excluded secret files (see [Privacy](#privacy)) are read normally and never stored, so they never produce savings.
|
|
255
|
+
- 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.
|
|
256
|
+
|
|
257
|
+
### Known limitations
|
|
258
|
+
|
|
259
|
+
- 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.
|
|
260
|
+
- Subagents only get their own read tracking when the [subagent hook](#subagents) is installed; without it they share the parent's.
|
|
261
|
+
- 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.
|
|
262
|
+
- Extra patterns in `FILESTASH_EXCLUDE` match basenames only, not directories or full paths.
|
|
263
|
+
- Node.js 24 or later is required (`node:sqlite`).
|
|
264
|
+
- The server only reads files inside its working directory.
|
|
265
|
+
|
|
266
|
+
### Degraded mode
|
|
267
|
+
|
|
268
|
+
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.
|
|
269
|
+
|
|
270
|
+
- 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.
|
|
271
|
+
- 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.
|
|
272
|
+
- A database created by a newer release (its `user_version` is higher than this release knows) is not treated as corrupt and is left untouched: the stash degrades with `database schema version N is newer than this release supports (M); upgrade agent-file-stash`.
|
|
273
|
+
- Errors about the file being read (missing, unreadable, a directory) are still reported as errors.
|
|
274
|
+
- 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.
|
|
275
|
+
- `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.
|
|
276
|
+
|
|
277
|
+
### As an SDK
|
|
278
|
+
|
|
279
|
+
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.
|
|
280
|
+
|
|
153
281
|
```typescript
|
|
154
|
-
import { createStash } from "
|
|
282
|
+
import { createStash } from "filestash-sdk";
|
|
155
283
|
|
|
156
284
|
const { stash, watcher } = createStash({
|
|
157
285
|
dbPath: "./my-stash.db",
|
|
@@ -180,7 +308,7 @@ const r3 = await stash.readFile("src/auth.ts");
|
|
|
180
308
|
|
|
181
309
|
// Partial read — only the lines you need
|
|
182
310
|
const r4 = await stash.readFile("src/auth.ts", { offset: 50, limit: 10 });
|
|
183
|
-
// Returns lines 50-59, or "
|
|
311
|
+
// Returns lines 50-59, or an "unchanged in lines 50-59" label if nothing changed there
|
|
184
312
|
|
|
185
313
|
// Force a full re-read (bypasses stash, resets session tracking for this file)
|
|
186
314
|
const r5 = await stash.readFileFull("src/auth.ts");
|
|
@@ -188,7 +316,8 @@ const r5 = await stash.readFileFull("src/auth.ts");
|
|
|
188
316
|
|
|
189
317
|
// Stats
|
|
190
318
|
const stats = await stash.getStats();
|
|
191
|
-
// { filesTracked: 12, tokensSaved: 53851, sessionTokensSaved: 33205
|
|
319
|
+
// { filesTracked: 12, tokensSaved: 53851, sessionTokensSaved: 33205,
|
|
320
|
+
// sessionReads: 80, sessionBaselineTokens: 61000, sessionSentTokens: 27795 }
|
|
192
321
|
|
|
193
322
|
// Cleanup
|
|
194
323
|
watcher.close();
|
|
@@ -202,9 +331,30 @@ await stash.close();
|
|
|
202
331
|
| `stash.init()` | Initialize the database (called automatically on first read) |
|
|
203
332
|
| `stash.readFile(path, opts?)` | Read with stashing. Options: `{ offset?: number; limit?: number }` |
|
|
204
333
|
| `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.
|
|
334
|
+
| `stash.getStats()` | Return `{ filesTracked, tokensSaved, sessionTokensSaved, sessionReads, sessionBaselineTokens, sessionSentTokens, degraded, degradedReason?, recoveredFrom? }` |
|
|
335
|
+
| `stash.clear()` | Wipe all stashed content, read tracking and stats |
|
|
336
|
+
| `stash.resetReads()` | Forget read tracking for all sessions; next reads return full content |
|
|
337
|
+
| `stash.onFileDeleted(path)` | Drop stashed versions and read pointers for a path (called by `FileWatcher`) |
|
|
338
|
+
| `stash.isDegraded` / `stash.degradedReason` | Whether the stash is unavailable (see [Degraded mode](#degraded-mode)) and why |
|
|
339
|
+
| `stash.close()` | Remove this session's registration and close the database connection |
|
|
340
|
+
|
|
341
|
+
**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`.
|
|
342
|
+
|
|
343
|
+
### Reading the numbers
|
|
344
|
+
|
|
345
|
+
`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.
|
|
346
|
+
|
|
347
|
+
```
|
|
348
|
+
filestash status:
|
|
349
|
+
Files tracked: 12
|
|
350
|
+
This session: 80 reads
|
|
351
|
+
Would have sent (plain reads): ~61,000 tokens
|
|
352
|
+
Actually sent: ~27,795 tokens
|
|
353
|
+
Gross saved: ~33,205 tokens
|
|
354
|
+
Tool definitions overhead: ~277 tokens (est.)
|
|
355
|
+
Net saved: ~32,928 tokens (est.)
|
|
356
|
+
Gross saved (all sessions): ~53,851 tokens
|
|
357
|
+
```
|
|
208
358
|
|
|
209
359
|
## Benchmark
|
|
210
360
|
|
|
@@ -246,23 +396,37 @@ _Run `pnpm benchmark` to reproduce._
|
|
|
246
396
|
```
|
|
247
397
|
packages/
|
|
248
398
|
├── sdk/src/
|
|
249
|
-
│ ├── index.ts
|
|
250
|
-
│ ├──
|
|
251
|
-
│ ├──
|
|
399
|
+
│ ├── index.ts Exports: createStash, StashStore, FileWatcher, computeDiff, isExcludedPath, types
|
|
400
|
+
│ ├── migrations.ts SCHEMA_VERSION and the ordered, transactional schema migrations
|
|
401
|
+
│ ├── stash.ts StashStore — SQLite-backed content-addressed stash with per-session read tracking and pruning
|
|
402
|
+
│ ├── differ.ts computeDiff — line-based Myers diff (unified format, edit distance capped at 2 500 lines)
|
|
403
|
+
│ ├── exclude.ts isExcludedPath — secret-file denylist and FILESTASH_EXCLUDE patterns
|
|
252
404
|
│ ├── watcher.ts FileWatcher — debounced fs.watch wrapper that evicts deleted files from the stash
|
|
253
405
|
│ └── types.ts StashConfig, FileReadResult, StashStats type definitions
|
|
254
406
|
│
|
|
255
407
|
└── cli/src/
|
|
256
|
-
├── index.ts CLI entry point — init, serve, status, help commands
|
|
257
|
-
|
|
408
|
+
├── index.ts CLI entry point — init, serve, status, reset, help commands
|
|
409
|
+
├── hook.ts The hook subagent-scope command (PreToolUse hook for per-subagent read tracking)
|
|
410
|
+
├── mcp.ts MCP server — registers read_file, read_files, stash_status, stash_clear tools
|
|
411
|
+
└── scan.ts findStashDatabases — locates stash databases for status --all
|
|
258
412
|
|
|
259
413
|
test/
|
|
260
414
|
├── smoke.test.ts End-to-end flows: first read, stash hit, diff on change, partial reads, multi-session isolation
|
|
261
|
-
├── differ.test.ts Unit tests for computeDiff: add/remove/mixed edits, context lines,
|
|
415
|
+
├── differ.test.ts Unit tests for computeDiff: add/remove/mixed edits, context lines, edit distance cap
|
|
262
416
|
├── stash-errors.test.ts Error paths: missing file, clear(), onFileDeleted(), post-close re-init
|
|
263
417
|
├── watcher.test.ts FileWatcher: deletion detection, debounce coalescence, close() cancellation
|
|
264
418
|
├── mcp-tools.test.ts Unit tests for isPathAllowed (path traversal guard) and formatReadResult
|
|
265
419
|
├── mcp-meta.test.ts Validates the _meta field format and reverse-DNS namespace convention
|
|
420
|
+
├── differ-myers.test.ts computeDiff against a reference LCS, patch round-trips, edge cases, edit distance cap, speed bounds
|
|
421
|
+
├── diff-guard.test.ts Full content is returned when a diff is not smaller than the file
|
|
422
|
+
├── prune.test.ts Pruning of closed sessions and their data
|
|
423
|
+
├── scan.test.ts findStashDatabases (status --all)
|
|
424
|
+
├── secret-denylist.test.ts Secret files and FILESTASH_EXCLUDE patterns are never stored
|
|
425
|
+
├── session-reset.test.ts resetReads, the reset command, init --hooks, MCP integration
|
|
426
|
+
├── savings-regression.test.ts Session accounting identity, savings workload, tool definition overhead
|
|
427
|
+
├── e2e.test.ts End-to-end suite with real servers: concurrent servers, crash safety, secrets, path restriction, shutdown
|
|
428
|
+
├── subagent-scope.test.ts Per-agent read tracking in the SDK, the MCP server, the hook command, init --hooks and doctor
|
|
429
|
+
├── docs.test.ts README mentions every CLI command, FILESTASH_* variable and MCP tool
|
|
266
430
|
└── benchmark.ts Reproducible two-pass simulation across generated TypeScript files (pnpm benchmark)
|
|
267
431
|
```
|
|
268
432
|
|
|
@@ -270,26 +434,32 @@ The SDK has no external dependencies — it uses only Node.js built-ins (`node:s
|
|
|
270
434
|
|
|
271
435
|
## Architecture
|
|
272
436
|
|
|
273
|
-
**Database:** Single SQLite file (`node:sqlite`, WAL mode) with
|
|
437
|
+
**Database:** Single SQLite file (`node:sqlite`, WAL mode) with six tables:
|
|
274
438
|
|
|
275
439
|
| Table | Purpose |
|
|
276
440
|
|---|---|
|
|
277
441
|
| `file_versions` | Content-addressed storage, keyed by `(path, hash)` |
|
|
278
442
|
| `session_reads` | Per-session read pointers — tracks which version each session last saw |
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
443
|
+
| `session_ranges` | Per-session, per-path line intervals already delivered for the current hash (merged) |
|
|
444
|
+
| `sessions` | One row per live server process: session id and pid, used to prune closed sessions |
|
|
445
|
+
| `stats` | Lifetime token-savings counter (survives pruning) |
|
|
446
|
+
| `session_stats` | Per-session counters: reads, baseline tokens, sent tokens, tokens saved |
|
|
281
447
|
|
|
282
|
-
`file_versions` is content-addressed: each row is a unique `(path, hash)` pair storing the full file content
|
|
448
|
+
`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.
|
|
283
449
|
|
|
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 →
|
|
450
|
+
`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.
|
|
285
451
|
|
|
286
|
-
WAL mode is enabled
|
|
452
|
+
WAL mode is enabled with a 5-second busy timeout so several servers can share one database and readers do not block the writer.
|
|
453
|
+
|
|
454
|
+
**Schema versioning:** the schema version is stored in SQLite's `PRAGMA user_version` (currently 1; a database with version 0 is a legacy one created before versioning and is completed with any missing table or index). On open, each pending migration runs in its own `BEGIN IMMEDIATE` transaction that also bumps `user_version`, and servers opening the same database at once re-read the version inside the transaction, so each migration is applied once. Migrations are forward-only and additive when possible; there is no downgrade. A database whose version is newer than the release supports is never modified: the server enters [degraded mode](#degraded-mode) with `database schema version N is newer than this release supports (M); upgrade agent-file-stash`.
|
|
455
|
+
|
|
456
|
+
**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
457
|
|
|
288
458
|
**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
459
|
|
|
290
|
-
**Diff algorithm:** Line-based unified diff (`computeDiff`).
|
|
460
|
+
**Diff algorithm:** Line-based unified diff (`computeDiff`). The common leading and trailing lines are trimmed and the rest is diffed with Myers' O(ND) algorithm, so the cost depends on how much changed rather than on file size. Changed lines are grouped into hunks 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. When the edit distance exceeds 2 500 changed lines (or the work budget is spent), the differing middle is reported as fully removed and re-added, which in practice makes the guard return the full file; this keeps the diff within a fixed time bound on the single-threaded server.
|
|
291
461
|
|
|
292
|
-
**Token estimation:** `ceil(characters / 4)`. Rough but directionally correct for code. Used
|
|
462
|
+
**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
463
|
|
|
294
464
|
## Uninstall
|
|
295
465
|
|
|
@@ -303,9 +473,13 @@ Remove the `agent-file-stash` entry from each config file where `init` added it:
|
|
|
303
473
|
| Cursor | `~/.cursor/mcp.json` |
|
|
304
474
|
| OpenCode | `$XDG_CONFIG_HOME/opencode/opencode.json` |
|
|
305
475
|
|
|
306
|
-
Delete the `"agent-file-stash"`
|
|
476
|
+
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.
|
|
477
|
+
|
|
478
|
+
**2. Remove the Claude Code hook**
|
|
479
|
+
|
|
480
|
+
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` and the `PreToolUse` entry whose command is `npx agent-file-stash hook subagent-scope` from `~/.claude/settings.json`. `init --hooks` also left a backup of the previous file at `~/.claude/settings.json.bak`, which you can delete.
|
|
307
481
|
|
|
308
|
-
**
|
|
482
|
+
**3. Remove the stash database**
|
|
309
483
|
|
|
310
484
|
```bash
|
|
311
485
|
rm -rf .file-stash/
|
|
@@ -313,7 +487,7 @@ rm -rf .file-stash/
|
|
|
313
487
|
|
|
314
488
|
This deletes the SQLite database and all cached content. If you set a custom `FILESTASH_DIR`, remove that directory instead.
|
|
315
489
|
|
|
316
|
-
**
|
|
490
|
+
**4. Remove the package** _(if installed globally)_
|
|
317
491
|
|
|
318
492
|
```bash
|
|
319
493
|
npm uninstall -g agent-file-stash
|