@nexrall/code-core 1.4.11 → 1.4.12

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 +1 @@
1
- {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAsgEtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
1
+ {"version":3,"file":"executor.d.ts","sourceRoot":"","sources":["../../src/tools/executor.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAC3C,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AA6iEtE,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,WAAW,CAAC,EAAE;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EAClC,OAAO,CAAC,EAAE,aAAa,EACvB,OAAO,CAAC,EAAE,MAAM,EAChB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,UAAU,CAAC,CAiBrB"}
@@ -52,8 +52,24 @@ const tsLangService_1 = require("./tsLangService");
52
52
  // ─── Constants ────────────────────────────────────────────────────────────────
53
53
  const DEFAULT_TIMEOUT_MS = 60000;
54
54
  const MAX_FETCH_BYTES = 200 * 1024; // 200 KB
55
- const MAX_READ_BYTES = 500 * 1024; // 500 KB
55
+ const MAX_READ_BYTES = 500 * 1024; // 500 KB — still used by notebook_read (JSON.parse needs it whole)
56
56
  const MAX_OUTPUT_CHARS = 100000; // bash / grep output cap (~100 KB)
57
+ // read_file has NO byte-size gate (unlike the old 500KB cutoff) — like Claude
58
+ // Code, a file of any size can be read; it's always streamed line-by-line so
59
+ // memory is bounded regardless of file size. Instead there are two independent
60
+ // caps per call, mirroring Claude Code's Read tool:
61
+ // - MAX_READ_LINES: a hard line-count ceiling (default window AND clamp on
62
+ // an explicit limit) so one call never floods context with thousands of
63
+ // short lines (JSON/minified/logs) just because the file is small in bytes.
64
+ // - MAX_READ_TOKENS: a hard token budget (rough chars/4 estimate). If the
65
+ // requested window would exceed it, we either return a PARTIAL page (no
66
+ // explicit limit — same UX as Claude Code's "first page + how to continue"
67
+ // notice) or an error (explicit limit given and it still doesn't fit —
68
+ // caller must shrink the limit or use search_files instead).
69
+ const MAX_READ_LINES = 2000;
70
+ const MAX_READ_TOKENS = 25000;
71
+ const READ_CHARS_PER_TOKEN = 4; // rough, conservative — consistent with loop.ts's resume estimator
72
+ const MAX_READ_CHARS = MAX_READ_TOKENS * READ_CHARS_PER_TOKEN; // 100,000 chars
57
73
  // GAP B — output spillover. The inline bash result keeps only HEAD+TAIL (~100 KB),
58
74
  // which loses the MIDDLE of a large log — often exactly where a stack trace's root
59
75
  // cause or a failing assertion lives. To make the full log recoverable WITHOUT
@@ -217,51 +233,99 @@ function isBinaryFile(filePath) {
217
233
  return false;
218
234
  }
219
235
  }
236
+ function formatNumberedLine(offset, indexInKept, line) {
237
+ return `${String(offset + indexInKept + 1).padStart(4, ' ')}\t${line}`;
238
+ }
220
239
  /**
221
- * Read a bounded line window [offset, offset+limit) from a file too large to load
222
- * whole, by streaming it in chunks and keeping only the requested lines. Memory is
223
- * bounded by (limit lines actually kept) + one chunk, never the whole file so a
224
- * 20MB bash-output spill file can be inspected section-by-section. (GAP B.)
240
+ * Read a bounded window [offset, offset+limit) of a file by streaming it in
241
+ * chunks memory is bounded by what's actually kept, never the whole file, so
242
+ * this is safe for files of ANY size (there is no byte-size gate on read_file
243
+ * at all, matching Claude Code). Two independent hard caps apply per call:
244
+ * - MAX_READ_LINES lines
245
+ * - MAX_READ_TOKENS tokens (~MAX_READ_CHARS chars) of formatted output
246
+ * If an explicit `requestedLimit` was given and the token cap is hit before
247
+ * that many lines were produced, the call fails with an actionable error
248
+ * (mirrors Claude Code: "a read that passes an explicit offset or limit and
249
+ * still exceeds the token limit returns an error"). Otherwise (default,
250
+ * no explicit limit) it silently returns a partial page with a continuation
251
+ * note — never an error just for being a big file.
225
252
  */
226
- async function readLargeFileWindow(resolved, offset, limit, kb) {
253
+ async function readFileWindowed(resolved, offset, requestedLimit) {
227
254
  return new Promise((resolve) => {
228
- const endLine = offset + limit; // exclusive, 0-based
255
+ const explicitLimit = requestedLimit > 0;
256
+ const lineScanCap = explicitLimit ? Math.min(requestedLimit, MAX_READ_LINES) : MAX_READ_LINES;
257
+ const hardEndLine = offset + lineScanCap; // exclusive, 0-based
229
258
  const kept = [];
259
+ let keptChars = 0;
230
260
  let lineNo = 0; // 0-based index of the NEXT line to be completed
231
261
  let carry = ''; // partial line spanning chunk boundaries
232
262
  let stopped = false;
263
+ let hitTokenCap = false;
264
+ let sawEof = false;
233
265
  const stream = fs.createReadStream(resolved, { encoding: 'utf-8', highWaterMark: 256 * 1024 });
266
+ // Returns false if adding this line would exceed the token budget (line NOT kept).
267
+ const tryPushLine = (line) => {
268
+ const formatted = formatNumberedLine(offset, kept.length, line);
269
+ const added = formatted.length + 1; // +1 for the join newline
270
+ if (keptChars + added > MAX_READ_CHARS) {
271
+ hitTokenCap = true;
272
+ return false;
273
+ }
274
+ kept.push(line);
275
+ keptChars += added;
276
+ return true;
277
+ };
234
278
  const finish = () => {
235
279
  if (stopped)
236
280
  return;
237
281
  stopped = true;
238
282
  stream.destroy();
283
+ if (hitTokenCap && explicitLimit) {
284
+ resolve({
285
+ error: `Requested range (offset:${offset}, limit:${requestedLimit}) is too large — exceeds ${MAX_READ_TOKENS} tokens. ` +
286
+ `Pass a smaller limit, or use search_files to locate the relevant part first.`,
287
+ });
288
+ return;
289
+ }
239
290
  const first = offset + 1;
240
291
  const last = offset + kept.length;
241
- const numbered = kept.map((l, i) => `${String(offset + i + 1).padStart(4, ' ')}\t${l}`).join('\n');
242
- const note = kept.length < limit
292
+ const numbered = kept.map((l, i) => formatNumberedLine(offset, i, l)).join('\n');
293
+ const totalNote = sawEof ? `/${lineNo}` : '';
294
+ const note = sawEof
243
295
  ? ` (reached end of file at line ${last})`
244
- : ` (more lines follow — increase offset to continue)`;
245
- resolve({ output: `[File: ${resolved} — ${kb} KB, showing lines ${first}-${last}${note}]\n${numbered}` });
296
+ : ` (more lines follow — pass offset:${last} to continue, or search_files to locate the relevant part)`;
297
+ resolve({ output: `[File: ${resolved} — lines ${first}-${last}${totalNote}${note}]\n${numbered}` });
246
298
  };
247
299
  stream.on('data', (chunk) => {
248
300
  const text = carry + (typeof chunk === 'string' ? chunk : chunk.toString('utf-8'));
249
301
  const lines = text.split('\n');
250
302
  carry = lines.pop() ?? ''; // last element is an incomplete line (or '')
251
303
  for (const line of lines) {
252
- if (lineNo >= offset && lineNo < endLine)
253
- kept.push(line);
254
- lineNo++;
255
- if (lineNo >= endLine) {
256
- finish();
257
- return;
304
+ if (lineNo >= offset) {
305
+ if (lineNo >= hardEndLine) {
306
+ finish();
307
+ return;
308
+ }
309
+ if (!tryPushLine(line)) {
310
+ finish();
311
+ return;
312
+ }
258
313
  }
314
+ lineNo++;
259
315
  }
260
316
  });
261
317
  stream.on('end', () => {
318
+ if (stopped)
319
+ return;
262
320
  // Flush the final carry (file not ending in newline) if it's in range.
263
- if (!stopped && carry !== '' && lineNo >= offset && lineNo < endLine)
264
- kept.push(carry);
321
+ if (carry !== '' && lineNo >= offset && lineNo < hardEndLine) {
322
+ if (!tryPushLine(carry)) {
323
+ finish();
324
+ return;
325
+ }
326
+ lineNo++;
327
+ }
328
+ sawEof = true;
265
329
  finish();
266
330
  });
267
331
  stream.on('error', (err) => { if (!stopped) {
@@ -278,38 +342,19 @@ async function readFile(input, workDir) {
278
342
  return { error: 'Missing required parameter: path' };
279
343
  try {
280
344
  const resolved = resolvePath(filePath, workDir);
281
- const stat = fs.statSync(resolved);
345
+ fs.statSync(resolved); // throws ENOENT/EISDIR etc with a clear message if the path is bad
282
346
  // Reject binary files early — reading them as UTF-8 produces garbage.
347
+ // (isBinaryFile only samples the first 8KB, so this is cheap regardless of file size.)
283
348
  if (isBinaryFile(resolved)) {
284
349
  const ext = path.extname(resolved).toLowerCase();
285
350
  return { error: `Cannot read binary file: ${resolved} (${ext || 'no extension'}). Use a text-based tool or convert it first.` };
286
351
  }
287
- // Large-file path: when the file exceeds the in-memory cap, a full readFileSync
288
- // would OOM but we must still be able to inspect SECTIONS (this is what makes a
289
- // 20MB bash-output spill file usable, GAP B). If offset+limit are given we stream
290
- // line-by-line and materialise only the requested window; otherwise we require it.
291
- if (stat.size > MAX_READ_BYTES) {
292
- const kb = (stat.size / 1024).toFixed(0);
293
- if (limit <= 0) {
294
- return { error: `File too large (${kb} KB, max ${MAX_READ_BYTES / 1024} KB). Pass offset + limit to read a section (e.g. {offset:0, limit:500}), or search_files to locate the relevant part first.` };
295
- }
296
- return await readLargeFileWindow(resolved, offset, limit, kb);
297
- }
298
- const content = fs.readFileSync(resolved, 'utf-8');
299
- const allLines = content.split('\n');
300
- const totalLines = allLines.length;
301
- const startLine = offset; // 0-based
302
- const endLine = limit > 0 ? Math.min(startLine + limit, totalLines) : totalLines;
303
- const slice = allLines.slice(startLine, endLine);
304
- // Prefix each line with its 1-based line number (like cat -n / Claude Code view)
305
- const numbered = slice.map((l, i) => {
306
- const lineNo = String(startLine + i + 1).padStart(4, ' ');
307
- return `${lineNo}\t${l}`;
308
- }).join('\n');
309
- const rangeNote = (offset > 0 || limit > 0)
310
- ? ` lines ${startLine + 1}-${endLine}/${totalLines}`
311
- : ` ${totalLines} lines`;
312
- return { output: `[File: ${resolved} (${rangeNote})]\n${numbered}` };
352
+ // No byte-size gate a file of any size can be read; it's always streamed
353
+ // line-by-line so memory is bounded regardless of file size (matches Claude
354
+ // Code, which has no separate "too large" error either). Instead, one call
355
+ // is capped at MAX_READ_LINES lines and MAX_READ_TOKENS tokens (see
356
+ // readFileWindowed for exact semantics of each cap).
357
+ return await readFileWindowed(resolved, offset, limit);
313
358
  }
314
359
  catch (err) {
315
360
  return { error: err.message };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexrall/code-core",
3
- "version": "1.4.11",
3
+ "version": "1.4.12",
4
4
  "description": "Core agent loop, tools, and extension primitives for Nexrall Code — embed an AI coding agent in any Node.js application.",
5
5
  "license": "MIT",
6
6
  "author": "Nexrall <support@nexrall.com> (https://nexrall.com)",