smcreader 0.0.0-stage → 0.1.2
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 +232 -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.2",
|
|
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,233 @@
|
|
|
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 the version must be on npm *before* the bump reaches that branch.
|
|
205
|
+
Bump the version in `package.json`, `.claude-plugin/plugin.json` and `.mcp.json` (`npm test` checks
|
|
206
|
+
they agree), commit, push **only the tag** `v<version>`; the `Release` workflow publishes to npm
|
|
207
|
+
(trusted publishing, with provenance). Then push the branch.
|
|
208
|
+
|
|
209
|
+
The e2e verdict comes from `--output-format stream-json`, i.e. from what the model actually
|
|
210
|
+
received, not from what it said: (1) the Bash `tool_result` contains `[READER ctx_` and not
|
|
211
|
+
`1500` (from `seq 1 3000`); (2) a reader tool call with that id returned `2999`. One run takes
|
|
212
|
+
~10–20 s and ~$0.03 on Haiku. CI runs only `npm test`: e2e needs a logged-in Claude Code.
|
|
213
|
+
|
|
214
|
+
</details>
|
|
215
|
+
|
|
216
|
+
## Limitations
|
|
217
|
+
|
|
218
|
+
- Needs a Claude Code version that applies `hookSpecificOutput.updatedToolOutput` from command
|
|
219
|
+
hooks (verified on 2.1.282). Older versions call the hook but keep the original output.
|
|
220
|
+
- Supported output shapes: string, `{stdout,…}` (Bash), `{content:[…]}` (MCP), `{output}`,
|
|
221
|
+
`{file:{content}}` (Read), arrays of content blocks. Unknown shape → passed through untouched.
|
|
222
|
+
- `Read` is not intercepted by default — `Edit` needs exact bytes. Use the read guard instead.
|
|
223
|
+
- `reader_ask` needs Claude Code credentials and a `claude` binary (Agent SDK, model Haiku);
|
|
224
|
+
`reader_open` / `reader_peek` / `reader_list` work without them. Documents above 700 KB can
|
|
225
|
+
only be peeked; above 5 MB `reader_open` refuses them.
|
|
226
|
+
- The plugin's hook sets an environment variable inline (`VAR=… node …`), which needs a POSIX
|
|
227
|
+
shell; Windows is not tested.
|
|
228
|
+
- The server does not know the Claude Code session by itself (it outlives `/clear` and resumes);
|
|
229
|
+
set `READER_OWNER` if you need per-session isolation in a shared `READER_DIR`.
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
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
|
+
};
|