smcreader 0.0.0-stage → 0.1.1
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/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +10 -0
- package/.mcp.json +7 -0
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/bin/reader-hook +3 -0
- package/bin/reader-mcp +3 -0
- package/bin/smcreader-hook +6 -0
- package/bin/smcreader-mcp +14 -0
- package/hooks/hooks.json +13 -0
- package/lib/config.js +33 -0
- package/lib/hook-command.js +80 -0
- package/lib/index.js +17 -0
- package/lib/intercept-hook.js +190 -0
- package/lib/read-guard.js +128 -0
- package/lib/reader-mcp.js +32 -0
- package/lib/reader-model.js +230 -0
- package/lib/reader-store.js +316 -0
- package/lib/reader-tools.js +145 -0
- package/lib/stdio-mcp.js +31 -0
- package/package.json +62 -4
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
|
+
"name": "smcreader",
|
|
4
|
+
"description": "SMC Reader (Save My Context Reader) for Claude Code",
|
|
5
|
+
"owner": { "name": "bulatko" },
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "smcreader",
|
|
9
|
+
"description": "Large tool outputs go to disk behind a short pointer; the model pulls only the lines it needs.",
|
|
10
|
+
"source": "./",
|
|
11
|
+
"category": "productivity"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smcreader",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "SMC Reader (Save My Context Reader): large tool outputs are spilled to disk and replaced by a short pointer; the model pulls only the lines it needs via reader_ask / reader_peek.",
|
|
5
|
+
"author": { "name": "bulatko" },
|
|
6
|
+
"homepage": "https://github.com/bulatko/SMCReader",
|
|
7
|
+
"repository": "https://github.com/bulatko/SMCReader",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"keywords": ["context", "mcp", "hooks", "tool-output"]
|
|
10
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bulatko
|
|
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
|
@@ -1,3 +1,230 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# SMC Reader
|
|
4
|
+
|
|
5
|
+
**Save My Context Reader** — keep large tool outputs out of your agent's context window.
|
|
6
|
+
|
|
7
|
+
[](https://github.com/bulatko/SMCReader/actions/workflows/ci.yml)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
<img src="docs/hero.svg" alt="Without SMC Reader a 13.9 KB command output stays in the model context; with SMC Reader a hook spills it to disk and the model gets a ~1 KB pointer and pulls only the lines it needs" width="880">
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
A big `Bash` or `WebFetch` output does not have to land in the model's context.
|
|
17
|
+
SMC Reader's hook catches it **before the model sees it**, spills it to disk and hands the model a
|
|
18
|
+
short pointer. The model then pulls only what it needs — exact lines, or an answer to a question.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
In Claude Code (Node.js ≥ 20 on `PATH`):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
/plugin marketplace add bulatko/SMCReader
|
|
26
|
+
/plugin install smcreader@smcreader
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That's it — no `settings.json` or `--mcp-config` edits. Run something noisy and the model gets this
|
|
30
|
+
instead of 3000 lines:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
[READER ctx_b4c9b23c] Bash output intercepted: 13893 bytes, 3001 lines (>2048 chars).
|
|
34
|
+
1
|
|
35
|
+
2
|
|
36
|
+
...
|
|
37
|
+
... (2989 lines omitted — in the reader) ...
|
|
38
|
+
2999
|
|
39
|
+
3000
|
|
40
|
+
Full output is in reader ctx_b4c9b23c: mcp__plugin_smcreader_smcreader__reader_ask(id, question) to analyze, mcp__plugin_smcreader_smcreader__reader_peek(id, start_line, end_line) for exact lines, mcp__plugin_smcreader_smcreader__reader_close(id) when done.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Why
|
|
44
|
+
|
|
45
|
+
Agents run commands whose output is mostly noise for the task at hand: test logs, build output,
|
|
46
|
+
`grep -r`, `git log`, fetched pages. Every byte of it stays in the context for the rest of the
|
|
47
|
+
session, pushes out what matters and makes every following turn more expensive.
|
|
48
|
+
Truncating is not a fix — the one line the agent needs may be in the middle.
|
|
49
|
+
|
|
50
|
+
Measured on our own agent transcripts over 5 days (19–23 Oct 2026): **472 interceptions, 2.88 MB**
|
|
51
|
+
of tool output.
|
|
52
|
+
|
|
53
|
+
| what the agent did with the intercepted output | share |
|
|
54
|
+
|---|---:|
|
|
55
|
+
| nothing — the head and tail in the pointer were enough | **48%** |
|
|
56
|
+
| asked a question (`reader_ask`) | 11% |
|
|
57
|
+
| pulled a part of it (`reader_peek`) | 14% |
|
|
58
|
+
| pulled almost everything back | 27% |
|
|
59
|
+
|
|
60
|
+
**54% of the intercepted bytes (1.55 MB) never entered the context.** On outputs of 16 KB and more
|
|
61
|
+
the saving is about **21 KB per interception**.
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
<p align="center">
|
|
66
|
+
<img src="docs/analogy.svg" alt="Three steps: 1) Opus runs npm test; the SMC Reader hook catches the 3,000-line output before it reaches Opus and saves it to disk; 2) Opus gets a short note instead, saying what was caught and how to read it with reader_ask or reader_peek; 3) Opus asks Haiku which test failed, Haiku reads all of it and returns just the two lines that matter" width="880">
|
|
67
|
+
</p>
|
|
68
|
+
|
|
69
|
+
The main model (Opus, Fable) runs a command as usual — but the output never reaches it. SMC Reader's
|
|
70
|
+
hook catches it on the way, saves it to disk and gives the model a short note instead: what was caught
|
|
71
|
+
and how to read it. When the model needs something, it asks a cheap model (Haiku) with `reader_ask`;
|
|
72
|
+
Haiku reads the whole output and returns only the lines that answer the question, verbatim.
|
|
73
|
+
Exact lines by number (`reader_peek`) come straight from disk, with no model at all.
|
|
74
|
+
|
|
75
|
+
Under the hood:
|
|
76
|
+
|
|
77
|
+
1. Claude Code runs a tool and passes the result to the `PostToolUse` command hook.
|
|
78
|
+
2. If the output is large (≥ 2048 chars by default), the hook writes it to
|
|
79
|
+
`~/.cache/smcreader/ctx_<id>.txt` and replaces it with a pointer **in the same shape as the
|
|
80
|
+
original output** — the engine silently ignores a replacement of a different shape.
|
|
81
|
+
3. The model sees the pointer: id, size, head and tail of the output.
|
|
82
|
+
4. It pulls what it needs through the `smcreader` MCP server:
|
|
83
|
+
|
|
84
|
+
| tool | what it does |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `reader_peek(id, start_line, end_line)` | exact numbered lines from disk, no LLM |
|
|
87
|
+
| `reader_ask(id, question)` | a small Haiku session reads the document and returns the relevant lines verbatim with a short note |
|
|
88
|
+
| `reader_open({path \| text, briefing})` | open any file or text explicitly |
|
|
89
|
+
| `reader_list()` / `reader_close(id)` | what is open / delete a document |
|
|
90
|
+
|
|
91
|
+
The hook and the MCP server are separate processes sharing one directory; the directory is the
|
|
92
|
+
source of truth (open-document limit, LRU eviction, 48 h expiry since last use).
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
**As a plugin (recommended)** — see [Quick start](#quick-start); the repository is its own plugin
|
|
97
|
+
marketplace. The hook has no dependencies and runs from the plugin directory. The MCP server is
|
|
98
|
+
started with `npx -y --omit=optional --package=smcreader@<plugin version> smcreader-mcp`: the first
|
|
99
|
+
start downloads the npm package once (~40 MB with dependencies, ~60 MB on disk). `--omit=optional`
|
|
100
|
+
skips the Agent SDK's bundled Claude Code binaries (~280 MB per platform variant); `reader_ask` runs your own `claude`
|
|
101
|
+
from `PATH` instead (or `READER_CLAUDE_EXECUTABLE`). Loaded as a plugin, the tools are named
|
|
102
|
+
`mcp__plugin_smcreader_smcreader__reader_*`, and the hook writes these names into the pointer.
|
|
103
|
+
|
|
104
|
+
For a one-off session from a checkout: `git clone https://github.com/bulatko/SMCReader ~/smcreader`
|
|
105
|
+
and `claude --plugin-dir ~/smcreader`.
|
|
106
|
+
|
|
107
|
+
<details>
|
|
108
|
+
<summary><b>Manual setup</b> (hook in <code>settings.json</code> + <code>claude mcp add</code>)</summary>
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
npm install -g smcreader # or: git clone … && npm install --omit=dev
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
1. Hook — add to `~/.claude/settings.json` (or a project's `.claude/settings.json`):
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"hooks": {
|
|
119
|
+
"PostToolUse": [
|
|
120
|
+
{ "matcher": "Bash|WebFetch",
|
|
121
|
+
"hooks": [{ "type": "command", "command": "READER_TOOL_PREFIX=mcp__smcreader__ smcreader-hook" }] }
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
2. MCP server:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
claude mcp add smcreader -- smcreader-mcp
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
With a git checkout instead of a global install use `node /ABSOLUTE/PATH/bin/smcreader-hook` and
|
|
134
|
+
`node /ABSOLUTE/PATH/bin/smcreader-mcp`. The old binary names `reader-hook` / `reader-mcp` remain
|
|
135
|
+
as aliases.
|
|
136
|
+
|
|
137
|
+
Both processes read the same environment; if you set `READER_DIR`, set it for both
|
|
138
|
+
(`claude mcp add smcreader -e READER_DIR=/path -- smcreader-mcp`).
|
|
139
|
+
|
|
140
|
+
**Intercepting an MCP tool.** Add it to the hook's `matcher` **and** to `READER_ALLOWLIST`, e.g.
|
|
141
|
+
matcher `Bash|WebFetch|mcp__mesh__execute` and command
|
|
142
|
+
`READER_ALLOWLIST=Bash,WebFetch,mcp__mesh__execute smcreader-hook`. Never allowlist SMC Reader's
|
|
143
|
+
own tools.
|
|
144
|
+
|
|
145
|
+
**Read guard (optional).** `PreToolUse`, matcher `Read`, same command with
|
|
146
|
+
`READER_READ_GUARD=32768`: a full `Read` of a file above the threshold is denied with working
|
|
147
|
+
alternatives (`Read` with offset/limit, Grep, `reader_open`, sed/head/tail). The plugin does not
|
|
148
|
+
enable it.
|
|
149
|
+
|
|
150
|
+
</details>
|
|
151
|
+
|
|
152
|
+
<details>
|
|
153
|
+
<summary><b>Configuration</b> (environment variables)</summary>
|
|
154
|
+
|
|
155
|
+
| variable | default | used by | meaning |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `READER_DIR` | `$XDG_CACHE_HOME/smcreader` or `~/.cache/smcreader` | both | shared document directory |
|
|
158
|
+
| `READER_THRESHOLD` | `2048` | hook | intercept outputs of at least this many characters |
|
|
159
|
+
| `READER_ALLOWLIST` | `Bash,WebFetch` | hook | comma-separated tool names to intercept |
|
|
160
|
+
| `READER_TOOL_PREFIX` | — | hook | tool-name prefix written into the pointer, e.g. `mcp__smcreader__` |
|
|
161
|
+
| `READER_READ_GUARD` | `0` (off) | hook | bytes; deny a full `Read` of bigger files (`PreToolUse`) |
|
|
162
|
+
| `READER_LOG` | — | hook | append a log line per interception / error to this file |
|
|
163
|
+
| `READER_OWNER` | — | server (hook: fallback) | owner the server acts as; unset = sees all documents |
|
|
164
|
+
| `READER_ASK_FORMAT` | `lines` | server | default `reader_ask` format: `lines` (verbatim lines + short note) or `prose` |
|
|
165
|
+
| `READER_WORK_DIR` | `$TMPDIR/reader-work` | server | cwd of the Haiku reader sessions (their transcripts land there) |
|
|
166
|
+
| `READER_CLAUDE_EXECUTABLE` | the Agent SDK's bundled binary if installed, else `claude` on `PATH` | server | Claude Code binary that runs the Haiku reader |
|
|
167
|
+
|
|
168
|
+
**Owners.** The hook stamps each document with the `session_id` from its input (a subagent's
|
|
169
|
+
document gets `<session>/agent:<agent_id>`). The open-document limit (10) and LRU eviction are per
|
|
170
|
+
owner; `reader_close` refuses another owner's document when the server has `READER_OWNER`;
|
|
171
|
+
reading by id is never restricted.
|
|
172
|
+
|
|
173
|
+
</details>
|
|
174
|
+
|
|
175
|
+
<details>
|
|
176
|
+
<summary><b>Development</b> (layout, tests)</summary>
|
|
177
|
+
|
|
178
|
+
| path | role |
|
|
179
|
+
|---|---|
|
|
180
|
+
| `lib/reader-store.js` | document store over a directory (`ctx_<8hex>.txt` + `.meta.json`), owners, LRU limit, TTL sweep |
|
|
181
|
+
| `lib/reader-model.js` | Haiku reader via Agent SDK `query()`; answer formats `prose` / `lines`; `createRunQuery({cwd, settings, cleanupPeriodDays, executable})` for embedding hosts |
|
|
182
|
+
| `lib/reader-tools.js` | transport-free tool definitions `reader_open/ask/peek/close/list` |
|
|
183
|
+
| `lib/intercept-hook.js` | interception: extract text, mirror the tool's output shape, build the pointer |
|
|
184
|
+
| `lib/read-guard.js` | optional `PreToolUse` guard for big `Read`s |
|
|
185
|
+
| `lib/stdio-mcp.js`, `bin/smcreader-mcp` | stdio MCP server (`@modelcontextprotocol/sdk`) |
|
|
186
|
+
| `lib/reader-mcp.js` | in-process MCP factory for the Agent SDK (`createSdkMcpServer`) — same tools |
|
|
187
|
+
| `lib/hook-command.js`, `bin/smcreader-hook` | the command hook (stdin JSON → stdout JSON) |
|
|
188
|
+
| `.claude-plugin/`, `hooks/hooks.json`, `.mcp.json` | Claude Code plugin + marketplace manifests |
|
|
189
|
+
| `e2e/claude-code.sh`, `e2e/plugin.sh` | end-to-end tests in a real Claude Code CLI |
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
npm test # unit + cross-process (spawns the real bin/ scripts)
|
|
193
|
+
sh e2e/claude-code.sh # real Claude Code: hook via --settings, server via --mcp-config
|
|
194
|
+
READER_E2E_NO_HOOK=1 sh e2e/claude-code.sh # negative control: no hook — must fail
|
|
195
|
+
sh e2e/plugin.sh # real Claude Code, everything from the plugin
|
|
196
|
+
READER_E2E_NO_PLUGIN=1 sh e2e/plugin.sh # negative control: no --plugin-dir — must fail
|
|
197
|
+
READER_E2E_PACKAGE=registry sh e2e/plugin.sh # the published npm version instead of `npm pack`
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`e2e/plugin.sh` loads a copy of the tracked files without `node_modules` (what a marketplace
|
|
201
|
+
install gets) and points its `.mcp.json` at `npm pack` of the working tree.
|
|
202
|
+
|
|
203
|
+
**Releasing.** A marketplace install takes the plugin from the default branch, and its `.mcp.json`
|
|
204
|
+
pins `smcreader@<version>` — so publish that version to npm *before* pushing the version bump.
|
|
205
|
+
|
|
206
|
+
The e2e verdict comes from `--output-format stream-json`, i.e. from what the model actually
|
|
207
|
+
received, not from what it said: (1) the Bash `tool_result` contains `[READER ctx_` and not
|
|
208
|
+
`1500` (from `seq 1 3000`); (2) a reader tool call with that id returned `2999`. One run takes
|
|
209
|
+
~10–20 s and ~$0.03 on Haiku. CI runs only `npm test`: e2e needs a logged-in Claude Code.
|
|
210
|
+
|
|
211
|
+
</details>
|
|
212
|
+
|
|
213
|
+
## Limitations
|
|
214
|
+
|
|
215
|
+
- Needs a Claude Code version that applies `hookSpecificOutput.updatedToolOutput` from command
|
|
216
|
+
hooks (verified on 2.1.282). Older versions call the hook but keep the original output.
|
|
217
|
+
- Supported output shapes: string, `{stdout,…}` (Bash), `{content:[…]}` (MCP), `{output}`,
|
|
218
|
+
`{file:{content}}` (Read), arrays of content blocks. Unknown shape → passed through untouched.
|
|
219
|
+
- `Read` is not intercepted by default — `Edit` needs exact bytes. Use the read guard instead.
|
|
220
|
+
- `reader_ask` needs Claude Code credentials and a `claude` binary (Agent SDK, model Haiku);
|
|
221
|
+
`reader_open` / `reader_peek` / `reader_list` work without them. Documents above 700 KB can
|
|
222
|
+
only be peeked; above 5 MB `reader_open` refuses them.
|
|
223
|
+
- The plugin's hook sets an environment variable inline (`VAR=… node …`), which needs a POSIX
|
|
224
|
+
shell; Windows is not tested.
|
|
225
|
+
- The server does not know the Claude Code session by itself (it outlives `/clear` and resumes);
|
|
226
|
+
set `READER_OWNER` if you need per-session isolation in a shared `READER_DIR`.
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT — see [LICENSE](LICENSE).
|
package/bin/reader-hook
ADDED
package/bin/reader-mcp
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Claude Code command hook (PostToolUse; PreToolUse — Read guard). Fail-open: always exits 0.
|
|
3
|
+
'use strict';
|
|
4
|
+
let main;
|
|
5
|
+
try { ({ main } = require('../lib/hook-command')); } catch (_) { process.exit(0); }
|
|
6
|
+
main().then(() => process.exit(0), () => process.exit(0));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Reader stdio MCP server. stdout carries the protocol, so all logging goes to stderr.
|
|
3
|
+
'use strict';
|
|
4
|
+
const { createReaderStore } = require('../lib/reader-store');
|
|
5
|
+
const { runStdio } = require('../lib/stdio-mcp');
|
|
6
|
+
const { loadConfig } = require('../lib/config');
|
|
7
|
+
|
|
8
|
+
const cfg = loadConfig();
|
|
9
|
+
const log = (msg) => { try { process.stderr.write(`${msg}\n`); } catch (_) { /* */ } };
|
|
10
|
+
const store = createReaderStore({ dir: cfg.dir, log });
|
|
11
|
+
runStdio({ store, owner: cfg.owner, askFormat: cfg.askFormat }).catch((e) => {
|
|
12
|
+
log(`[smcreader-mcp] fatal: ${e && e.stack || e}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "SMC Reader: replace large Bash/WebFetch outputs with a [READER ctx_...] pointer",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PostToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Bash|WebFetch",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{ "type": "command", "command": "READER_TOOL_PREFIX=mcp__plugin_smcreader_smcreader__ node \"${CLAUDE_PLUGIN_ROOT}/bin/smcreader-hook\"" }
|
|
9
|
+
]
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config — settings from the environment. The hook and the server are separate processes, so
|
|
3
|
+
* the only shared channel between them is the $READER_DIR directory and the same variables.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { DEFAULT_ALLOWLIST, DEFAULT_THRESHOLD } = require('./intercept-hook');
|
|
11
|
+
|
|
12
|
+
const int = (v, dflt) => {
|
|
13
|
+
const n = parseInt(v, 10);
|
|
14
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
15
|
+
};
|
|
16
|
+
const list = (v, dflt) => (v && v.trim() ? v.split(',').map((x) => x.trim()).filter(Boolean) : dflt);
|
|
17
|
+
|
|
18
|
+
function loadConfig(env = process.env) {
|
|
19
|
+
return {
|
|
20
|
+
dir: env.READER_DIR || path.join(env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'smcreader'),
|
|
21
|
+
threshold: int(env.READER_THRESHOLD, DEFAULT_THRESHOLD),
|
|
22
|
+
allowlist: list(env.READER_ALLOWLIST, DEFAULT_ALLOWLIST),
|
|
23
|
+
owner: env.READER_OWNER || undefined,
|
|
24
|
+
askFormat: env.READER_ASK_FORMAT === 'prose' ? 'prose' : 'lines',
|
|
25
|
+
readGuard: int(env.READER_READ_GUARD, 0), // bytes; 0 — Read guard disabled
|
|
26
|
+
logFile: env.READER_LOG || '',
|
|
27
|
+
// Prefix of reader tool names in the pointer hint: depends on how the server is connected
|
|
28
|
+
// (claude mcp add smcreader → mcp__smcreader__, plugin → mcp__plugin_smcreader_smcreader__).
|
|
29
|
+
toolPrefix: env.READER_TOOL_PREFIX && /^[A-Za-z0-9_-]+$/.test(env.READER_TOOL_PREFIX) ? env.READER_TOOL_PREFIX : '',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { loadConfig };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hook-command — body of bin/reader-hook, a Claude Code command hook.
|
|
3
|
+
*
|
|
4
|
+
* PostToolUse: large output from an allowlisted tool goes into the store ($READER_DIR); the
|
|
5
|
+
* response is hookSpecificOutput.updatedToolOutput in the same shape as tool_response, holding
|
|
6
|
+
* the pointer "[READER ctx_…] <tool> output intercepted: N bytes, L lines …" plus head/tail.
|
|
7
|
+
* PreToolUse (only when $READER_READ_GUARD > 0): a full Read of a file above the threshold is
|
|
8
|
+
* denied with working alternatives (lib/read-guard.js).
|
|
9
|
+
*
|
|
10
|
+
* The document owner is the session_id from stdin (a subagent gets `<session>/agent:<id>`).
|
|
11
|
+
* Fail-open: any error → empty response, the output passes through unchanged, exit 0.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const { createReaderStore } = require('./reader-store');
|
|
18
|
+
const { createInterceptHook, extractText } = require('./intercept-hook');
|
|
19
|
+
const { createReadGuardHook } = require('./read-guard');
|
|
20
|
+
const { loadConfig } = require('./config');
|
|
21
|
+
|
|
22
|
+
function makeLog(cfg) {
|
|
23
|
+
if (!cfg.logFile) return () => {};
|
|
24
|
+
return (msg) => {
|
|
25
|
+
try { fs.appendFileSync(cfg.logFile, `${new Date().toISOString()} ${msg}\n`); } catch (_) { /* logging never breaks the hook */ }
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} input JSON from the hook's stdin
|
|
31
|
+
* @param {object} [env]
|
|
32
|
+
* @returns {Promise<object>} hook response ({} means change nothing)
|
|
33
|
+
*/
|
|
34
|
+
async function handleHookInput(input, env = process.env) {
|
|
35
|
+
const cfg = loadConfig(env);
|
|
36
|
+
const log = makeLog(cfg);
|
|
37
|
+
try {
|
|
38
|
+
if (!input || typeof input !== 'object') return {};
|
|
39
|
+
const event = input.hook_event_name;
|
|
40
|
+
|
|
41
|
+
if (event === 'PreToolUse') {
|
|
42
|
+
if (!cfg.readGuard) return {};
|
|
43
|
+
return await createReadGuardHook({ threshold: cfg.readGuard, hasReader: true, log })(input);
|
|
44
|
+
}
|
|
45
|
+
if (event !== undefined && event !== 'PostToolUse') return {};
|
|
46
|
+
|
|
47
|
+
// Cheap prefilter BEFORE creating the store: the hook runs on every tool call, and the
|
|
48
|
+
// store reads its directory on creation (sweep). Small output costs zero file operations.
|
|
49
|
+
if (!cfg.allowlist.includes(input.tool_name)) return {};
|
|
50
|
+
const { text } = extractText(input.tool_response);
|
|
51
|
+
if (!text || text.length < cfg.threshold) return {};
|
|
52
|
+
|
|
53
|
+
const store = createReaderStore({ dir: cfg.dir, log });
|
|
54
|
+
const owner = typeof input.session_id === 'string' && input.session_id ? input.session_id : cfg.owner;
|
|
55
|
+
const P = cfg.toolPrefix;
|
|
56
|
+
const askHint = P ? (id) => `Full output is in reader ${id}: ${P}reader_ask(id, question) to analyze, ${P}reader_peek(id, start_line, end_line) for exact lines, ${P}reader_close(id) when done.` : undefined;
|
|
57
|
+
const hook = createInterceptHook({ store, threshold: cfg.threshold, allowlist: cfg.allowlist, log, owner, askHint });
|
|
58
|
+
return await hook(input);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
log(`[reader-hook] error (passing output through): ${e.message}`);
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read all of stdin, handle it, print the response. Never throws. */
|
|
66
|
+
async function main({ stdin = process.stdin, stdout = process.stdout, env = process.env } = {}) {
|
|
67
|
+
let raw = '';
|
|
68
|
+
try {
|
|
69
|
+
stdin.setEncoding('utf8');
|
|
70
|
+
for await (const chunk of stdin) raw += chunk;
|
|
71
|
+
} catch (_) { return 0; }
|
|
72
|
+
let out = {};
|
|
73
|
+
try { out = await handleHookInput(JSON.parse(raw), env); } catch (_) { out = {}; }
|
|
74
|
+
if (out && Object.keys(out).length) {
|
|
75
|
+
try { stdout.write(JSON.stringify(out)); } catch (_) { /* fail-open */ }
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { handleHookInput, main };
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Public package entry point: core (store, reader model, tools) and adapters.
|
|
3
|
+
const { createReaderStore } = require('./reader-store');
|
|
4
|
+
const readerModel = require('./reader-model');
|
|
5
|
+
const { createReaderTools, READER_TOOL_NAMES } = require('./reader-tools');
|
|
6
|
+
const { createInterceptHook, extractText, buildReplacement, headTail, DEFAULT_ALLOWLIST, DEFAULT_THRESHOLD } = require('./intercept-hook');
|
|
7
|
+
const { createReadGuardHook } = require('./read-guard');
|
|
8
|
+
const { loadConfig } = require('./config');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
createReaderStore, readerModel, createReaderTools, READER_TOOL_NAMES,
|
|
12
|
+
createInterceptHook, extractText, buildReplacement, headTail, DEFAULT_ALLOWLIST, DEFAULT_THRESHOLD,
|
|
13
|
+
createReadGuardHook, loadConfig,
|
|
14
|
+
// adapters are loaded lazily: each pulls in its own SDK
|
|
15
|
+
createReaderMcpServer: (...a) => require('./reader-mcp').createReaderMcpServer(...a),
|
|
16
|
+
buildMcpServer: (...a) => require('./stdio-mcp').buildMcpServer(...a),
|
|
17
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* intercept-hook — PostToolUse hook that intercepts LARGE tool outputs before
|
|
3
|
+
* they reach the main agent's context and replaces them with a "lazy pointer":
|
|
4
|
+
* a compact header + head/tail + reader_id.
|
|
5
|
+
*
|
|
6
|
+
* Haiku is NOT called at interception time (zero latency, zero cost) — the
|
|
7
|
+
* actual reading happens lazily, when the agent calls reader_ask with a
|
|
8
|
+
* question it already has (pull model).
|
|
9
|
+
*
|
|
10
|
+
* Fail-safe: any failure inside the hook → return {} (output passes through as is).
|
|
11
|
+
* Losing compression is cheap; blinding the agent is expensive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_THRESHOLD = 2048; // chars; ~500 tokens
|
|
15
|
+
const HEAD_LINES = 8;
|
|
16
|
+
const TAIL_LINES = 4;
|
|
17
|
+
const POINTER_BODY_CAP = 900; // chars for head+tail in the pointer
|
|
18
|
+
|
|
19
|
+
/** Tools whose outputs are intercepted by default. Deliberately NOT: Read (exact bytes
|
|
20
|
+
* for Edit), Agent/Task (already summaries), mcp__reader__* (recursion). MCP tools —
|
|
21
|
+
* only via an explicit allowlist ($READER_ALLOWLIST): each has its own output shape. */
|
|
22
|
+
const DEFAULT_ALLOWLIST = ['Bash', 'WebFetch'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract text from tool_response + classify its SHAPE. The shape is critical:
|
|
26
|
+
* the engine applies updatedToolOutput only if the replacement structurally matches
|
|
27
|
+
* what the tool returned (otherwise "does not match output shape" → original is kept).
|
|
28
|
+
* shape ∈ {string, stdout, content, output, array, file} — supported for replacement;
|
|
29
|
+
* 'unknown' → skip interception (fail-open).
|
|
30
|
+
*/
|
|
31
|
+
function extractText(toolResponse) {
|
|
32
|
+
if (typeof toolResponse === 'string') return { text: toolResponse, shape: 'string' };
|
|
33
|
+
if (toolResponse == null) return { text: '', shape: 'unknown' };
|
|
34
|
+
if (typeof toolResponse === 'object') {
|
|
35
|
+
if (typeof toolResponse.stdout === 'string') { // Bash: {stdout,stderr,interrupted,isImage,...}
|
|
36
|
+
const err = typeof toolResponse.stderr === 'string' && toolResponse.stderr.trim()
|
|
37
|
+
? `\n--- stderr ---\n${toolResponse.stderr}` : '';
|
|
38
|
+
return { text: toolResponse.stdout + err, shape: 'stdout' };
|
|
39
|
+
}
|
|
40
|
+
if (Array.isArray(toolResponse.content)) { // MCP: {content:[{type:'text',text}]}
|
|
41
|
+
const text = toolResponse.content
|
|
42
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
43
|
+
.map((b) => b.text).join('\n');
|
|
44
|
+
if (text) return { text, shape: 'content' };
|
|
45
|
+
}
|
|
46
|
+
if (typeof toolResponse.output === 'string') return { text: toolResponse.output, shape: 'output' };
|
|
47
|
+
// Read: {type:'text', file:{filePath, content, numLines, startLine, totalLines}}.
|
|
48
|
+
// Without this shape, interception silently failed open on EVERY Read, and large
|
|
49
|
+
// file contents went into the context in full.
|
|
50
|
+
if (toolResponse.file && typeof toolResponse.file.content === 'string') {
|
|
51
|
+
return { text: toolResponse.file.content, shape: 'file' };
|
|
52
|
+
}
|
|
53
|
+
// MCP output as a bare array of content blocks: [{type:'text',text}, ...]
|
|
54
|
+
// (some MCP servers return this; Object.keys of the array = ['0','1',...]).
|
|
55
|
+
if (Array.isArray(toolResponse)) {
|
|
56
|
+
const text = toolResponse
|
|
57
|
+
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
|
58
|
+
.map((b) => b.text).join('\n');
|
|
59
|
+
if (text) return { text, shape: 'array' };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { text: '', shape: 'unknown' }; // can't mirror this shape — don't intercept
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the replacement in the SAME shape the tool returned, substituting the pointer.
|
|
67
|
+
* Other fields (interrupted, isImage, ...) are preserved; stderr is cleared.
|
|
68
|
+
*/
|
|
69
|
+
function buildReplacement(toolResponse, shape, pointer) {
|
|
70
|
+
switch (shape) {
|
|
71
|
+
case 'string': return pointer;
|
|
72
|
+
case 'stdout': return { ...toolResponse, stdout: pointer, stderr: '' };
|
|
73
|
+
case 'content': return { ...toolResponse, content: [{ type: 'text', text: pointer }] };
|
|
74
|
+
case 'output': return { ...toolResponse, output: pointer };
|
|
75
|
+
// Other file fields (filePath/numLines/…) are kept: the engine checks the shape,
|
|
76
|
+
// and the agent benefits from seeing WHAT was moved to the reader.
|
|
77
|
+
case 'file': return { ...toolResponse, file: { ...toolResponse.file, content: pointer } };
|
|
78
|
+
case 'array': return [{ type: 'text', text: pointer }]; // array of content blocks — mirror as an array
|
|
79
|
+
default: return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function headTail(text) {
|
|
84
|
+
const lines = text.split('\n');
|
|
85
|
+
if (lines.length <= HEAD_LINES + TAIL_LINES) {
|
|
86
|
+
// Few lines, but the text may be huge (minified JSON, single-line log):
|
|
87
|
+
// use character head+tail so the tail (exit code, last error) is not lost.
|
|
88
|
+
if (text.length <= POINTER_BODY_CAP) return text;
|
|
89
|
+
const headChars = Math.floor(POINTER_BODY_CAP * 0.7);
|
|
90
|
+
const tailChars = POINTER_BODY_CAP - headChars;
|
|
91
|
+
return `${text.slice(0, headChars)}\n... (${text.length - headChars - tailChars} chars omitted — in the reader) ...\n${text.slice(-tailChars)}`;
|
|
92
|
+
}
|
|
93
|
+
const head = lines.slice(0, HEAD_LINES).join('\n');
|
|
94
|
+
const tail = lines.slice(-TAIL_LINES).join('\n');
|
|
95
|
+
let body = `${head}\n... (${lines.length - HEAD_LINES - TAIL_LINES} lines omitted — in the reader) ...\n${tail}`;
|
|
96
|
+
if (body.length > POINTER_BODY_CAP) body = body.slice(0, POINTER_BODY_CAP) + '…';
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function describeInput(toolName, toolInput) {
|
|
101
|
+
try {
|
|
102
|
+
if (toolName === 'Bash' && toolInput?.command) return toolInput.command.slice(0, 200);
|
|
103
|
+
if (toolName === 'WebFetch' && toolInput?.url) return toolInput.url.slice(0, 200);
|
|
104
|
+
if (toolInput?.command) return String(toolInput.command).slice(0, 200);
|
|
105
|
+
return JSON.stringify(toolInput).slice(0, 200);
|
|
106
|
+
} catch (_) { return '(unprintable input)'; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Factory for the PostToolUse callback.
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {object} opts.store ReaderStore
|
|
113
|
+
* @param {number} [opts.threshold]
|
|
114
|
+
* @param {string[]} [opts.allowlist]
|
|
115
|
+
* @param {Function} [opts.log]
|
|
116
|
+
*/
|
|
117
|
+
// owner — id of the session that owns intercepted documents (shared store of the main agent).
|
|
118
|
+
// taskContext() — the agent's task, used in the reader briefing.
|
|
119
|
+
// askHint(id) — last line of the pointer: how to work with the reader.
|
|
120
|
+
function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist = DEFAULT_ALLOWLIST, log = console.log, owner,
|
|
121
|
+
taskContext = null, askHint = null } = {}) {
|
|
122
|
+
if (!store) throw new Error('createInterceptHook requires {store}');
|
|
123
|
+
const allowed = new Set(allowlist);
|
|
124
|
+
|
|
125
|
+
return async function postToolUseIntercept(input /*, toolUseID, { signal } */) {
|
|
126
|
+
try {
|
|
127
|
+
const toolName = input?.tool_name;
|
|
128
|
+
if (!toolName || !allowed.has(toolName)) return {};
|
|
129
|
+
|
|
130
|
+
const { text, shape } = extractText(input.tool_response);
|
|
131
|
+
if (!text || text.length < threshold) return {};
|
|
132
|
+
if (shape === 'unknown') return {}; // shape can't be mirrored → skip (the engine would reject the replacement)
|
|
133
|
+
|
|
134
|
+
const inputDesc = describeInput(toolName, input.tool_input);
|
|
135
|
+
let task = null;
|
|
136
|
+
try { task = typeof taskContext === 'function' ? taskContext() : null; } catch (_) { task = null; }
|
|
137
|
+
const why = input.tool_input && typeof input.tool_input.description === 'string' ? input.tool_input.description.trim() : '';
|
|
138
|
+
// The task and the call description give the reader a goal: without them the briefing
|
|
139
|
+
// is a generic "look for errors" template, and the reader has nothing to judge
|
|
140
|
+
// "what matters here" against.
|
|
141
|
+
const briefing = `Intercepted output of ${toolName} (${inputDesc}).`
|
|
142
|
+
+ (task ? ` The agent's task: ${String(task).slice(0, 600)}.` : '')
|
|
143
|
+
+ (why ? ` Its own description of this call: ${why.slice(0, 300)}.` : '')
|
|
144
|
+
+ ` The main agent saw only a head/tail pointer and will ask about specifics. Watch for: errors, warnings, anomalies, unexpected values${task ? ', and whatever matters for the task' : ''}.`;
|
|
145
|
+
// The hook also fires for subagent tools (Agent/Task) — the SDK sets agent_id for them.
|
|
146
|
+
// Under the session owner, their outputs would consume the session's reader limit and
|
|
147
|
+
// evict its documents. A subagent gets its own child owner: its own limit and LRU;
|
|
148
|
+
// the session can still close them.
|
|
149
|
+
const docOwner = input.agent_id
|
|
150
|
+
? `${owner !== undefined ? `${owner}/` : ''}agent:${input.agent_id}`
|
|
151
|
+
: owner;
|
|
152
|
+
const r = store.open({ text }, briefing, {
|
|
153
|
+
tool_name: toolName,
|
|
154
|
+
tool_input: inputDesc,
|
|
155
|
+
...(docOwner !== undefined ? { owner: docOwner } : {}),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Eviction MUST be visible right here: the pointer is the only thing the agent
|
|
159
|
+
// reads. It doesn't see the store's server log, and a silently lost document
|
|
160
|
+
// looks to it like "the reader lied".
|
|
161
|
+
const cap = store.limits && store.limits.MAX_OPEN;
|
|
162
|
+
const evictedNote = r.evicted
|
|
163
|
+
? `\n(evicted ${r.evicted}${cap ? ` — ${cap}/${cap} open` : ' — reader limit reached'}; close with mcp__reader__reader_close)`
|
|
164
|
+
: '';
|
|
165
|
+
const pointer =
|
|
166
|
+
`[READER ${r.id}] ${toolName} output intercepted: ${r.size} bytes, ${r.lines} lines (>${threshold} chars).${evictedNote}\n` +
|
|
167
|
+
`${headTail(text)}\n` +
|
|
168
|
+
(typeof askHint === 'function' ? askHint(r.id)
|
|
169
|
+
: `Full output is in reader ${r.id}: mcp__reader__reader_ask(id, question) to analyze, reader_peek(id, start_line, end_line) for exact lines, reader_close(id) when done.`);
|
|
170
|
+
|
|
171
|
+
// The replacement MUST match the tool's shape — otherwise the engine silently keeps the original.
|
|
172
|
+
const replacement = buildReplacement(input.tool_response, shape, pointer);
|
|
173
|
+
if (replacement === null) return {}; // safety net
|
|
174
|
+
|
|
175
|
+
log(`[reader-hook] intercepted ${toolName} output: ${text.length} chars (shape=${shape}) -> ${r.id}`);
|
|
176
|
+
return {
|
|
177
|
+
hookSpecificOutput: {
|
|
178
|
+
hookEventName: 'PostToolUse',
|
|
179
|
+
updatedToolOutput: replacement,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
} catch (e) {
|
|
183
|
+
// fail-open: never break output delivery
|
|
184
|
+
try { log(`[reader-hook] error (passing output through): ${e.message}`); } catch (_) {}
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { createInterceptHook, extractText, buildReplacement, headTail, DEFAULT_ALLOWLIST, DEFAULT_THRESHOLD };
|