smcreader 0.1.3 → 0.1.4
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/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/lib/intercept-hook.js +41 -5
- package/lib/reader-model.js +38 -13
- package/lib/reader-store.js +23 -6
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smcreader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
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
5
|
"author": { "name": "bulatko" },
|
|
6
6
|
"homepage": "https://github.com/bulatko/SMCReader",
|
package/.mcp.json
CHANGED
package/lib/intercept-hook.js
CHANGED
|
@@ -97,6 +97,23 @@ function headTail(text) {
|
|
|
97
97
|
return body;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Head of an intercepted Read: whole file lines only, as is. The engine numbers the replacement
|
|
102
|
+
* like cat -n starting at file.startLine, so file lines must come first — a [READER] header on top
|
|
103
|
+
* shifted every number by +1, and a tail after "… omitted …" got numbers that are not in the file
|
|
104
|
+
* (the last line of a 160-line file was shown as line 14). Agents edit by these numbers.
|
|
105
|
+
*/
|
|
106
|
+
function fileHead(text) {
|
|
107
|
+
const out = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
for (const l of text.split('\n').slice(0, HEAD_LINES)) {
|
|
110
|
+
if (size + l.length + 1 > POINTER_BODY_CAP) break;
|
|
111
|
+
out.push(l);
|
|
112
|
+
size += l.length + 1;
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
function describeInput(toolName, toolInput) {
|
|
101
118
|
try {
|
|
102
119
|
if (toolName === 'Bash' && toolInput?.command) return toolInput.command.slice(0, 200);
|
|
@@ -149,10 +166,15 @@ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist =
|
|
|
149
166
|
const docOwner = input.agent_id
|
|
150
167
|
? `${owner !== undefined ? `${owner}/` : ''}agent:${input.agent_id}`
|
|
151
168
|
: owner;
|
|
169
|
+
// A Read excerpt (offset) starts at file line startLine: the reader (peek, ask) keeps FILE
|
|
170
|
+
// numbers, not excerpt numbers.
|
|
171
|
+
const fileStart = shape === 'file' ? input.tool_response.file.startLine : 1;
|
|
172
|
+
const startLine = Number.isInteger(fileStart) && fileStart > 1 ? fileStart : 1;
|
|
152
173
|
const r = store.open({ text }, briefing, {
|
|
153
174
|
tool_name: toolName,
|
|
154
175
|
tool_input: inputDesc,
|
|
155
176
|
...(docOwner !== undefined ? { owner: docOwner } : {}),
|
|
177
|
+
...(startLine > 1 ? { lineBase: startLine } : {}),
|
|
156
178
|
});
|
|
157
179
|
|
|
158
180
|
// Eviction MUST be visible right here: the pointer is the only thing the agent
|
|
@@ -162,11 +184,25 @@ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist =
|
|
|
162
184
|
const evictedNote = r.evicted
|
|
163
185
|
? `\n(evicted ${r.evicted}${cap ? ` — ${cap}/${cap} open` : ' — reader limit reached'}; close with mcp__reader__reader_close)`
|
|
164
186
|
: '';
|
|
165
|
-
const
|
|
166
|
-
`
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
187
|
+
const hint = typeof askHint === 'function' ? askHint(r.id)
|
|
188
|
+
: `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.`;
|
|
189
|
+
let pointer;
|
|
190
|
+
if (shape === 'file') {
|
|
191
|
+
// File lines first, at their real numbers; then one [READER] line that says the numbering
|
|
192
|
+
// stops being file numbering from here. No tail: it would get numbers that are not in the file.
|
|
193
|
+
const head = fileHead(text);
|
|
194
|
+
const last = startLine + r.lines - 1;
|
|
195
|
+
const hidden = startLine + head.length;
|
|
196
|
+
pointer = (head.length ? `${head.join('\n')}\n` : '')
|
|
197
|
+
+ `[READER ${r.id}] ${toolName} output intercepted: ${r.size} bytes, file lines ${startLine}–${last} (>${threshold} chars).${evictedNote} `
|
|
198
|
+
+ (head.length ? `Above: file lines ${startLine}–${hidden - 1} verbatim, real numbers. ` : '')
|
|
199
|
+
+ (hidden <= last ? `From this line on the numbers are NOT file lines. Lines ${hidden}–${last} are in the reader; reader_peek takes file line numbers.\n` : '\n')
|
|
200
|
+
+ hint;
|
|
201
|
+
} else {
|
|
202
|
+
pointer =
|
|
203
|
+
`[READER ${r.id}] ${toolName} output intercepted: ${r.size} bytes, ${r.lines} lines (>${threshold} chars).${evictedNote}\n` +
|
|
204
|
+
`${headTail(text)}\n` + hint;
|
|
205
|
+
}
|
|
170
206
|
|
|
171
207
|
// The replacement MUST match the tool's shape — otherwise the engine silently keeps the original.
|
|
172
208
|
const replacement = buildReplacement(input.tool_response, shape, pointer);
|
package/lib/reader-model.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const os = require('os');
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const { lineBaseOf } = require('./reader-store');
|
|
15
16
|
|
|
16
17
|
// cwd for Haiku sessions: their transcripts go to a separate Claude Code project, not the agent's project.
|
|
17
18
|
// The directory is created on the first question, not at module load (the hook and tests don't need it).
|
|
@@ -116,6 +117,8 @@ const defaultRunQuery = createRunQuery();
|
|
|
116
117
|
// accurate. The line text is filled in by the SERVER from the file (store.peek), so it is verbatim
|
|
117
118
|
// by construction: Haiku does not retype or distort code.
|
|
118
119
|
const LINES_CAP = 160; // lines shown verbatim in one answer; the rest are listed as ranges for reader_peek
|
|
120
|
+
const LINES_BYTES_CAP = 16 * 1024; // and bytes: long lines (minified JSON, one-line logs) must not flood the agent's window
|
|
121
|
+
const LINE_CHARS_CAP = 2000; // a longer line is shown by its start, marked as truncated
|
|
119
122
|
|
|
120
123
|
// NOTICED is not requested in lines mode: scanning the whole document for "anything else suspicious"
|
|
121
124
|
// made the first answer take 12–16 s versus 7–9 s without it, while subagents typically ask a document
|
|
@@ -141,10 +144,12 @@ function extractJson(raw) {
|
|
|
141
144
|
function renderLines(store, entry, raw) {
|
|
142
145
|
const j = extractJson(raw);
|
|
143
146
|
if (!j || !Array.isArray(j.ranges)) return `(answer not in lines format — shown as is)\n${raw}`;
|
|
144
|
-
|
|
147
|
+
// Source numbers (lineBase of a Read excerpt with offset): Haiku saw them in numberLines.
|
|
148
|
+
const base = lineBaseOf(entry);
|
|
149
|
+
const last = base + entry.meta.lines - 1;
|
|
145
150
|
const rs = j.ranges
|
|
146
151
|
.map(r => (Array.isArray(r) ? r : [r, r]))
|
|
147
|
-
.map(([a, b]) => [Math.max(
|
|
152
|
+
.map(([a, b]) => [Math.max(base, Math.floor(Number(a))), Math.min(last, Math.floor(Number(b ?? a)))])
|
|
148
153
|
.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && a <= b)
|
|
149
154
|
.sort((x, y) => x[0] - y[0]);
|
|
150
155
|
const merged = [];
|
|
@@ -153,26 +158,44 @@ function renderLines(store, entry, raw) {
|
|
|
153
158
|
if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]); else merged.push([...r]);
|
|
154
159
|
}
|
|
155
160
|
let budget = LINES_CAP;
|
|
161
|
+
let bytes = LINES_BYTES_CAP;
|
|
162
|
+
let byBytes = false;
|
|
156
163
|
const parts = [];
|
|
157
164
|
const cut = [];
|
|
158
165
|
for (const [a, b] of merged) {
|
|
159
|
-
if (budget <= 0) { cut.push(`${a}-${b}`); continue; }
|
|
166
|
+
if (budget <= 0 || bytes <= 0) { cut.push(`${a}-${b}`); continue; }
|
|
160
167
|
const e = Math.min(b, a + budget - 1);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
// A byte cap too: 160 lines of minified JSON are hundreds of KB in one answer — exactly what
|
|
169
|
+
// the reader exists to keep out of the window. A line longer than LINE_CHARS_CAP is shown by
|
|
170
|
+
// its start with an explicit mark, so it is never passed off as verbatim.
|
|
171
|
+
const kept = [];
|
|
172
|
+
for (const row of store.peek(entry.id, a, e).split('\n')) {
|
|
173
|
+
if (bytes <= 0) { byBytes = true; break; }
|
|
174
|
+
const shown = row.length > LINE_CHARS_CAP
|
|
175
|
+
? `${row.slice(0, LINE_CHARS_CAP)}… [line truncated: ${row.length - LINE_CHARS_CAP} more chars — reader_peek]` : row;
|
|
176
|
+
kept.push(shown);
|
|
177
|
+
bytes -= Buffer.byteLength(shown, 'utf8') + 1;
|
|
178
|
+
}
|
|
179
|
+
parts.push(kept.join('\n'));
|
|
180
|
+
budget -= kept.length;
|
|
181
|
+
const shownEnd = a + kept.length - 1;
|
|
182
|
+
if (shownEnd < b) cut.push(`${shownEnd + 1}-${b}`);
|
|
164
183
|
}
|
|
165
184
|
let out = j.note ? String(j.note).trim() : '';
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
// Ranges were named but none is usable (outside the document, "12-20" instead of [12,20]) — that
|
|
186
|
+
// is not "not in document": the reader's answer is broken, and the agent must see it.
|
|
187
|
+
out += parts.length ? `\n\n${parts.join('\n…\n')}`
|
|
188
|
+
: j.ranges.length ? `\n\n(the reader named lines ${JSON.stringify(j.ranges).slice(0, 120)}, but the document has no such lines — it has lines ${base}–${last}; ask again or use reader_peek)`
|
|
189
|
+
: '\n\n(no lines given — not in document)';
|
|
190
|
+
if (cut.length) out += `\n\n(not shown due to the ${LINES_CAP}-line${byBytes || bytes <= 0 ? ` or ${LINES_BYTES_CAP}-byte` : ''} limit: ${cut.join(', ')} — use reader_peek)`;
|
|
168
191
|
if (Array.isArray(j.noticed)) {
|
|
169
192
|
out += j.noticed.length ? `\n\nNOTICED:\n${j.noticed.map(x => `- ${x}`).join('\n')}` : '\n\nNOTICED: nothing new';
|
|
170
193
|
}
|
|
171
194
|
return out.trim();
|
|
172
195
|
}
|
|
173
196
|
|
|
174
|
-
function numberLines(text) {
|
|
175
|
-
return text.split('\n').map((l, i) => `${i +
|
|
197
|
+
function numberLines(text, base = 1) {
|
|
198
|
+
return text.split('\n').map((l, i) => `${i + base}\t${l}`).join('\n');
|
|
176
199
|
}
|
|
177
200
|
|
|
178
201
|
/**
|
|
@@ -203,9 +226,11 @@ async function ask(store, entry, question, runQuery = defaultRunQuery, opts = {}
|
|
|
203
226
|
const q = lines ? question + linesInstruction() : question;
|
|
204
227
|
let prompt;
|
|
205
228
|
if (!base) {
|
|
206
|
-
const
|
|
229
|
+
const lb = lineBaseOf(entry);
|
|
230
|
+
const doc = numberLines(store.readRaw(entry.id), lb);
|
|
207
231
|
const note = entry.rehydrated ? '\n(Note: a previous dialog about this document was reset by a server restart.)' : '';
|
|
208
|
-
|
|
232
|
+
const span = lb > 1 ? `, source lines ${lb}–${lb + entry.meta.lines - 1}` : '';
|
|
233
|
+
prompt = `${READER_FRAMING}\n\nBRIEFING from the main agent: ${entry.meta.briefing}${note}\n\n--- DOCUMENT ${entry.id} (${entry.meta.lines} lines${span}, ${entry.meta.size} bytes, source: ${entry.meta.source.path || 'inline text'}) ---\n${doc}\n--- END DOCUMENT ---\n\nFirst question: ${q}`;
|
|
209
234
|
} else {
|
|
210
235
|
prompt = q;
|
|
211
236
|
}
|
|
@@ -227,4 +252,4 @@ async function ask(store, entry, question, runQuery = defaultRunQuery, opts = {}
|
|
|
227
252
|
return run.finally(() => { entry.inFlight--; });
|
|
228
253
|
}
|
|
229
254
|
|
|
230
|
-
module.exports = { ask, createRunQuery, defaultRunQuery, resolveClaudeExecutable, READER_FRAMING, numberLines, renderLines, extractJson, LINES_CAP };
|
|
255
|
+
module.exports = { ask, createRunQuery, defaultRunQuery, resolveClaudeExecutable, READER_FRAMING, numberLines, renderLines, extractJson, LINES_CAP, LINES_BYTES_CAP, LINE_CHARS_CAP };
|
package/lib/reader-store.js
CHANGED
|
@@ -39,6 +39,15 @@ const PREVIEW_TAIL = 10;
|
|
|
39
39
|
const PREVIEW_CHAR_CAP = 1500;
|
|
40
40
|
const PEEK_MAX_LINES = 400;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Source line number the document starts at: an intercepted Read(offset=219) holds file lines
|
|
44
|
+
* 219…, and meta.lineBase = 219 keeps peek/ask/lines answers in FILE numbers. Default 1.
|
|
45
|
+
*/
|
|
46
|
+
function lineBaseOf(entry) {
|
|
47
|
+
const b = entry && entry.meta && Number(entry.meta.lineBase);
|
|
48
|
+
return Number.isInteger(b) && b > 1 ? b : 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function createReaderStore({ dir, now = Date.now, log = console.log } = {}) {
|
|
43
52
|
if (!dir) throw new Error('createReaderStore requires {dir}');
|
|
44
53
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -233,18 +242,24 @@ function createReaderStore({ dir, now = Date.now, log = console.log } = {}) {
|
|
|
233
242
|
}
|
|
234
243
|
}
|
|
235
244
|
|
|
236
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* Verbatim numbered slice from DISK. Inclusive; clamped to bounds.
|
|
247
|
+
* Numbers are SOURCE numbers: for an excerpt Read(offset=219) the first document line is file
|
|
248
|
+
* line 219 (meta.lineBase), and peek(id, 219, 219) returns it. Without lineBase — from 1.
|
|
249
|
+
*/
|
|
237
250
|
function peek(id, startLine, endLine) {
|
|
238
251
|
const entry = get(id);
|
|
239
252
|
if (!entry) throw new Error(`no reader '${id}'`);
|
|
240
253
|
const lines = fs.readFileSync(entry.rawPath, 'utf8').split('\n');
|
|
241
|
-
const
|
|
242
|
-
const
|
|
254
|
+
const base = lineBaseOf(entry);
|
|
255
|
+
const last = base + lines.length - 1;
|
|
256
|
+
const start = Math.max(base, Math.min(startLine, last));
|
|
257
|
+
const end = Math.max(base, Math.min(endLine, last));
|
|
243
258
|
if (start > end) throw new Error(`start_line ${startLine} > end_line ${endLine}`);
|
|
244
259
|
if (end - start + 1 > PEEK_MAX_LINES) {
|
|
245
260
|
throw new Error(`span is ${end - start + 1} lines (max ${PEEK_MAX_LINES}). Narrow the range`);
|
|
246
261
|
}
|
|
247
|
-
return lines.slice(start -
|
|
262
|
+
return lines.slice(start - base, end - base + 1).map((l, i) => `${start + i}\t${l}`).join('\n');
|
|
248
263
|
}
|
|
249
264
|
|
|
250
265
|
/** Read the whole raw file (for the haiku reader on the first ask). */
|
|
@@ -303,7 +318,9 @@ function createReaderStore({ dir, now = Date.now, log = console.log } = {}) {
|
|
|
303
318
|
if (entry.meta.tooLargeForAsk) {
|
|
304
319
|
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
320
|
}
|
|
306
|
-
|
|
321
|
+
// In-flight questions count: asks grows only when a question completes, so a batch of
|
|
322
|
+
// parallel questions used to pass this check all at once (asks=19 + 8 parallel → 27 of 20).
|
|
323
|
+
if (entry.asks + (entry.inFlight || 0) >= MAX_ASKS) {
|
|
307
324
|
return { ok: false, reason: `ask limit reached (${MAX_ASKS}) for this reader. Close it and reopen with a fresh briefing` };
|
|
308
325
|
}
|
|
309
326
|
return { ok: true };
|
|
@@ -313,4 +330,4 @@ function createReaderStore({ dir, now = Date.now, log = console.log } = {}) {
|
|
|
313
330
|
return { open, get, peek, readRaw, close, list, canAsk, sweep, limits: { OPEN_MAX, ASK_MAX, MAX_OPEN, MAX_ASKS, PEEK_MAX_LINES } };
|
|
314
331
|
}
|
|
315
332
|
|
|
316
|
-
module.exports = { createReaderStore };
|
|
333
|
+
module.exports = { createReaderStore, lineBaseOf };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smcreader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "bulatko",
|