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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * intercept-hook — PostToolUse hook that intercepts LARGE tool outputs before
3
+ * they reach the main agent's context and replaces them with a "lazy pointer":
4
+ * a compact header + head/tail + reader_id.
5
+ *
6
+ * Haiku is NOT called at interception time (zero latency, zero cost) — the
7
+ * actual reading happens lazily, when the agent calls reader_ask with a
8
+ * question it already has (pull model).
9
+ *
10
+ * Fail-safe: any failure inside the hook → return {} (output passes through as is).
11
+ * Losing compression is cheap; blinding the agent is expensive.
12
+ */
13
+
14
+ const DEFAULT_THRESHOLD = 2048; // chars; ~500 tokens
15
+ const HEAD_LINES = 8;
16
+ const TAIL_LINES = 4;
17
+ const POINTER_BODY_CAP = 900; // chars for head+tail in the pointer
18
+
19
+ /** Tools whose outputs are intercepted by default. Deliberately NOT: Read (exact bytes
20
+ * for Edit), Agent/Task (already summaries), mcp__reader__* (recursion). MCP tools —
21
+ * only via an explicit allowlist ($READER_ALLOWLIST): each has its own output shape. */
22
+ const DEFAULT_ALLOWLIST = ['Bash', 'WebFetch'];
23
+
24
+ /**
25
+ * Extract text from tool_response + classify its SHAPE. The shape is critical:
26
+ * the engine applies updatedToolOutput only if the replacement structurally matches
27
+ * what the tool returned (otherwise "does not match output shape" → original is kept).
28
+ * shape ∈ {string, stdout, content, output, array, file} — supported for replacement;
29
+ * 'unknown' → skip interception (fail-open).
30
+ */
31
+ function extractText(toolResponse) {
32
+ if (typeof toolResponse === 'string') return { text: toolResponse, shape: 'string' };
33
+ if (toolResponse == null) return { text: '', shape: 'unknown' };
34
+ if (typeof toolResponse === 'object') {
35
+ if (typeof toolResponse.stdout === 'string') { // Bash: {stdout,stderr,interrupted,isImage,...}
36
+ const err = typeof toolResponse.stderr === 'string' && toolResponse.stderr.trim()
37
+ ? `\n--- stderr ---\n${toolResponse.stderr}` : '';
38
+ return { text: toolResponse.stdout + err, shape: 'stdout' };
39
+ }
40
+ if (Array.isArray(toolResponse.content)) { // MCP: {content:[{type:'text',text}]}
41
+ const text = toolResponse.content
42
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
43
+ .map((b) => b.text).join('\n');
44
+ if (text) return { text, shape: 'content' };
45
+ }
46
+ if (typeof toolResponse.output === 'string') return { text: toolResponse.output, shape: 'output' };
47
+ // Read: {type:'text', file:{filePath, content, numLines, startLine, totalLines}}.
48
+ // Without this shape, interception silently failed open on EVERY Read, and large
49
+ // file contents went into the context in full.
50
+ if (toolResponse.file && typeof toolResponse.file.content === 'string') {
51
+ return { text: toolResponse.file.content, shape: 'file' };
52
+ }
53
+ // MCP output as a bare array of content blocks: [{type:'text',text}, ...]
54
+ // (some MCP servers return this; Object.keys of the array = ['0','1',...]).
55
+ if (Array.isArray(toolResponse)) {
56
+ const text = toolResponse
57
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
58
+ .map((b) => b.text).join('\n');
59
+ if (text) return { text, shape: 'array' };
60
+ }
61
+ }
62
+ return { text: '', shape: 'unknown' }; // can't mirror this shape — don't intercept
63
+ }
64
+
65
+ /**
66
+ * Build the replacement in the SAME shape the tool returned, substituting the pointer.
67
+ * Other fields (interrupted, isImage, ...) are preserved; stderr is cleared.
68
+ */
69
+ function buildReplacement(toolResponse, shape, pointer) {
70
+ switch (shape) {
71
+ case 'string': return pointer;
72
+ case 'stdout': return { ...toolResponse, stdout: pointer, stderr: '' };
73
+ case 'content': return { ...toolResponse, content: [{ type: 'text', text: pointer }] };
74
+ case 'output': return { ...toolResponse, output: pointer };
75
+ // Other file fields (filePath/numLines/…) are kept: the engine checks the shape,
76
+ // and the agent benefits from seeing WHAT was moved to the reader.
77
+ case 'file': return { ...toolResponse, file: { ...toolResponse.file, content: pointer } };
78
+ case 'array': return [{ type: 'text', text: pointer }]; // array of content blocks — mirror as an array
79
+ default: return null;
80
+ }
81
+ }
82
+
83
+ function headTail(text) {
84
+ const lines = text.split('\n');
85
+ if (lines.length <= HEAD_LINES + TAIL_LINES) {
86
+ // Few lines, but the text may be huge (minified JSON, single-line log):
87
+ // use character head+tail so the tail (exit code, last error) is not lost.
88
+ if (text.length <= POINTER_BODY_CAP) return text;
89
+ const headChars = Math.floor(POINTER_BODY_CAP * 0.7);
90
+ const tailChars = POINTER_BODY_CAP - headChars;
91
+ return `${text.slice(0, headChars)}\n... (${text.length - headChars - tailChars} chars omitted — in the reader) ...\n${text.slice(-tailChars)}`;
92
+ }
93
+ const head = lines.slice(0, HEAD_LINES).join('\n');
94
+ const tail = lines.slice(-TAIL_LINES).join('\n');
95
+ let body = `${head}\n... (${lines.length - HEAD_LINES - TAIL_LINES} lines omitted — in the reader) ...\n${tail}`;
96
+ if (body.length > POINTER_BODY_CAP) body = body.slice(0, POINTER_BODY_CAP) + '…';
97
+ return body;
98
+ }
99
+
100
+ function describeInput(toolName, toolInput) {
101
+ try {
102
+ if (toolName === 'Bash' && toolInput?.command) return toolInput.command.slice(0, 200);
103
+ if (toolName === 'WebFetch' && toolInput?.url) return toolInput.url.slice(0, 200);
104
+ if (toolInput?.command) return String(toolInput.command).slice(0, 200);
105
+ return JSON.stringify(toolInput).slice(0, 200);
106
+ } catch (_) { return '(unprintable input)'; }
107
+ }
108
+
109
+ /**
110
+ * Factory for the PostToolUse callback.
111
+ * @param {object} opts
112
+ * @param {object} opts.store ReaderStore
113
+ * @param {number} [opts.threshold]
114
+ * @param {string[]} [opts.allowlist]
115
+ * @param {Function} [opts.log]
116
+ */
117
+ // owner — id of the session that owns intercepted documents (shared store of the main agent).
118
+ // taskContext() — the agent's task, used in the reader briefing.
119
+ // askHint(id) — last line of the pointer: how to work with the reader.
120
+ function createInterceptHook({ store, threshold = DEFAULT_THRESHOLD, allowlist = DEFAULT_ALLOWLIST, log = console.log, owner,
121
+ taskContext = null, askHint = null } = {}) {
122
+ if (!store) throw new Error('createInterceptHook requires {store}');
123
+ const allowed = new Set(allowlist);
124
+
125
+ return async function postToolUseIntercept(input /*, toolUseID, { signal } */) {
126
+ try {
127
+ const toolName = input?.tool_name;
128
+ if (!toolName || !allowed.has(toolName)) return {};
129
+
130
+ const { text, shape } = extractText(input.tool_response);
131
+ if (!text || text.length < threshold) return {};
132
+ if (shape === 'unknown') return {}; // shape can't be mirrored → skip (the engine would reject the replacement)
133
+
134
+ const inputDesc = describeInput(toolName, input.tool_input);
135
+ let task = null;
136
+ try { task = typeof taskContext === 'function' ? taskContext() : null; } catch (_) { task = null; }
137
+ const why = input.tool_input && typeof input.tool_input.description === 'string' ? input.tool_input.description.trim() : '';
138
+ // The task and the call description give the reader a goal: without them the briefing
139
+ // is a generic "look for errors" template, and the reader has nothing to judge
140
+ // "what matters here" against.
141
+ const briefing = `Intercepted output of ${toolName} (${inputDesc}).`
142
+ + (task ? ` The agent's task: ${String(task).slice(0, 600)}.` : '')
143
+ + (why ? ` Its own description of this call: ${why.slice(0, 300)}.` : '')
144
+ + ` The main agent saw only a head/tail pointer and will ask about specifics. Watch for: errors, warnings, anomalies, unexpected values${task ? ', and whatever matters for the task' : ''}.`;
145
+ // The hook also fires for subagent tools (Agent/Task) — the SDK sets agent_id for them.
146
+ // Under the session owner, their outputs would consume the session's reader limit and
147
+ // evict its documents. A subagent gets its own child owner: its own limit and LRU;
148
+ // the session can still close them.
149
+ const docOwner = input.agent_id
150
+ ? `${owner !== undefined ? `${owner}/` : ''}agent:${input.agent_id}`
151
+ : owner;
152
+ const r = store.open({ text }, briefing, {
153
+ tool_name: toolName,
154
+ tool_input: inputDesc,
155
+ ...(docOwner !== undefined ? { owner: docOwner } : {}),
156
+ });
157
+
158
+ // Eviction MUST be visible right here: the pointer is the only thing the agent
159
+ // reads. It doesn't see the store's server log, and a silently lost document
160
+ // looks to it like "the reader lied".
161
+ const cap = store.limits && store.limits.MAX_OPEN;
162
+ const evictedNote = r.evicted
163
+ ? `\n(evicted ${r.evicted}${cap ? ` — ${cap}/${cap} open` : ' — reader limit reached'}; close with mcp__reader__reader_close)`
164
+ : '';
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.`);
170
+
171
+ // The replacement MUST match the tool's shape — otherwise the engine silently keeps the original.
172
+ const replacement = buildReplacement(input.tool_response, shape, pointer);
173
+ if (replacement === null) return {}; // safety net
174
+
175
+ log(`[reader-hook] intercepted ${toolName} output: ${text.length} chars (shape=${shape}) -> ${r.id}`);
176
+ return {
177
+ hookSpecificOutput: {
178
+ hookEventName: 'PostToolUse',
179
+ updatedToolOutput: replacement,
180
+ },
181
+ };
182
+ } catch (e) {
183
+ // fail-open: never break output delivery
184
+ try { log(`[reader-hook] error (passing output through): ${e.message}`); } catch (_) {}
185
+ return {};
186
+ }
187
+ };
188
+ }
189
+
190
+ module.exports = { createInterceptHook, extractText, buildReplacement, headTail, DEFAULT_ALLOWLIST, DEFAULT_THRESHOLD };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * read-guard — PreToolUse guard for large Reads: a full Read of a large file is
3
+ * not let into the context window, only a targeted one (limit/offset) or navigation.
4
+ *
5
+ * A factory (rather than a ready-made hook), because the threshold and the set of
6
+ * alternatives depend on the agent's profile (whether it has a reader, whether Read
7
+ * output is intercepted). A denial MUST lead to a working path: an agent told "no"
8
+ * without "but you can do this" simply loses access to the information.
9
+ *
10
+ * Fail-open on anything unclear (no file_path, stat failed, binary file) —
11
+ * let Read return its own error; blinding the agent costs more than not compressing.
12
+ */
13
+
14
+ const fs = require('fs');
15
+
16
+ const DEFAULT_THRESHOLD = 32 * 1024;
17
+ // Binary formats are not guarded — they can't be read by line ranges.
18
+ const BINARY_EXT = /\.(png|jpe?g|gif|webp|bmp|ico|pdf|ipynb)$/i;
19
+ // How much of the file to measure: sources and logs being edited are hundreds of KB; the tail
20
+ // of a giant log (beyond 4 MB) won't refine the chunk size, and the hook blocks every Read.
21
+ const MEASURE_BYTES = 4 * 1024 * 1024;
22
+ const CHUNK_FIT_SHARE = 0.95;
23
+
24
+ /**
25
+ * Chunk size for a targeted Read that will get past output interception. For a subagent there
26
+ * is a second guard behind this one — Read output larger than outputCap goes to the reader
27
+ * (Read interception, intercept-hook). Given a bare "Read with limit" hint, an agent would pick
28
+ * e.g. limit 200 on a 17 KB file and get a reader pointer instead of the text. Average line
29
+ * length doesn't work — on an uneven file a chunk based on the average still ends up in the
30
+ * reader, while a worst-case guarantee chops the file into tiny pieces. We take the largest N
31
+ * such that 95% of all windows of N consecutive lines are shorter than the cap, whatever line
32
+ * you start from; the hint itself covers the dense spots.
33
+ * Measured in characters, like the interception itself (file.content without line numbers).
34
+ * @returns {{per: number, total: number}|null} lines per read and lines in the file; null — don't chunk
35
+ */
36
+ function chunkLines(file, size, outputCap) {
37
+ const fd = fs.openSync(file, 'r');
38
+ let text;
39
+ try {
40
+ const buf = Buffer.alloc(Math.min(size, MEASURE_BYTES));
41
+ text = buf.subarray(0, fs.readSync(fd, buf, 0, buf.length, 0)).toString('utf8');
42
+ } finally {
43
+ fs.closeSync(fd);
44
+ }
45
+ const parts = text.split('\n');
46
+ if (parts.length > 1 && parts[parts.length - 1] === '') parts.pop(); // trailing newline is not a line
47
+ const lens = parts.map(l => l.length + 1);
48
+ const pre = [0];
49
+ for (const l of lens) pre.push(pre[pre.length - 1] + l);
50
+ const fits = (n) => {
51
+ let bad = 0;
52
+ for (let i = n; i < pre.length; i++) if (pre[i] - pre[i - n] >= outputCap) bad++;
53
+ return bad <= (pre.length - n) * (1 - CHUNK_FIT_SHARE);
54
+ };
55
+ if (!fits(1)) return null; // lines longer than the cap — can't chunk by lines
56
+ let lo = 1, hi = lens.length;
57
+ while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (fits(mid)) lo = mid; else hi = mid - 1; }
58
+ const per = lo >= 100 ? lo - lo % 10 : lo;
59
+ const total = size <= MEASURE_BYTES ? lens.length : Math.round(lens.length * size / text.length);
60
+ return { per, total };
61
+ }
62
+
63
+ /**
64
+ * Denial text. Every item is an executable command, not advice like "read less".
65
+ * @param {number} kb file size
66
+ * @param {number} thresholdKb threshold
67
+ * @param {boolean} hasReader whether mcp__reader__* is available to the agent (without it, the reader item is a trap)
68
+ * @param {{capKb: number, per?: number, total?: number}} [cap] the agent's Read output interception
69
+ */
70
+ function denyReason(kb, thresholdKb, hasReader, cap) {
71
+ const chunk = !cap ? ''
72
+ : cap.per
73
+ ? `, but output over ${cap.capKb}KB goes to the reader — read ${cap.per} lines at a time (file has ~${cap.total}): offset 1, ${cap.per + 1}, ${2 * cap.per + 1}…; if a chunk went to the reader (dense section), retry with half the limit`
74
+ : `, but output over ${cap.capKb}KB goes to the reader — read chunks smaller than ${cap.capKb}KB`;
75
+ const ways = [
76
+ `Read(file_path, offset, limit) — targeted read; Read with limit/offset is ALWAYS allowed${chunk}`,
77
+ 'Grep(pattern, path, -n) — returns line numbers, then do a targeted Read at them',
78
+ ...(hasReader
79
+ ? ['mcp__reader__reader_open({path, briefing}) + mcp__reader__reader_ask(id, question) — the file goes to a separate reader, only the answers enter your context; mcp__reader__reader_peek(id, start_line, end_line) gives a verbatim slice']
80
+ : []),
81
+ "Bash: sed -n '120,180p' file / head -80 / tail -80 / grep -n",
82
+ ];
83
+ // Without the last line, an agent that read the file via the reader would pull a separate
84
+ // full copy for Edit: Edit requires a Read, and a targeted one is enough for the whole file.
85
+ return `File is ${kb}KB (threshold ${thresholdKb}KB): a full Read would bloat your context. ` +
86
+ `Read it this way instead:\n` + ways.map((w, i) => `${i + 1}) ${w}`).join('\n') +
87
+ `\nEdit does not need a full Read: after a targeted Read (offset/limit), Edit works on the whole file.`;
88
+ }
89
+
90
+ /**
91
+ * @param {object} opts
92
+ * @param {number} [opts.threshold] bytes; larger — deny Read without limit/offset
93
+ * @param {boolean} [opts.hasReader] whether the agent has mcp__reader__*
94
+ * @param {number} [opts.outputCap] chars; larger Read output is intercepted into the reader (8 KB for subagents)
95
+ * @param {Function} [opts.log]
96
+ * @returns {Function} PreToolUse callback (matcher 'Read')
97
+ */
98
+ function createReadGuardHook({ threshold = DEFAULT_THRESHOLD, hasReader = false, outputCap = 0, log = console.log } = {}) {
99
+ const thresholdKb = Math.round(threshold / 1024);
100
+ return async function preToolUseReadGuard(input /*, toolUseID, { signal } */) {
101
+ try {
102
+ if (!input || input.tool_name !== 'Read') return {};
103
+ const ti = input.tool_input || {};
104
+ if (ti.limit || ti.offset) return {}; // targeted read — always allowed
105
+ if (!ti.file_path || BINARY_EXT.test(ti.file_path)) return {};
106
+ const size = fs.statSync(ti.file_path).size;
107
+ if (size <= threshold) return {};
108
+ const kb = Math.round(size / 1024);
109
+ try { log(`[read-guard] deny: ${ti.file_path} (${kb}KB)`); } catch (_) {}
110
+ let cap = null;
111
+ if (outputCap) {
112
+ cap = { capKb: Math.round(outputCap / 1024) };
113
+ try { Object.assign(cap, chunkLines(ti.file_path, size, outputCap)); } catch (_) { /* no numbers — generic hint */ }
114
+ }
115
+ return {
116
+ hookSpecificOutput: {
117
+ hookEventName: 'PreToolUse',
118
+ permissionDecision: 'deny',
119
+ permissionDecisionReason: denyReason(kb, thresholdKb, hasReader, cap),
120
+ },
121
+ };
122
+ } catch (_) {
123
+ return {}; // no file / stat failed — let Read return its own error
124
+ }
125
+ };
126
+ }
127
+
128
+ module.exports = { createReadGuardHook, denyReason, chunkLines, DEFAULT_THRESHOLD, BINARY_EXT };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * reader-mcp — factory for the in-process "reader" MCP server for the Agent SDK
3
+ * (createSdkMcpServer → options.mcpServers). Same tools as the stdio server,
4
+ * from the same core (lib/reader-tools.js).
5
+ *
6
+ * In-process: the SDK keeps the instance in a per-query map and connects it on every query(),
7
+ * so do NOT reuse one object across calls — call the factory on every turn.
8
+ * The SDK is required LAZILY: it is ESM, and a static require breaks jest's parsing of
9
+ * modules that import this one.
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ const { createReaderTools, READER_TOOL_NAMES } = require('./reader-tools');
15
+
16
+ /**
17
+ * @param {object} opts — same as createReaderTools, plus
18
+ * @param {boolean} [opts.alwaysLoad] tools are in the prompt right away, without ToolSearch
19
+ * @returns {object} McpSdkServerConfigWithInstance
20
+ */
21
+ function createReaderMcpServer({ alwaysLoad = false, ...opts } = {}) {
22
+ if (!opts.store) throw new Error('createReaderMcpServer requires {store}');
23
+ const { createSdkMcpServer, tool } = require('@anthropic-ai/claude-agent-sdk');
24
+ return createSdkMcpServer({
25
+ name: 'reader',
26
+ version: '1.0.0',
27
+ ...(alwaysLoad ? { alwaysLoad: true } : {}),
28
+ tools: createReaderTools(opts).map((t) => tool(t.name, t.description, t.schema, t.handler)),
29
+ });
30
+ }
31
+
32
+ module.exports = { createReaderMcpServer, READER_TOOL_NAMES };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * reader-haiku — the dialog layer of the reader MCP: a Haiku reader that holds
3
+ * the document in its context and answers questions from the main agent.
4
+ *
5
+ * Transport: query({model:'haiku'}) from claude-agent-sdk (Claude Code OAuth,
6
+ * or ANTHROPIC_API_KEY — same as Claude Code itself). The first ask creates a
7
+ * Haiku session with framing + briefing + numbered document; subsequent asks
8
+ * resume the same session (the document stays in its context, questions are cheap).
9
+ * The transport can be swapped inside ask() without changing the store or tools.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+
16
+ // cwd for Haiku sessions: their transcripts go to a separate Claude Code project, not the agent's project.
17
+ // The directory is created on the first question, not at module load (the hook and tests don't need it).
18
+ const workDir = () => process.env.READER_WORK_DIR || path.join(os.tmpdir(), 'reader-work');
19
+
20
+ // Claude Code binary for the Haiku sessions. The SDK ships its own as an optional per-platform package
21
+ // (~280 MB); the plugin starts the server via `npx --omit=optional` and reuses the user's `claude`.
22
+ // Order: $READER_CLAUDE_EXECUTABLE → the SDK's bundled binary, if installed → `claude` on PATH.
23
+ function bundledBinaryInstalled() {
24
+ const base = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`;
25
+ for (const name of [base, `${base}-musl`]) {
26
+ try { require.resolve(`${name}/package.json`); return true; } catch (_) { /* not installed */ }
27
+ }
28
+ return false;
29
+ }
30
+
31
+ function findOnPath(cmd, env = process.env) {
32
+ const exts = process.platform === 'win32' ? ['.exe', '.cmd', ''] : [''];
33
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
34
+ if (!dir) continue;
35
+ for (const ext of exts) {
36
+ const p = path.join(dir, cmd + ext);
37
+ try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return p; } catch (_) { /* next */ }
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** @returns {string|undefined} path for options.pathToClaudeCodeExecutable; undefined = the SDK's bundled binary */
44
+ function resolveClaudeExecutable({ env = process.env, bundled = bundledBinaryInstalled } = {}) {
45
+ if (env.READER_CLAUDE_EXECUTABLE) return env.READER_CLAUDE_EXECUTABLE;
46
+ if (bundled()) return undefined;
47
+ const found = findOnPath('claude', env);
48
+ if (!found) {
49
+ throw new Error('reader_ask needs Claude Code: put `claude` on PATH or set READER_CLAUDE_EXECUTABLE '
50
+ + '(the Agent SDK\'s bundled binary is not installed)');
51
+ }
52
+ return found;
53
+ }
54
+
55
+ const READER_FRAMING = `You are a document reader serving a main AI agent. Below is a document with line numbers ("N<TAB>text") and a BRIEFING explaining why the agent opened it.
56
+
57
+ RULES (strict, apply to every answer in this conversation):
58
+ 1. ANSWER concisely and densely. Every factual claim cites line numbers: (L120) or (L120-134). If the answer is not in the document, say "not in document" — NEVER invent or fill gaps from general knowledge.
59
+ 2. VERBATIM PRESERVATION: reproduce numbers, file paths, identifiers, versions, URLs and error-message text EXACTLY as written, character for character. Never round numbers, never paraphrase error text.
60
+ 3. End EVERY response with a "NOTICED:" section — important things the agent did NOT ask about: errors, warnings, anomalies, contradictions, secrets/keys, TODOs/FIXMEs, deprecations, suspicious values. One line each with citations. Do not repeat items already reported earlier in this conversation. If nothing new: exactly "NOTICED: nothing new".
61
+ 4. If a question is ambiguous, answer the most likely reading and flag the ambiguity in one line.
62
+ Keep answers under ~400 words unless enumeration is demanded.`;
63
+
64
+ /**
65
+ * Question transport: a single-turn Haiku call via agent-sdk; resume continues the dialog.
66
+ * @param {object} [o]
67
+ * @param {string} [o.cwd] cwd for Haiku sessions (default $READER_WORK_DIR or $TMPDIR/reader-work); created on call
68
+ * @param {object} [o.settings] settings for query (e.g. {cleanupPeriodDays: 365} — transcript retention period)
69
+ * @param {number} [o.cleanupPeriodDays] shorthand for settings.cleanupPeriodDays
70
+ * @param {string} [o.executable] Claude Code binary (default: resolveClaudeExecutable() on each call)
71
+ * @returns {Function} (prompt, resumeSid, {fork}) → {out, sid}
72
+ */
73
+ function createRunQuery({ cwd: cwdOpt, settings, cleanupPeriodDays, executable } = {}) {
74
+ const merged = { ...(settings || {}), ...(cleanupPeriodDays !== undefined ? { cleanupPeriodDays } : {}) };
75
+ const hasSettings = Object.keys(merged).length > 0;
76
+ // fork — branch off the resumeSid session (parallel question to the same document): the base is unchanged.
77
+ return async function runQuery(prompt, resumeSid, { fork = false } = {}) {
78
+ const { query } = require('@anthropic-ai/claude-agent-sdk');
79
+ const cwd = cwdOpt || workDir();
80
+ const exe = executable || resolveClaudeExecutable();
81
+ fs.mkdirSync(cwd, { recursive: true });
82
+ let out = '';
83
+ let sid = resumeSid || null;
84
+ const q = query({
85
+ prompt,
86
+ options: {
87
+ cwd,
88
+ // Do not inherit the user's settings.json: their hooks (including the reader hook itself
89
+ // and Stop hooks that replace the result) must not affect the reader session.
90
+ settingSources: [],
91
+ ...(hasSettings ? { settings: merged } : {}),
92
+ tools: { type: 'preset', preset: 'claude_code' },
93
+ permissionMode: 'default',
94
+ canUseTool: async (n, i) => ({ behavior: 'deny', message: 'reader is read-only' }),
95
+ model: 'haiku',
96
+ allowedTools: [],
97
+ maxTurns: 10,
98
+ ...(exe ? { pathToClaudeCodeExecutable: exe } : {}),
99
+ ...(resumeSid ? { resume: resumeSid } : {}),
100
+ ...(resumeSid && fork ? { forkSession: true } : {}),
101
+ },
102
+ });
103
+ for await (const m of q) {
104
+ if (m.session_id) sid = m.session_id;
105
+ if (m.type === 'result') out = m.result || '';
106
+ }
107
+ return { out, sid };
108
+ };
109
+ }
110
+
111
+ const defaultRunQuery = createRunQuery();
112
+
113
+ // ---- "lines" answer format ----
114
+ // Most of the answer latency is generating a long answer (<=400 words + NOTICED), not the prompt
115
+ // or tools. An answer of "line ranges + a note of <=40 words" takes ~5–7 s, and the ranges are
116
+ // accurate. The line text is filled in by the SERVER from the file (store.peek), so it is verbatim
117
+ // by construction: Haiku does not retype or distort code.
118
+ const LINES_CAP = 160; // lines shown verbatim in one answer; the rest are listed as ranges for reader_peek
119
+
120
+ // NOTICED is not requested in lines mode: scanning the whole document for "anything else suspicious"
121
+ // made the first answer take 12–16 s versus 7–9 s without it, while subagents typically ask a document
122
+ // a single question right after opening it. Suspicious items can be requested with a separate question.
123
+ function linesInstruction() {
124
+ return '\n\nANSWER FORMAT for this question (overrides rule 1 prose and rule 3 — NO NOTICED section in this answer): ONLY one JSON object, no other text:\n'
125
+ + '{"ranges":[[from,to],...],"note":"the direct answer in <=40 words; numbers, identifiers, paths verbatim"}\n'
126
+ + 'ranges = the fewest document lines that answer the question; the agent will see exactly these lines verbatim. '
127
+ + 'If the answer is not in the document: "ranges":[] and say so in note.';
128
+ }
129
+
130
+ // First parseable JSON object in the answer (Haiku sometimes appends text after it).
131
+ function extractJson(raw) {
132
+ const s = String(raw || '');
133
+ const start = s.indexOf('{');
134
+ if (start === -1) return null;
135
+ for (let end = s.lastIndexOf('}'); end > start; end = s.lastIndexOf('}', end - 1)) {
136
+ try { return JSON.parse(s.slice(start, end + 1)); } catch (_) { /* try shorter */ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function renderLines(store, entry, raw) {
142
+ const j = extractJson(raw);
143
+ if (!j || !Array.isArray(j.ranges)) return `(answer not in lines format — shown as is)\n${raw}`;
144
+ const total = entry.meta.lines;
145
+ const rs = j.ranges
146
+ .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)))])
148
+ .filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && a <= b)
149
+ .sort((x, y) => x[0] - y[0]);
150
+ const merged = [];
151
+ for (const r of rs) {
152
+ const last = merged[merged.length - 1];
153
+ if (last && r[0] <= last[1] + 1) last[1] = Math.max(last[1], r[1]); else merged.push([...r]);
154
+ }
155
+ let budget = LINES_CAP;
156
+ const parts = [];
157
+ const cut = [];
158
+ for (const [a, b] of merged) {
159
+ if (budget <= 0) { cut.push(`${a}-${b}`); continue; }
160
+ 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}`);
164
+ }
165
+ 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)`;
168
+ if (Array.isArray(j.noticed)) {
169
+ out += j.noticed.length ? `\n\nNOTICED:\n${j.noticed.map(x => `- ${x}`).join('\n')}` : '\n\nNOTICED: nothing new';
170
+ }
171
+ return out.trim();
172
+ }
173
+
174
+ function numberLines(text) {
175
+ return text.split('\n').map((l, i) => `${i + 1}\t${l}`).join('\n');
176
+ }
177
+
178
+ /**
179
+ * Ask the reader a question.
180
+ * A single question continues the dialog (entry.lock; the haikuSid session remembers earlier questions).
181
+ * A question that arrives while another one to the same document is in flight runs IN PARALLEL, without
182
+ * queueing: as a fork of the existing session (forkSession) or, if there is no session yet, in its own
183
+ * session with the document. Agents often ask questions in a batch, and queueing them would add up
184
+ * their latencies (e.g. 16 + 6.5 + 8.6 = 31 s).
185
+ * A parallel question does not see its siblings (they were written without each other's answers); the
186
+ * dialog base is changed only by a sequential question — or by a parallel one if there is no base yet.
187
+ * @param {object} store ReaderStore (for readRaw)
188
+ * @param {object} entry entry from store.get()
189
+ * @param {string} question
190
+ * @param {Function} [runQuery] transport injection for tests: (prompt, resumeSid, {fork}) → {out, sid}
191
+ * @param {object} [opts] format: 'prose' (connected answer with NOTICED) | 'lines' (verbatim lines + note)
192
+ * @returns {Promise<string>}
193
+ */
194
+ async function ask(store, entry, question, runQuery = defaultRunQuery, opts = {}) {
195
+ const lines = opts.format === 'lines';
196
+ const gate = store.canAsk(entry);
197
+ if (!gate.ok) throw new Error(gate.reason);
198
+
199
+ const parallel = (entry.inFlight || 0) > 0;
200
+ entry.inFlight = (entry.inFlight || 0) + 1;
201
+ const exec = async () => {
202
+ const base = entry.haikuSid;
203
+ const q = lines ? question + linesInstruction() : question;
204
+ let prompt;
205
+ if (!base) {
206
+ const doc = numberLines(store.readRaw(entry.id));
207
+ 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}`;
209
+ } else {
210
+ prompt = q;
211
+ }
212
+ const { out, sid } = await runQuery(prompt, base, { fork: parallel && !!base });
213
+ if (!out) throw new Error('haiku returned an empty answer (transport problem?)');
214
+ // save the session ONLY on success — otherwise the dialog is not "corrupted"
215
+ if (!parallel || !entry.haikuSid) entry.haikuSid = sid;
216
+ entry.rehydrated = false;
217
+ entry.asks++;
218
+ return lines ? renderLines(store, entry, out) : out;
219
+ };
220
+ let run;
221
+ if (parallel) run = exec();
222
+ else {
223
+ run = entry.lock.then(exec);
224
+ // even on error the lock chain must not break for subsequent asks
225
+ entry.lock = run.catch(() => {});
226
+ }
227
+ return run.finally(() => { entry.inFlight--; });
228
+ }
229
+
230
+ module.exports = { ask, createRunQuery, defaultRunQuery, resolveClaudeExecutable, READER_FRAMING, numberLines, renderLines, extractJson, LINES_CAP };