smcreader 0.1.2 → 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.
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smcreader",
3
- "version": "0.1.2",
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
@@ -2,6 +2,6 @@
2
2
  "smcreader": {
3
3
  "type": "stdio",
4
4
  "command": "npx",
5
- "args": ["-y", "--omit=optional", "--package=smcreader@0.1.2", "smcreader-mcp"]
5
+ "args": ["-y", "--omit=optional", "--package=smcreader@0.1.4", "smcreader-mcp"]
6
6
  }
7
7
  }
package/README.md CHANGED
@@ -213,6 +213,20 @@ received, not from what it said: (1) the Bash `tool_result` contains `[READER ct
213
213
 
214
214
  </details>
215
215
 
216
+ ## What it runs and sends
217
+
218
+ - **The hook** (`bin/smcreader-hook`, no dependencies, no network) reads the tool output that Claude
219
+ Code passes to it. If the output is large, the hook writes it to `READER_DIR`
220
+ (default `~/.cache/smcreader`) and returns the pointer in its place.
221
+ - **The MCP server** comes from the npm registry: the plugin starts it with `npx`
222
+ (`smcreader@<version>` and its dependencies), and later starts run from npm's cache.
223
+ - **`reader_ask`** sends the document and your question to Claude Haiku through your own Claude
224
+ Code (`claude`, your credentials), like a subagent would. Those Haiku sessions keep their
225
+ transcripts under `READER_WORK_DIR`. `reader_open`, `reader_peek`, `reader_list` and
226
+ `reader_close` run locally.
227
+ - Documents expire 48 h after their last use. There is no telemetry, and nothing else leaves your
228
+ machine.
229
+
216
230
  ## Limitations
217
231
 
218
232
  - Needs a Claude Code version that applies `hookSpecificOutput.updatedToolOutput` from command
@@ -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 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.`);
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);
@@ -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
- const total = entry.meta.lines;
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(1, Math.floor(Number(a))), Math.min(total, Math.floor(Number(b ?? a)))])
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
- parts.push(store.peek(entry.id, a, e));
162
- budget -= e - a + 1;
163
- if (e < b) cut.push(`${e + 1}-${b}`);
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
- out += parts.length ? `\n\n${parts.join('\n…\n')}` : '\n\n(no lines given — not in document)';
167
- if (cut.length) out += `\n\n(not shown due to the ${LINES_CAP}-line limit: ${cut.join(', ')} — use reader_peek)`;
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 + 1}\t${l}`).join('\n');
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 doc = numberLines(store.readRaw(entry.id));
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
- prompt = `${READER_FRAMING}\n\nBRIEFING from the main agent: ${entry.meta.briefing}${note}\n\n--- DOCUMENT ${entry.id} (${entry.meta.lines} lines, ${entry.meta.size} bytes, source: ${entry.meta.source.path || 'inline text'}) ---\n${doc}\n--- END DOCUMENT ---\n\nFirst question: ${q}`;
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 };
@@ -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
- /** Verbatim numbered slice from DISK. 1-based, inclusive; clamped to bounds. */
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 start = Math.max(1, Math.min(startLine, lines.length));
242
- const end = Math.max(1, Math.min(endLine, lines.length));
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 - 1, end).map((l, i) => `${start + i}\t${l}`).join('\n');
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
- if (entry.asks >= MAX_ASKS) {
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.2",
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",