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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReaderStore — document storage for the reader MCP (pull model of context).
|
|
3
|
+
*
|
|
4
|
+
* Keeps large texts OUTSIDE the main agent's context: the raw content is spilled
|
|
5
|
+
* to disk ($READER_DIR/ctx_<8hex>.txt + .meta.json); the Map holds only
|
|
6
|
+
* metadata + the id of the haiku dialog session. Document text is never kept
|
|
7
|
+
* in process memory — peek() reads from disk.
|
|
8
|
+
*
|
|
9
|
+
* No LLM and no SDK — 100% unit-testable. The Haiku transport lives in reader-model.js.
|
|
10
|
+
*
|
|
11
|
+
* CROSS-PROCESS: the directory is shared by several processes (the hook command
|
|
12
|
+
* writes, the MCP server reads). Disk is the source of truth: list, the MAX_OPEN
|
|
13
|
+
* limit and eviction count documents by .meta.json files in the directory, not by
|
|
14
|
+
* this process's Map; last-access time is the files' mtime (touch() sets it on every
|
|
15
|
+
* get), so LRU and sweeping see accesses from any process. The Map holds only what
|
|
16
|
+
* does not go to disk: the haiku dialog id, the question counter, the ask queue.
|
|
17
|
+
*
|
|
18
|
+
* Document owner: a single store may serve several sessions, and without ownership
|
|
19
|
+
* a large output in one session would evict another session's documents, and
|
|
20
|
+
* list/close would be shared. meta.owner (session id) is set at open: the MAX_OPEN
|
|
21
|
+
* limit and eviction apply per owner, list({owner}) returns only that owner's
|
|
22
|
+
* documents, close(id, {owner}) refuses to close someone else's. Reading by id
|
|
23
|
+
* (get/peek/ask) is not restricted: only whoever was given the id knows it (e.g. a
|
|
24
|
+
* handoff successor). No owner passed — legacy behavior (a store used by a single
|
|
25
|
+
* agent has nothing to share).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const OPEN_MAX = 5 * 1024 * 1024; // >5MB — refused: grep the raw file directly
|
|
33
|
+
const ASK_MAX = 700 * 1024; // >700KB — open works, but ask is unavailable (haiku window)
|
|
34
|
+
const MAX_OPEN = 10; // max open readers
|
|
35
|
+
const MAX_ASKS = 20; // max questions per reader
|
|
36
|
+
const SWEEP_AGE_MS = 48 * 60 * 60 * 1000; // on-disk TTL of raw files
|
|
37
|
+
const PREVIEW_HEAD = 20;
|
|
38
|
+
const PREVIEW_TAIL = 10;
|
|
39
|
+
const PREVIEW_CHAR_CAP = 1500;
|
|
40
|
+
const PEEK_MAX_LINES = 400;
|
|
41
|
+
|
|
42
|
+
function createReaderStore({ dir, now = Date.now, log = console.log } = {}) {
|
|
43
|
+
if (!dir) throw new Error('createReaderStore requires {dir}');
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
/** @type {Map<string, object>} id -> entry */
|
|
47
|
+
const entries = new Map();
|
|
48
|
+
|
|
49
|
+
// Access counter — the LRU order. A counter rather than now(): an open and an
|
|
50
|
+
// access within the same millisecond would get the same stamp, and the victim
|
|
51
|
+
// would be chosen arbitrarily. useSeq grows strictly monotonically, so "never
|
|
52
|
+
// accessed" naturally degenerates into "by open time".
|
|
53
|
+
let useSeq = 0;
|
|
54
|
+
function touch(entry) {
|
|
55
|
+
entry.useSeq = ++useSeq;
|
|
56
|
+
entry.lastUsedAt = now();
|
|
57
|
+
// Make the access visible to other processes and to sweeping: mtime of both files = access time.
|
|
58
|
+
const t = entry.lastUsedAt / 1000;
|
|
59
|
+
for (const p of [txtPath(entry.id), metaPath(entry.id)]) {
|
|
60
|
+
try { fs.utimesSync(p, t, t); } catch (_) { /* file already gone — get will handle it */ }
|
|
61
|
+
}
|
|
62
|
+
return entry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ID_RE = /^ctx_[0-9a-f]{8}$/;
|
|
66
|
+
const validId = (id) => typeof id === 'string' && ID_RE.test(id);
|
|
67
|
+
const txtPath = (id) => path.join(dir, `${id}.txt`);
|
|
68
|
+
const metaPath = (id) => path.join(dir, `${id}.meta.json`);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* All open documents in the DIRECTORY (not only this process's). Own entries come from
|
|
72
|
+
* the Map with their asks/useSeq; foreign ones (opened by another process) come from
|
|
73
|
+
* disk: lastUsedAt = meta mtime, useSeq 0. Corrupt meta or missing raw file — no document.
|
|
74
|
+
*/
|
|
75
|
+
function allOpen() {
|
|
76
|
+
let names;
|
|
77
|
+
try { names = fs.readdirSync(dir); } catch (_) { return []; }
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const name of names) {
|
|
80
|
+
const m = /^(ctx_[0-9a-f]{8})\.meta\.json$/.exec(name);
|
|
81
|
+
if (!m) continue;
|
|
82
|
+
const id = m[1];
|
|
83
|
+
const hit = entries.get(id);
|
|
84
|
+
if (hit && !hit.closed) {
|
|
85
|
+
if (fs.existsSync(txtPath(id))) out.push(hit); else entries.delete(id);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const meta = JSON.parse(fs.readFileSync(metaPath(id), 'utf8'));
|
|
90
|
+
if (!fs.existsSync(txtPath(id))) continue;
|
|
91
|
+
out.push({ id, meta, asks: 0, useSeq: 0, lastUsedAt: fs.statSync(metaPath(id)).mtimeMs, closed: false });
|
|
92
|
+
} catch (_) { /* corrupt meta / race with close */ }
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** a has been idle longer than b: earlier last access; within the same millisecond — by counter. */
|
|
98
|
+
const lastUse = (e) => e.lastUsedAt || e.meta.createdAt || 0;
|
|
99
|
+
const older = (a, b) => (lastUse(a) !== lastUse(b) ? lastUse(a) < lastUse(b) : a.useSeq <= b.useSeq);
|
|
100
|
+
|
|
101
|
+
/** Delete ctx files older than SWEEP_AGE_MS (called on store creation and in open). */
|
|
102
|
+
function sweep() {
|
|
103
|
+
let removed = 0;
|
|
104
|
+
let names;
|
|
105
|
+
try { names = fs.readdirSync(dir); } catch (_) { return 0; }
|
|
106
|
+
for (const name of names) {
|
|
107
|
+
if (!/^ctx_[0-9a-f]{8}\.(txt|meta\.json)$/.test(name)) continue;
|
|
108
|
+
const p = path.join(dir, name);
|
|
109
|
+
try {
|
|
110
|
+
if (now() - fs.statSync(p).mtimeMs > SWEEP_AGE_MS) {
|
|
111
|
+
fs.unlinkSync(p);
|
|
112
|
+
removed++;
|
|
113
|
+
entries.delete(name.replace(/\.(txt|meta\.json)$/, ''));
|
|
114
|
+
}
|
|
115
|
+
} catch (_) { /* race with close — harmless */ }
|
|
116
|
+
}
|
|
117
|
+
return removed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function makePreview(text) {
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
const numbered = (arr, offset) => arr.map((l, i) => `${offset + i + 1}\t${l}`);
|
|
123
|
+
let parts;
|
|
124
|
+
if (lines.length <= PREVIEW_HEAD + PREVIEW_TAIL) {
|
|
125
|
+
parts = numbered(lines, 0);
|
|
126
|
+
} else {
|
|
127
|
+
parts = [
|
|
128
|
+
...numbered(lines.slice(0, PREVIEW_HEAD), 0),
|
|
129
|
+
`... (${lines.length - PREVIEW_HEAD - PREVIEW_TAIL} lines omitted) ...`,
|
|
130
|
+
...numbered(lines.slice(-PREVIEW_TAIL), lines.length - PREVIEW_TAIL),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
let preview = parts.join('\n');
|
|
134
|
+
if (preview.length > PREVIEW_CHAR_CAP) preview = preview.slice(0, PREVIEW_CHAR_CAP) + '…';
|
|
135
|
+
return preview;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Open a document. source: {path} XOR {text}; briefing is required.
|
|
140
|
+
* @returns {{id, size, lines, preview, tooLargeForAsk}}
|
|
141
|
+
*/
|
|
142
|
+
function open(source, briefing, extraMeta = {}) {
|
|
143
|
+
const hasPath = typeof source?.path === 'string';
|
|
144
|
+
const hasText = typeof source?.text === 'string';
|
|
145
|
+
if (hasPath === hasText) throw new Error('provide exactly one of {path} or {text}');
|
|
146
|
+
if (!briefing || typeof briefing !== 'string') throw new Error('briefing is required');
|
|
147
|
+
|
|
148
|
+
sweep();
|
|
149
|
+
// Overflow is NOT a refusal. Refusing here would cost more than evicting: the
|
|
150
|
+
// interception hook swallows the exception fail-open, so the whole large output
|
|
151
|
+
// would go into the context window — context hygiene would silently switch off
|
|
152
|
+
// at the 11th document and never come back (the store outlives a turn). Evict
|
|
153
|
+
// the least recently used one, and LOUDLY: the victim id goes both to the log and
|
|
154
|
+
// to the returned field so the caller can show it to the agent. Silent eviction
|
|
155
|
+
// would be the same problem.
|
|
156
|
+
// while, not if: other processes (the hook command) also write documents into the
|
|
157
|
+
// directory, and a single eviction could leave the store over the limit forever.
|
|
158
|
+
// The normal path is exactly one iteration.
|
|
159
|
+
let evicted = null;
|
|
160
|
+
const owner = extraMeta.owner ?? null;
|
|
161
|
+
let open_ = allOpen().filter((e) => (e.meta.owner ?? null) === owner);
|
|
162
|
+
while (open_.length >= MAX_OPEN) {
|
|
163
|
+
const victim = open_.reduce((a, b) => (older(a, b) ? a : b));
|
|
164
|
+
const idleMs = Math.max(0, now() - (victim.lastUsedAt || victim.meta.createdAt));
|
|
165
|
+
close(victim.id); // same path as a manual close: raw file off disk, entry out of the map
|
|
166
|
+
evicted = victim.id;
|
|
167
|
+
try {
|
|
168
|
+
log(`[reader-store] evicted ${victim.id} (LRU, ${MAX_OPEN}/${MAX_OPEN} open${owner ? ` for ${owner}` : ''}, idle ${Math.round(idleMs / 1000)}s, asks ${victim.asks})`);
|
|
169
|
+
} catch (_) { /* logging must not break open */ }
|
|
170
|
+
open_ = open_.filter((e) => e !== victim);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let text;
|
|
174
|
+
if (hasPath) {
|
|
175
|
+
const st = fs.statSync(source.path); // ENOENT/EACCES propagate as is
|
|
176
|
+
if (st.isDirectory()) throw new Error(`${source.path} is a directory`);
|
|
177
|
+
if (st.size > OPEN_MAX) throw new Error(`file is ${st.size} bytes (limit ${OPEN_MAX}). Grep it directly or open an excerpt as {text}`);
|
|
178
|
+
text = fs.readFileSync(source.path, 'utf8');
|
|
179
|
+
} else {
|
|
180
|
+
if (Buffer.byteLength(source.text, 'utf8') > OPEN_MAX) throw new Error(`text is too large (limit ${OPEN_MAX} bytes)`);
|
|
181
|
+
text = source.text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const id = 'ctx_' + crypto.randomBytes(4).toString('hex');
|
|
185
|
+
const size = Buffer.byteLength(text, 'utf8');
|
|
186
|
+
const meta = {
|
|
187
|
+
id,
|
|
188
|
+
size,
|
|
189
|
+
lines: text.split('\n').length,
|
|
190
|
+
createdAt: now(),
|
|
191
|
+
briefing,
|
|
192
|
+
source: hasPath ? { path: source.path } : { text: 'inline' },
|
|
193
|
+
tooLargeForAsk: size > ASK_MAX,
|
|
194
|
+
...extraMeta, // e.g. tool_name, tool_input, owner
|
|
195
|
+
};
|
|
196
|
+
fs.writeFileSync(txtPath(id), text); // raw bytes as is; line numbering happens at ask/peek
|
|
197
|
+
fs.writeFileSync(metaPath(id), JSON.stringify(meta, null, 2));
|
|
198
|
+
|
|
199
|
+
const entry = { id, rawPath: txtPath(id), meta, haikuSid: null, asks: 0, lock: Promise.resolve(), closed: false };
|
|
200
|
+
touch(entry);
|
|
201
|
+
entries.set(id, entry);
|
|
202
|
+
// evicted is an added field, not a replacement: existing callers take id/size/lines/
|
|
203
|
+
// preview/tooLargeForAsk and notice nothing (null when nothing had to be evicted).
|
|
204
|
+
return { id, size, lines: meta.lines, preview: makePreview(text), tooLargeForAsk: meta.tooLargeForAsk, evicted };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Find an entry; on a miss — lazy reload from disk (a restart loses only the haiku dialog).
|
|
209
|
+
* Any access counts as use: ask, peek and readRaw all go through get, so the LRU stamp
|
|
210
|
+
* is set here, in one place.
|
|
211
|
+
*/
|
|
212
|
+
function get(id) {
|
|
213
|
+
if (!validId(id)) return null;
|
|
214
|
+
const hit = entries.get(id);
|
|
215
|
+
if (hit && !hit.closed) {
|
|
216
|
+
if (fs.existsSync(txtPath(id))) return touch(hit);
|
|
217
|
+
entries.delete(id); // closed by another process (or swept) — don't trust the in-memory entry
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const meta = JSON.parse(fs.readFileSync(metaPath(id), 'utf8'));
|
|
222
|
+
if (!fs.existsSync(txtPath(id))) return null;
|
|
223
|
+
const entry = {
|
|
224
|
+
id, rawPath: txtPath(id), meta,
|
|
225
|
+
haikuSid: null, asks: 0, lock: Promise.resolve(), closed: false,
|
|
226
|
+
rehydrated: true, // the first ask will report that the dialog was reset by a restart
|
|
227
|
+
};
|
|
228
|
+
touch(entry);
|
|
229
|
+
entries.set(id, entry);
|
|
230
|
+
return entry;
|
|
231
|
+
} catch (_) {
|
|
232
|
+
return null; // meta missing or corrupt — treat as nonexistent
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Verbatim numbered slice from DISK. 1-based, inclusive; clamped to bounds. */
|
|
237
|
+
function peek(id, startLine, endLine) {
|
|
238
|
+
const entry = get(id);
|
|
239
|
+
if (!entry) throw new Error(`no reader '${id}'`);
|
|
240
|
+
const lines = fs.readFileSync(entry.rawPath, 'utf8').split('\n');
|
|
241
|
+
const start = Math.max(1, Math.min(startLine, lines.length));
|
|
242
|
+
const end = Math.max(1, Math.min(endLine, lines.length));
|
|
243
|
+
if (start > end) throw new Error(`start_line ${startLine} > end_line ${endLine}`);
|
|
244
|
+
if (end - start + 1 > PEEK_MAX_LINES) {
|
|
245
|
+
throw new Error(`span is ${end - start + 1} lines (max ${PEEK_MAX_LINES}). Narrow the range`);
|
|
246
|
+
}
|
|
247
|
+
return lines.slice(start - 1, end).map((l, i) => `${start + i}\t${l}`).join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Read the whole raw file (for the haiku reader on the first ask). */
|
|
251
|
+
function readRaw(id) {
|
|
252
|
+
const entry = get(id);
|
|
253
|
+
if (!entry) throw new Error(`no reader '${id}'`);
|
|
254
|
+
return fs.readFileSync(entry.rawPath, 'utf8');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function ownerOf(id) {
|
|
258
|
+
if (!validId(id)) return null;
|
|
259
|
+
const e = entries.get(id);
|
|
260
|
+
if (e) return e.meta.owner ?? null;
|
|
261
|
+
try { return JSON.parse(fs.readFileSync(metaPath(id), 'utf8')).owner ?? null; } catch (_) { return null; }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Close and delete the files. Idempotent. {owner} — another owner's document is not closed (throws).
|
|
266
|
+
* Documents of the session's subagents (owner `<owner>/agent:<id>`, intercept-hook.js) count as
|
|
267
|
+
* the session's own: a session and its subagents share one reader MCP server, with the session owner.
|
|
268
|
+
* The owner is read from disk — the document may have been opened by another process.
|
|
269
|
+
*/
|
|
270
|
+
function close(id, { owner } = {}) {
|
|
271
|
+
// id goes into the unlink path: '../x' would delete an unrelated *.txt file outside the directory
|
|
272
|
+
if (!validId(id)) return false;
|
|
273
|
+
if (owner !== undefined) {
|
|
274
|
+
const o = ownerOf(id);
|
|
275
|
+
if (o && o !== owner && !(owner && o.startsWith(`${owner}/`))) {
|
|
276
|
+
throw new Error(`reader '${id}' belongs to another session — it is not yours to close`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const entry = entries.get(id);
|
|
280
|
+
if (entry) { entry.closed = true; entries.delete(id); }
|
|
281
|
+
let existed = false;
|
|
282
|
+
for (const p of [txtPath(id), metaPath(id)]) {
|
|
283
|
+
try { fs.unlinkSync(p); existed = true; } catch (_) { /* already gone */ }
|
|
284
|
+
}
|
|
285
|
+
return existed || !!entry;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** {owner} — only this owner's documents; no argument — all of them. */
|
|
289
|
+
function list({ owner } = {}) {
|
|
290
|
+
return allOpen()
|
|
291
|
+
.filter((e) => owner === undefined || (e.meta.owner ?? null) === owner)
|
|
292
|
+
.sort((a, b) => (a.meta.createdAt - b.meta.createdAt) || (a.useSeq - b.useSeq))
|
|
293
|
+
.map((e) => ({
|
|
294
|
+
id: e.id, size: e.meta.size, lines: e.meta.lines,
|
|
295
|
+
briefing: e.meta.briefing, asks: e.asks,
|
|
296
|
+
opened: new Date(e.meta.createdAt).toISOString(),
|
|
297
|
+
tooLargeForAsk: !!e.meta.tooLargeForAsk,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Gate for ask — a pure function, tested directly. */
|
|
302
|
+
function canAsk(entry) {
|
|
303
|
+
if (entry.meta.tooLargeForAsk) {
|
|
304
|
+
return { ok: false, reason: `document is ${entry.meta.size} bytes (ask limit ${ASK_MAX}). Use reader_peek for verbatim ranges, Grep the source, or open a smaller excerpt with {text}` };
|
|
305
|
+
}
|
|
306
|
+
if (entry.asks >= MAX_ASKS) {
|
|
307
|
+
return { ok: false, reason: `ask limit reached (${MAX_ASKS}) for this reader. Close it and reopen with a fresh briefing` };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
sweep(); // sweep on creation (server restart)
|
|
313
|
+
return { open, get, peek, readRaw, close, list, canAsk, sweep, limits: { OPEN_MAX, ASK_MAX, MAX_OPEN, MAX_ASKS, PEEK_MAX_LINES } };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { createReaderStore };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reader-tools — transport-agnostic reader tools: reader_open/ask/peek/close/list
|
|
3
|
+
* as plain {name, description, schema, handler}.
|
|
4
|
+
*
|
|
5
|
+
* schema is a zod "raw shape" (an object of fields), understood the same way by both adapters:
|
|
6
|
+
* stdio MCP (@modelcontextprotocol/sdk, lib/stdio-mcp.js) and the in-process Agent SDK MCP
|
|
7
|
+
* (createSdkMcpServer, lib/reader-mcp.js). handler(args) → {content:[{type:'text',text}]}.
|
|
8
|
+
* Errors are not thrown: the agent gets "Error: …" as text, like any tool output.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const { z } = require('zod');
|
|
14
|
+
const readerModel = require('./reader-model');
|
|
15
|
+
|
|
16
|
+
/** Tool names in Claude Code when the server is named "reader" — for allowedTools. */
|
|
17
|
+
const READER_TOOL_NAMES = [
|
|
18
|
+
'mcp__reader__reader_open',
|
|
19
|
+
'mcp__reader__reader_ask',
|
|
20
|
+
'mcp__reader__reader_peek',
|
|
21
|
+
'mcp__reader__reader_close',
|
|
22
|
+
'mcp__reader__reader_list',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const readerText = (text) => ({ content: [{ type: 'text', text }] });
|
|
26
|
+
const readerErr = (e) => readerText(`Error: ${e.message}`);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* reader_open description. The extra "also NOT for this" clause is a parameter: it depends
|
|
30
|
+
* on the agent's environment (some have a separate transcript reader, some don't).
|
|
31
|
+
*/
|
|
32
|
+
const openDescription = (alsoNotFor) =>
|
|
33
|
+
'Open a document in a persistent haiku-powered reader WITHOUT loading it into your context. ' +
|
|
34
|
+
'Use for: large files (logs, long sources, datasets), long command outputs (pass as {text}), ' +
|
|
35
|
+
'anything you will question repeatedly. Do NOT use for small files you are about to Edit ' +
|
|
36
|
+
`(use Read — you need exact bytes)${alsoNotFor ? ` or ${alsoNotFor}` : ''}. ` +
|
|
37
|
+
'Returns id + stats + head/tail preview; then interrogate with reader_ask, verify exact values with reader_peek.';
|
|
38
|
+
|
|
39
|
+
const evictedNote = (store, r) => {
|
|
40
|
+
// The wording is SHARED with the interception pointer (intercept-hook.js): the agent
|
|
41
|
+
// should not have to care which path opened the document.
|
|
42
|
+
const cap = store.limits && store.limits.MAX_OPEN;
|
|
43
|
+
return r.evicted
|
|
44
|
+
? `\n(evicted ${r.evicted}${cap ? ` — ${cap}/${cap} open` : ' — reader limit reached'}; close with mcp__reader__reader_close)`
|
|
45
|
+
: '';
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} opts
|
|
50
|
+
* @param {object} opts.store ReaderStore (lib/reader-store.js)
|
|
51
|
+
* @param {object} [opts.haiku] question transport {ask} (injected in tests)
|
|
52
|
+
* @param {string} [opts.openAlsoNotFor] tail of the "Do NOT use for …" clause in reader_open
|
|
53
|
+
* @param {string} [opts.owner] document owner (session id); unset — all documents are visible
|
|
54
|
+
* @param {'prose'|'lines'} [opts.askFormat] default reader_ask format
|
|
55
|
+
* @returns {Array<{name, description, schema, handler}>}
|
|
56
|
+
*/
|
|
57
|
+
function createReaderTools({ store, haiku = readerModel, openAlsoNotFor = '', owner, askFormat = 'prose' } = {}) {
|
|
58
|
+
if (!store) throw new Error('createReaderTools requires {store}');
|
|
59
|
+
const own = owner !== undefined ? { owner } : {};
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
name: 'reader_open',
|
|
63
|
+
description: openDescription(openAlsoNotFor),
|
|
64
|
+
schema: {
|
|
65
|
+
path: z.string().optional().describe('Absolute file path. Exactly one of path|text.'),
|
|
66
|
+
text: z.string().optional().describe('Inline text to open (e.g. a big command output you want kept out of your context).'),
|
|
67
|
+
briefing: z.string().describe('Why you are opening this and what matters — the reader prioritizes by it. Name what to watch for (errors? a function? specific numbers?).'),
|
|
68
|
+
},
|
|
69
|
+
handler: async (args) => {
|
|
70
|
+
try {
|
|
71
|
+
const r = store.open(args.path !== undefined ? { path: args.path } : { text: args.text }, args.briefing, own);
|
|
72
|
+
const warn = r.tooLargeForAsk ? '\nNOTE: too large for reader_ask — use reader_peek/Grep.' : '';
|
|
73
|
+
return readerText(`Opened ${r.id} (${r.lines} lines, ${r.size} bytes).${warn}${evictedNote(store, r)}\nPreview:\n${r.preview}`);
|
|
74
|
+
} catch (e) { return readerErr(e); }
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'reader_ask',
|
|
79
|
+
description: askFormat === 'lines'
|
|
80
|
+
? 'Ask the haiku reader a question about an open document. The reader remembers the dialog: follow-up questions are cheap. '
|
|
81
|
+
+ 'Default answer (format "lines"): a short direct answer + the document lines that answer it, VERBATIM from disk (exact bytes — fit for Edit), ~6–9 s. '
|
|
82
|
+
+ 'Several questions to one document at once run in parallel. Errors/anomalies you did not ask about are NOT listed in lines — ask for them explicitly '
|
|
83
|
+
+ '("what looks suspicious here?") or use format "prose" — a connected explanation with line citations and a NOTICED section (slower).'
|
|
84
|
+
: 'Ask the haiku reader a question about an open document. Answers cite line numbers and ALWAYS end with a NOTICED section (errors/anomalies/secrets you did not ask about — read it). The reader remembers the dialog: follow-up questions are cheap. Confirm critical exact values via reader_peek.',
|
|
85
|
+
schema: {
|
|
86
|
+
id: z.string().describe('Reader id from reader_open (ctx_...).'),
|
|
87
|
+
question: z.string().describe('One specific question per ask.'),
|
|
88
|
+
// The format parameter exists only where lines is the default: the prose description and schema stay unchanged.
|
|
89
|
+
...(askFormat === 'lines' ? { format: z.enum(['lines', 'prose']).optional().describe('Answer format (default "lines"): "lines" — relevant document lines verbatim + short note; "prose" — explanation with citations.') } : {}),
|
|
90
|
+
},
|
|
91
|
+
handler: async (args) => {
|
|
92
|
+
try {
|
|
93
|
+
const entry = store.get(args.id);
|
|
94
|
+
if (!entry) return readerText(`Error: no reader '${args.id}'. reader_list shows open readers.`);
|
|
95
|
+
return readerText(await haiku.ask(store, entry, args.question, undefined, { format: args.format || askFormat }));
|
|
96
|
+
} catch (e) { return readerErr(e); }
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'reader_peek',
|
|
101
|
+
description: 'Verbatim numbered slice of an open document — no LLM, exact bytes from disk. Use to ground-check a reader_ask claim or pull an exact value/snippet. Max 400 lines per call.',
|
|
102
|
+
// start/end are aliases, not a typo: agents write "reader_peek(id, start, end)", and
|
|
103
|
+
// numeric strings ("9", not 9) are no reason to reject the call, hence coerce.
|
|
104
|
+
schema: {
|
|
105
|
+
id: z.string(),
|
|
106
|
+
start_line: z.coerce.number().int().min(1).optional().describe('First line, 1-based. Required (alias: start).'),
|
|
107
|
+
end_line: z.coerce.number().int().min(1).optional().describe('Last line, inclusive. Required (alias: end).'),
|
|
108
|
+
start: z.coerce.number().int().min(1).optional().describe('Alias of start_line.'),
|
|
109
|
+
end: z.coerce.number().int().min(1).optional().describe('Alias of end_line.'),
|
|
110
|
+
},
|
|
111
|
+
handler: async (args) => {
|
|
112
|
+
const startLine = args.start_line ?? args.start;
|
|
113
|
+
const endLine = args.end_line ?? args.end;
|
|
114
|
+
if (startLine === undefined || endLine === undefined) {
|
|
115
|
+
return readerText('Error: reader_peek needs start_line and end_line (1-based, inclusive), e.g. {id, start_line: 1, end_line: 50}.');
|
|
116
|
+
}
|
|
117
|
+
try { return readerText(store.peek(args.id, startLine, endLine)); } catch (e) { return readerErr(e); }
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'reader_close',
|
|
122
|
+
description: 'Close a reader and delete its spilled raw file. Call when done — max 10 readers may be open.',
|
|
123
|
+
schema: { id: z.string() },
|
|
124
|
+
handler: async (args) => {
|
|
125
|
+
try {
|
|
126
|
+
const existed = store.close(args.id, own);
|
|
127
|
+
return readerText(existed ? `Closed ${args.id}.` : `${args.id} was not open (already closed?).`);
|
|
128
|
+
} catch (e) { return readerErr(e); }
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'reader_list',
|
|
133
|
+
description: 'List open readers (id, size, briefing, ask count). Use when unsure what is open or before opening near the limit of 10.',
|
|
134
|
+
schema: {},
|
|
135
|
+
handler: async () => {
|
|
136
|
+
try {
|
|
137
|
+
const l = store.list(own);
|
|
138
|
+
return readerText(l.length ? JSON.stringify(l, null, 2) : 'No open readers.');
|
|
139
|
+
} catch (e) { return readerErr(e); }
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { createReaderTools, READER_TOOL_NAMES, openDescription };
|
package/lib/stdio-mcp.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio-mcp — adapter of the core (lib/reader-tools.js) to stdio MCP (@modelcontextprotocol/sdk).
|
|
3
|
+
* Claude Code launches it as a separate process (bin/smcreader-mcp); the store is the same
|
|
4
|
+
* on-disk directory the hook command writes to (bin/smcreader-hook), so documents
|
|
5
|
+
* intercepted by the hook are visible here by id, in list, and count toward the limit.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
|
|
11
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
12
|
+
const { createReaderTools } = require('./reader-tools');
|
|
13
|
+
|
|
14
|
+
const VERSION = require('../package.json').version;
|
|
15
|
+
|
|
16
|
+
/** Build an McpServer with the reader tools (without connecting a transport). */
|
|
17
|
+
function buildMcpServer(toolOpts) {
|
|
18
|
+
const server = new McpServer({ name: 'reader', version: VERSION });
|
|
19
|
+
for (const t of createReaderTools(toolOpts)) {
|
|
20
|
+
server.registerTool(t.name, { description: t.description, inputSchema: t.schema }, (args) => t.handler(args || {}));
|
|
21
|
+
}
|
|
22
|
+
return server;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function runStdio(toolOpts) {
|
|
26
|
+
const server = buildMcpServer(toolOpts);
|
|
27
|
+
await server.connect(new StdioServerTransport());
|
|
28
|
+
return server;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { buildMcpServer, runStdio };
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smcreader",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "SMC Reader (Save My Context Reader): pull-model reading of large tool outputs for Claude Code. A PostToolUse hook spills big outputs to disk and leaves a short pointer; an MCP server returns only the lines or answers the model asks for.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "bulatko",
|
|
7
|
+
"homepage": "https://github.com/bulatko/SMCReader#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/bulatko/SMCReader.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/bulatko/SMCReader/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"claude-code-plugin",
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"context",
|
|
21
|
+
"context-window",
|
|
22
|
+
"hooks",
|
|
23
|
+
"agents",
|
|
24
|
+
"llm"
|
|
25
|
+
],
|
|
26
|
+
"main": "lib/index.js",
|
|
27
|
+
"bin": {
|
|
28
|
+
"smcreader-hook": "bin/smcreader-hook",
|
|
29
|
+
"smcreader-mcp": "bin/smcreader-mcp",
|
|
30
|
+
"reader-hook": "bin/reader-hook",
|
|
31
|
+
"reader-mcp": "bin/reader-mcp"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"lib/",
|
|
35
|
+
"bin/",
|
|
36
|
+
".claude-plugin/",
|
|
37
|
+
"hooks/",
|
|
38
|
+
".mcp.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "jest",
|
|
44
|
+
"e2e": "sh e2e/claude-code.sh",
|
|
45
|
+
"e2e:plugin": "sh e2e/plugin.sh"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"jest": {
|
|
51
|
+
"testEnvironment": "node",
|
|
52
|
+
"testMatch": [
|
|
53
|
+
"<rootDir>/tests/**/*.test.js"
|
|
54
|
+
]
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.293",
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
|
+
"zod": "^4.3.6"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"jest": "^29.7.0"
|
|
63
|
+
}
|
|
64
|
+
}
|