smcreader 0.1.3 → 0.1.5

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smcreader",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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.3", "smcreader-mcp"]
5
+ "args": ["-y", "--omit=optional", "--package=smcreader@0.1.5", "smcreader-mcp"]
6
6
  }
7
7
  }
package/README.md CHANGED
@@ -230,7 +230,12 @@ received, not from what it said: (1) the Bash `tool_result` contains `[READER ct
230
230
  ## Limitations
231
231
 
232
232
  - Needs a Claude Code version that applies `hookSpecificOutput.updatedToolOutput` from command
233
- hooks (verified on 2.1.282). Older versions call the hook but keep the original output.
233
+ hooks (verified on 2.1.282 and 2.1.310). Older versions call the hook but keep the original output.
234
+ - Claude Code itself saves a `Bash` output above `BASH_MAX_OUTPUT_LENGTH` (30 000 characters by
235
+ default) to a file and shows the model a preview with the path. SMC Reader leaves such outputs
236
+ alone, so it covers outputs between `READER_THRESHOLD` and that limit.
237
+ - A failed command (non-zero exit) is not intercepted: Claude Code reports it through
238
+ `PostToolUseFailure`, which cannot replace the output (Claude Code shortens it itself).
234
239
  - Supported output shapes: string, `{stdout,…}` (Bash), `{content:[…]}` (MCP), `{output}`,
235
240
  `{file:{content}}` (Read), arrays of content blocks. Unknown shape → passed through untouched.
236
241
  - `Read` is not intercepted by default — `Edit` needs exact bytes. Use the read guard instead.
@@ -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);
@@ -126,6 +143,10 @@ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist =
126
143
  try {
127
144
  const toolName = input?.tool_name;
128
145
  if (!toolName || !allowed.has(toolName)) return {};
146
+ // Claude Code already moved a big output to a file: tool_response holds a stdout truncated to
147
+ // BASH_MAX_OUTPUT_LENGTH plus persistedOutputPath, and the model gets a preview with the path.
148
+ // Intercepting it would store the truncated text and put our pointer in place of that preview.
149
+ if (input.tool_response && typeof input.tool_response === 'object' && input.tool_response.persistedOutputPath) return {};
129
150
 
130
151
  const { text, shape } = extractText(input.tool_response);
131
152
  if (!text || text.length < threshold) return {};
@@ -149,10 +170,15 @@ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist =
149
170
  const docOwner = input.agent_id
150
171
  ? `${owner !== undefined ? `${owner}/` : ''}agent:${input.agent_id}`
151
172
  : owner;
173
+ // A Read excerpt (offset) starts at file line startLine: the reader (peek, ask) keeps FILE
174
+ // numbers, not excerpt numbers.
175
+ const fileStart = shape === 'file' ? input.tool_response.file.startLine : 1;
176
+ const startLine = Number.isInteger(fileStart) && fileStart > 1 ? fileStart : 1;
152
177
  const r = store.open({ text }, briefing, {
153
178
  tool_name: toolName,
154
179
  tool_input: inputDesc,
155
180
  ...(docOwner !== undefined ? { owner: docOwner } : {}),
181
+ ...(startLine > 1 ? { lineBase: startLine } : {}),
156
182
  });
157
183
 
158
184
  // Eviction MUST be visible right here: the pointer is the only thing the agent
@@ -162,11 +188,25 @@ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist =
162
188
  const evictedNote = r.evicted
163
189
  ? `\n(evicted ${r.evicted}${cap ? ` — ${cap}/${cap} open` : ' — reader limit reached'}; close with mcp__reader__reader_close)`
164
190
  : '';
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.`);
191
+ const hint = typeof askHint === 'function' ? askHint(r.id)
192
+ : `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.`;
193
+ let pointer;
194
+ if (shape === 'file') {
195
+ // File lines first, at their real numbers; then one [READER] line that says the numbering
196
+ // stops being file numbering from here. No tail: it would get numbers that are not in the file.
197
+ const head = fileHead(text);
198
+ const last = startLine + r.lines - 1;
199
+ const hidden = startLine + head.length;
200
+ pointer = (head.length ? `${head.join('\n')}\n` : '')
201
+ + `[READER ${r.id}] ${toolName} output intercepted: ${r.size} bytes, file lines ${startLine}–${last} (>${threshold} chars).${evictedNote} `
202
+ + (head.length ? `Above: file lines ${startLine}–${hidden - 1} verbatim, real numbers. ` : '')
203
+ + (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')
204
+ + hint;
205
+ } else {
206
+ pointer =
207
+ `[READER ${r.id}] ${toolName} output intercepted: ${r.size} bytes, ${r.lines} lines (>${threshold} chars).${evictedNote}\n` +
208
+ `${headTail(text)}\n` + hint;
209
+ }
170
210
 
171
211
  // The replacement MUST match the tool's shape — otherwise the engine silently keeps the original.
172
212
  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.3",
3
+ "version": "0.1.5",
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",