@agent-sh/harness-read 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Avi Fenesh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @agent-sh/harness-read
2
+
3
+ Safe, bounded, line-numbered file reading with pagination, binary refusal, and fuzzy-sibling NOT_FOUND.
4
+
5
+ Part of the [`@agent-sh/harness-*`](https://github.com/avifenesh/tools) monorepo — see the top-level README for architectural context and the full tool surface.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @agent-sh/harness-read
11
+ ```
12
+
13
+ Requires Node ≥ 20.
14
+
15
+ ## Usage
16
+
17
+ ```ts
18
+ import { read } from "@agent-sh/harness-read";
19
+
20
+ const r = await read(
21
+ { path: "src/index.ts", offset: 1, limit: 200 },
22
+ { cwd: process.cwd(), permissions: { roots: [process.cwd()], sensitivePatterns: [] } },
23
+ );
24
+
25
+ if (r.kind === "text") console.log(r.output);
26
+ ```
27
+
28
+ ## Contract
29
+
30
+ The full contract — input shape, output discriminated-union, error codes, permission model, and acceptance tests — lives in [`agent-knowledge/design/read.md`](https://github.com/avifenesh/tools/blob/main/agent-knowledge/design/read.md). Changes to this package must stay in sync with that spec.
31
+
32
+ ## License
33
+
34
+ MIT © Avi Fenesh
package/dist/index.cjs ADDED
@@ -0,0 +1,590 @@
1
+ 'use strict';
2
+
3
+ var buffer = require('buffer');
4
+ var crypto = require('crypto');
5
+ var path3 = require('path');
6
+ var harnessCore = require('@agent-sh/harness-core');
7
+ var v = require('valibot');
8
+
9
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
10
+
11
+ function _interopNamespace(e) {
12
+ if (e && e.__esModule) return e;
13
+ var n = Object.create(null);
14
+ if (e) {
15
+ Object.keys(e).forEach(function (k) {
16
+ if (k !== 'default') {
17
+ var d = Object.getOwnPropertyDescriptor(e, k);
18
+ Object.defineProperty(n, k, d.get ? d : {
19
+ enumerable: true,
20
+ get: function () { return e[k]; }
21
+ });
22
+ }
23
+ });
24
+ }
25
+ n.default = e;
26
+ return Object.freeze(n);
27
+ }
28
+
29
+ var path3__default = /*#__PURE__*/_interopDefault(path3);
30
+ var v__namespace = /*#__PURE__*/_interopNamespace(v);
31
+
32
+ // src/read.ts
33
+
34
+ // src/constants.ts
35
+ var DEFAULT_LIMIT = 2e3;
36
+ var MAX_LINE_LENGTH = 2e3;
37
+ var MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
38
+ var MAX_BYTES = 50 * 1024;
39
+ var MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
40
+ var MAX_FILE_SIZE = 5 * 1024 * 1024;
41
+ var BINARY_SAMPLE_BYTES = 4096;
42
+ var FUZZY_SUGGESTION_LIMIT = 3;
43
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
44
+ ".zip",
45
+ ".tar",
46
+ ".gz",
47
+ ".exe",
48
+ ".dll",
49
+ ".so",
50
+ ".class",
51
+ ".jar",
52
+ ".war",
53
+ ".7z",
54
+ ".doc",
55
+ ".docx",
56
+ ".xls",
57
+ ".xlsx",
58
+ ".ppt",
59
+ ".pptx",
60
+ ".odt",
61
+ ".ods",
62
+ ".odp",
63
+ ".bin",
64
+ ".dat",
65
+ ".obj",
66
+ ".o",
67
+ ".a",
68
+ ".lib",
69
+ ".wasm",
70
+ ".pyc",
71
+ ".pyo"
72
+ ]);
73
+
74
+ // src/binary.ts
75
+ function isBinaryByExtension(filepath) {
76
+ return BINARY_EXTENSIONS.has(path3__default.default.extname(filepath).toLowerCase());
77
+ }
78
+ function isBinaryByContent(sample) {
79
+ if (sample.length === 0) return false;
80
+ let nonPrintable = 0;
81
+ for (let i = 0; i < sample.length; i++) {
82
+ const b = sample[i];
83
+ if (b === 0) return true;
84
+ if (b < 9 || b > 13 && b < 32) nonPrintable++;
85
+ }
86
+ return nonPrintable / sample.length > 0.3;
87
+ }
88
+ function isBinary(filepath, sample) {
89
+ return isBinaryByExtension(filepath) || isBinaryByContent(sample);
90
+ }
91
+ function isImageMime(mime) {
92
+ if (!mime.startsWith("image/")) return false;
93
+ if (mime === "image/svg+xml") return false;
94
+ return true;
95
+ }
96
+ function isPdfMime(mime) {
97
+ return mime === "application/pdf";
98
+ }
99
+
100
+ // src/format.ts
101
+ function formatText(params) {
102
+ const { path: path4, offset, lines, totalLines, more, byteCap } = params;
103
+ const header = `<path>${path4}</path>
104
+ <type>file</type>
105
+ <content>`;
106
+ if (lines.length === 0 && totalLines === 0) {
107
+ return `${header}
108
+ (File exists but is empty)
109
+ </content>`;
110
+ }
111
+ const body = lines.map((line, i) => `${offset + i}: ${line}`).join("\n");
112
+ const last = offset + lines.length - 1;
113
+ const next = last + 1;
114
+ let hint;
115
+ if (byteCap) {
116
+ const pct = totalLines > 0 ? Math.round(last / totalLines * 100) : 0;
117
+ const remaining = Math.max(totalLines - last, 0);
118
+ hint = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${offset}-${last} of ${totalLines} \xB7 ${pct}% covered \xB7 ${remaining} lines remaining. Next offset: ${next}.)`;
119
+ } else if (more) {
120
+ const pct = Math.round(last / totalLines * 100);
121
+ const remaining = Math.max(totalLines - last, 0);
122
+ hint = `(Showing lines ${offset}-${last} of ${totalLines} \xB7 ${pct}% covered \xB7 ${remaining} lines remaining. Next offset: ${next}.)`;
123
+ } else {
124
+ hint = `(End of file \xB7 ${totalLines} lines total)`;
125
+ }
126
+ return `${header}
127
+ ${body}
128
+
129
+ ${hint}
130
+ </content>`;
131
+ }
132
+ function formatDirectory(params) {
133
+ const { path: path4, entries, offset, totalEntries, more } = params;
134
+ const header = `<path>${path4}</path>
135
+ <type>directory</type>
136
+ <entries>`;
137
+ const body = entries.join("\n");
138
+ const last = offset + entries.length - 1;
139
+ const next = last + 1;
140
+ const remaining = Math.max(totalEntries - last, 0);
141
+ const hint = more ? `(Showing ${entries.length} of ${totalEntries} entries \xB7 ${remaining} remaining. Next offset: ${next}.)` : `(${totalEntries} entries)`;
142
+ return `${header}
143
+ ${body}
144
+
145
+ ${hint}
146
+ </entries>`;
147
+ }
148
+ function formatAttachment(kind) {
149
+ return `${kind} read successfully`;
150
+ }
151
+ async function streamLines(ops, path4, opts) {
152
+ const maxBytes = opts.maxBytes ?? MAX_BYTES;
153
+ const maxLineLen = opts.maxLineLength ?? MAX_LINE_LENGTH;
154
+ const start = opts.offset - 1;
155
+ const out = [];
156
+ let bytes = 0;
157
+ let totalLines = 0;
158
+ let more = false;
159
+ let byteCap = false;
160
+ const signalOpt = {};
161
+ if (opts.signal !== void 0) signalOpt.signal = opts.signal;
162
+ const iter = ops.openLineStream(path4, signalOpt);
163
+ for await (const raw of iter) {
164
+ totalLines += 1;
165
+ if (totalLines <= start) continue;
166
+ if (out.length >= opts.limit) {
167
+ more = true;
168
+ continue;
169
+ }
170
+ const truncated = raw.length > maxLineLen ? raw.substring(0, maxLineLen) + MAX_LINE_SUFFIX : raw;
171
+ const size = buffer.Buffer.byteLength(truncated, "utf8") + (out.length > 0 ? 1 : 0);
172
+ if (bytes + size > maxBytes) {
173
+ byteCap = true;
174
+ more = true;
175
+ break;
176
+ }
177
+ out.push(truncated);
178
+ bytes += size;
179
+ }
180
+ return {
181
+ lines: out,
182
+ totalLines,
183
+ offset: opts.offset,
184
+ more,
185
+ byteCap
186
+ };
187
+ }
188
+ var ReadParamsSchema = v__namespace.object({
189
+ path: v__namespace.pipe(v__namespace.string(), v__namespace.minLength(1, "path must not be empty")),
190
+ offset: v__namespace.optional(
191
+ v__namespace.pipe(v__namespace.number(), v__namespace.integer(), v__namespace.minValue(1, "offset must be >= 1"))
192
+ ),
193
+ limit: v__namespace.optional(
194
+ v__namespace.pipe(v__namespace.number(), v__namespace.integer(), v__namespace.minValue(1, "limit must be >= 1"))
195
+ )
196
+ });
197
+ function parseReadParams(input) {
198
+ return v__namespace.parse(ReadParamsSchema, input);
199
+ }
200
+ function safeParseReadParams(input) {
201
+ const result = v__namespace.safeParse(ReadParamsSchema, input);
202
+ if (result.success) return { ok: true, value: result.output };
203
+ return { ok: false, issues: result.issues };
204
+ }
205
+ var READ_TOOL_NAME = "read";
206
+ var READ_TOOL_DESCRIPTION = `Read a file or directory from the local filesystem.
207
+
208
+ Usage:
209
+ - The path parameter should be an absolute path. If relative, it resolves against the session working directory.
210
+ - By default, returns up to 2000 lines from the start of the file.
211
+ - The offset parameter is the 1-indexed line number to start from.
212
+ - For later sections, call this tool again with a larger offset.
213
+ - Use the grep tool for content search in large files; glob to locate files by pattern.
214
+ - Contents are returned with each line prefixed by its line number as "<line>: <content>".
215
+ - Any line longer than 2000 characters is truncated.
216
+ - Call this tool in parallel when reading multiple files.
217
+ - Avoid tiny repeated slices (under 30 lines). Read a larger window instead.
218
+ - Images and PDFs are returned as file attachments.
219
+ - Binary files are refused; use specialized tools.`;
220
+ var readToolDefinition = {
221
+ name: READ_TOOL_NAME,
222
+ description: READ_TOOL_DESCRIPTION,
223
+ inputSchema: {
224
+ type: "object",
225
+ properties: {
226
+ path: {
227
+ type: "string",
228
+ description: "Absolute path (preferred) or path relative to the session cwd."
229
+ },
230
+ offset: {
231
+ type: "integer",
232
+ minimum: 1,
233
+ description: "1-indexed line number to start reading from."
234
+ },
235
+ limit: {
236
+ type: "integer",
237
+ minimum: 1,
238
+ description: "Maximum number of lines to read. Defaults to 2000."
239
+ }
240
+ },
241
+ required: ["path"],
242
+ additionalProperties: false
243
+ }
244
+ };
245
+ async function suggestSiblings(ops, missingPath) {
246
+ const dir = path3__default.default.dirname(missingPath);
247
+ const base = path3__default.default.basename(missingPath).toLowerCase();
248
+ let entries;
249
+ try {
250
+ entries = await ops.readDirectory(dir);
251
+ } catch {
252
+ return [];
253
+ }
254
+ const scored = [];
255
+ for (const entry of entries) {
256
+ const lower = entry.toLowerCase();
257
+ const score = similarity(base, lower);
258
+ if (score > 0) scored.push({ p: path3__default.default.join(dir, entry), score });
259
+ }
260
+ scored.sort((a, b) => b.score - a.score);
261
+ return scored.slice(0, FUZZY_SUGGESTION_LIMIT).map((s) => s.p);
262
+ }
263
+ function similarity(a, b) {
264
+ if (a === b) return 1e3;
265
+ if (a.length === 0 || b.length === 0) return 0;
266
+ if (a.includes(b) || b.includes(a)) return 500;
267
+ const prefix = commonPrefix(a, b);
268
+ if (prefix >= 3) return 200 + prefix;
269
+ if (prefix >= 2 && Math.abs(a.length - b.length) <= 2) return 100 + prefix;
270
+ const aExt = extOf(a);
271
+ const bExt = extOf(b);
272
+ if (aExt && aExt === bExt) return 10;
273
+ return 0;
274
+ }
275
+ function commonPrefix(a, b) {
276
+ const n = Math.min(a.length, b.length);
277
+ let i = 0;
278
+ while (i < n && a[i] === b[i]) i++;
279
+ return i;
280
+ }
281
+ function extOf(name) {
282
+ const dot = name.lastIndexOf(".");
283
+ return dot > 0 ? name.slice(dot) : "";
284
+ }
285
+
286
+ // src/read.ts
287
+ function err(error) {
288
+ return { kind: "error", error };
289
+ }
290
+ async function read(input, session) {
291
+ const parsed = safeParseReadParams(input);
292
+ if (!parsed.ok) {
293
+ const messages = parsed.issues.map((i) => i.message).join("; ");
294
+ return err(harnessCore.toolError("INVALID_PARAM", messages, { cause: parsed.issues }));
295
+ }
296
+ const params = parsed.value;
297
+ const ops = session.ops ?? harnessCore.defaultNodeOperations();
298
+ const resolvedPath = await resolvePath(ops, session.cwd, params.path);
299
+ const fence = await fencePath(ops, session, resolvedPath);
300
+ if (fence !== void 0) return err(fence);
301
+ return harnessCore.withFileLock(
302
+ resolvedPath,
303
+ () => executeRead(ops, session, resolvedPath, params)
304
+ );
305
+ }
306
+ async function resolvePath(ops, cwd, input) {
307
+ const absolute = path3__default.default.isAbsolute(input) ? input : path3__default.default.resolve(cwd, input);
308
+ try {
309
+ return await ops.realpath(absolute);
310
+ } catch {
311
+ return absolute;
312
+ }
313
+ }
314
+ async function fencePath(ops, session, resolvedPath) {
315
+ const { permissions } = session;
316
+ const isSensitive = harnessCore.matchesAnyPattern(
317
+ resolvedPath,
318
+ permissions.sensitivePatterns
319
+ );
320
+ const insideWorkspace = harnessCore.isInsideAnyRoot(resolvedPath, permissions.roots);
321
+ const needsAsk = isSensitive || !insideWorkspace && permissions.bypassWorkspaceGuard !== true;
322
+ if (isSensitive && permissions.hook === void 0) {
323
+ return harnessCore.toolError(
324
+ "SENSITIVE",
325
+ `Refusing to read sensitive path: ${resolvedPath}`,
326
+ { meta: { path: resolvedPath } }
327
+ );
328
+ }
329
+ if (!insideWorkspace && permissions.bypassWorkspaceGuard !== true && permissions.hook === void 0) {
330
+ if (process.env.E2E_DEBUG_PERMISSIONS) {
331
+ console.error(
332
+ `[read fencePath] OUTSIDE_WORKSPACE reject: resolvedPath=${JSON.stringify(resolvedPath)} roots=${JSON.stringify(permissions.roots)}`
333
+ );
334
+ }
335
+ return harnessCore.toolError(
336
+ "OUTSIDE_WORKSPACE",
337
+ `Path is outside all configured workspace roots: ${resolvedPath}`,
338
+ { meta: { path: resolvedPath, roots: permissions.roots } }
339
+ );
340
+ }
341
+ if (permissions.hook !== void 0) {
342
+ const reason = isSensitive ? "sensitive" : !insideWorkspace ? "outside_workspace" : "in_workspace";
343
+ const alwaysPatterns = needsAsk ? [path3__default.default.dirname(resolvedPath) + "/*"] : ["*"];
344
+ const decision = await permissions.hook({
345
+ tool: "read",
346
+ path: resolvedPath,
347
+ action: "read",
348
+ always_patterns: alwaysPatterns,
349
+ metadata: { reason }
350
+ });
351
+ if (decision === "deny") {
352
+ return harnessCore.toolError(
353
+ "PERMISSION_DENIED",
354
+ `Read denied by user: ${resolvedPath}`,
355
+ { meta: { path: resolvedPath } }
356
+ );
357
+ }
358
+ }
359
+ return void 0;
360
+ }
361
+ async function executeRead(ops, session, resolvedPath, params) {
362
+ let stat;
363
+ try {
364
+ stat = await ops.stat(resolvedPath);
365
+ } catch (e) {
366
+ return err(
367
+ harnessCore.toolError("IO_ERROR", `stat failed: ${e.message}`, {
368
+ cause: e
369
+ })
370
+ );
371
+ }
372
+ if (!stat) {
373
+ const suggestions = await suggestSiblings(ops, resolvedPath);
374
+ const msg = suggestions.length > 0 ? `File not found: ${resolvedPath}
375
+
376
+ Did you mean one of these?
377
+ ${suggestions.join("\n")}` : `File not found: ${resolvedPath}`;
378
+ return err(
379
+ harnessCore.toolError("NOT_FOUND", msg, { meta: { path: resolvedPath, suggestions } })
380
+ );
381
+ }
382
+ if (stat.type === "directory") {
383
+ return readDirectory(ops, resolvedPath, params);
384
+ }
385
+ const maxSize = session.maxFileSize ?? MAX_FILE_SIZE;
386
+ if (stat.size > maxSize) {
387
+ return err(
388
+ harnessCore.toolError(
389
+ "TOO_LARGE",
390
+ `File size ${stat.size} exceeds max ${maxSize}. Use a narrower offset/limit or grep first.`,
391
+ { meta: { path: resolvedPath, size: stat.size, maxSize } }
392
+ )
393
+ );
394
+ }
395
+ const half = session.modelContextTokens ? Math.floor(session.modelContextTokens / 2) : void 0;
396
+ const tokensPerByte = session.tokensPerByte ?? 0.3;
397
+ if (half !== void 0 && stat.size * tokensPerByte > half) {
398
+ return err(
399
+ harnessCore.toolError(
400
+ "TOO_LARGE",
401
+ `File would consume more than half of the model context (~${Math.floor(stat.size * tokensPerByte)} tokens > ${half}). Use offset/limit or grep first.`,
402
+ { meta: { path: resolvedPath, size: stat.size, half } }
403
+ )
404
+ );
405
+ }
406
+ const mime = ops.mimeType(resolvedPath);
407
+ if (isImageMime(mime) || isPdfMime(mime)) {
408
+ return readAttachment(ops, resolvedPath, mime, stat.size);
409
+ }
410
+ const sample = await readSample(ops, resolvedPath, stat.size);
411
+ if (isBinary(resolvedPath, sample)) {
412
+ return err(
413
+ harnessCore.toolError("BINARY", `Cannot read binary file: ${resolvedPath}`, {
414
+ meta: { path: resolvedPath }
415
+ })
416
+ );
417
+ }
418
+ return readText(ops, session, resolvedPath, stat, params);
419
+ }
420
+ async function readSample(ops, p, size) {
421
+ if (size === 0) return new Uint8Array();
422
+ const bytes = await ops.readFile(p);
423
+ return bytes.length > BINARY_SAMPLE_BYTES ? bytes.subarray(0, BINARY_SAMPLE_BYTES) : bytes;
424
+ }
425
+ async function readDirectory(ops, resolvedPath, params) {
426
+ const entries = await ops.readDirectoryEntries(resolvedPath);
427
+ const named = await Promise.all(
428
+ entries.map(async (e) => {
429
+ if (e.type === "directory") return e.name + "/";
430
+ if (e.type !== "symlink") return e.name;
431
+ const target = await ops.stat(path3__default.default.join(resolvedPath, e.name)).catch(() => void 0);
432
+ return target?.type === "directory" ? e.name + "/" : e.name;
433
+ })
434
+ );
435
+ named.sort((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
436
+ const offset = params.offset ?? 1;
437
+ const limit = params.limit ?? DEFAULT_LIMIT;
438
+ const start = offset - 1;
439
+ const sliced = named.slice(start, start + limit);
440
+ const more = start + sliced.length < named.length;
441
+ const output = formatDirectory({
442
+ path: resolvedPath,
443
+ entries: sliced,
444
+ offset,
445
+ totalEntries: named.length,
446
+ more
447
+ });
448
+ return {
449
+ kind: "directory",
450
+ output,
451
+ meta: {
452
+ path: resolvedPath,
453
+ totalEntries: named.length,
454
+ returnedEntries: sliced.length,
455
+ offset,
456
+ limit,
457
+ more
458
+ }
459
+ };
460
+ }
461
+ async function readAttachment(ops, resolvedPath, mime, size) {
462
+ const bytes = await ops.readFile(resolvedPath);
463
+ const kind = mime === "application/pdf" ? "PDF" : "Image";
464
+ const dataUrl = `data:${mime};base64,${buffer.Buffer.from(bytes).toString("base64")}`;
465
+ return {
466
+ kind: "attachment",
467
+ output: formatAttachment(kind),
468
+ attachments: [{ mime, dataUrl }],
469
+ meta: { path: resolvedPath, mime, size_bytes: size }
470
+ };
471
+ }
472
+ async function readText(ops, session, resolvedPath, stat, params) {
473
+ const offset = params.offset ?? 1;
474
+ const limit = params.limit ?? session.defaultLimit ?? DEFAULT_LIMIT;
475
+ if (session.cache) {
476
+ const cached = session.cache.get({
477
+ path: resolvedPath,
478
+ mtime_ms: stat.mtime_ms,
479
+ size_bytes: stat.size,
480
+ offset,
481
+ limit
482
+ });
483
+ if (cached) {
484
+ if (session.ledger) {
485
+ session.ledger.record({
486
+ path: resolvedPath,
487
+ sha256: cached.meta.sha256,
488
+ mtime_ms: stat.mtime_ms,
489
+ size_bytes: stat.size,
490
+ lines_returned: cached.meta.returnedLines,
491
+ offset,
492
+ limit,
493
+ timestamp_ms: Date.now()
494
+ });
495
+ }
496
+ return cached;
497
+ }
498
+ }
499
+ const lineStreamOpts = { offset, limit };
500
+ if (session.maxBytes !== void 0) lineStreamOpts.maxBytes = session.maxBytes;
501
+ if (session.maxLineLength !== void 0)
502
+ lineStreamOpts.maxLineLength = session.maxLineLength;
503
+ if (session.signal !== void 0) lineStreamOpts.signal = session.signal;
504
+ const result = await streamLines(ops, resolvedPath, lineStreamOpts);
505
+ if (result.totalLines > 0 && offset > result.totalLines) {
506
+ return err(
507
+ harnessCore.toolError(
508
+ "INVALID_PARAM",
509
+ `Offset ${offset} is out of range for this file (${result.totalLines} lines)`,
510
+ { meta: { path: resolvedPath, totalLines: result.totalLines } }
511
+ )
512
+ );
513
+ }
514
+ const bytes = await ops.readFile(resolvedPath);
515
+ const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
516
+ const output = formatText({
517
+ path: resolvedPath,
518
+ offset,
519
+ lines: result.lines,
520
+ totalLines: result.totalLines,
521
+ more: result.more,
522
+ byteCap: result.byteCap
523
+ });
524
+ const textResult = {
525
+ kind: "text",
526
+ output,
527
+ meta: {
528
+ path: resolvedPath,
529
+ totalLines: result.totalLines,
530
+ returnedLines: result.lines.length,
531
+ offset,
532
+ limit,
533
+ byteCap: result.byteCap,
534
+ more: result.more,
535
+ sha256,
536
+ mtime_ms: stat.mtime_ms,
537
+ size_bytes: stat.size
538
+ }
539
+ };
540
+ if (session.cache) {
541
+ session.cache.set(
542
+ {
543
+ path: resolvedPath,
544
+ mtime_ms: stat.mtime_ms,
545
+ size_bytes: stat.size,
546
+ offset,
547
+ limit
548
+ },
549
+ textResult
550
+ );
551
+ }
552
+ if (session.ledger) {
553
+ session.ledger.record({
554
+ path: resolvedPath,
555
+ sha256,
556
+ mtime_ms: stat.mtime_ms,
557
+ size_bytes: stat.size,
558
+ lines_returned: result.lines.length,
559
+ offset,
560
+ limit,
561
+ timestamp_ms: Date.now()
562
+ });
563
+ }
564
+ return textResult;
565
+ }
566
+
567
+ exports.BINARY_EXTENSIONS = BINARY_EXTENSIONS;
568
+ exports.DEFAULT_LIMIT = DEFAULT_LIMIT;
569
+ exports.MAX_BYTES = MAX_BYTES;
570
+ exports.MAX_FILE_SIZE = MAX_FILE_SIZE;
571
+ exports.MAX_LINE_LENGTH = MAX_LINE_LENGTH;
572
+ exports.READ_TOOL_DESCRIPTION = READ_TOOL_DESCRIPTION;
573
+ exports.READ_TOOL_NAME = READ_TOOL_NAME;
574
+ exports.ReadParamsSchema = ReadParamsSchema;
575
+ exports.formatAttachment = formatAttachment;
576
+ exports.formatDirectory = formatDirectory;
577
+ exports.formatText = formatText;
578
+ exports.isBinary = isBinary;
579
+ exports.isBinaryByContent = isBinaryByContent;
580
+ exports.isBinaryByExtension = isBinaryByExtension;
581
+ exports.isImageMime = isImageMime;
582
+ exports.isPdfMime = isPdfMime;
583
+ exports.parseReadParams = parseReadParams;
584
+ exports.read = read;
585
+ exports.readToolDefinition = readToolDefinition;
586
+ exports.safeParseReadParams = safeParseReadParams;
587
+ exports.streamLines = streamLines;
588
+ exports.suggestSiblings = suggestSiblings;
589
+ //# sourceMappingURL=index.cjs.map
590
+ //# sourceMappingURL=index.cjs.map