@levnikolaevich/hex-line-mcp 1.2.0 → 1.3.1

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/lib/edit.mjs CHANGED
@@ -8,11 +8,12 @@
8
8
  * - dry_run preview, noop detection, diff output
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { writeFileSync } from "node:fs";
12
12
  import { diffLines } from "diff";
13
- import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
13
+ import { fnv1a, lineTag, rangeChecksum, parseChecksum, parseRef } from "./hash.mjs";
14
14
  import { validatePath } from "./security.mjs";
15
15
  import { getGraphDB, blastRadius, getRelativePath } from "./graph-enrich.mjs";
16
+ import { readText } from "./format.mjs";
16
17
 
17
18
  // Unicode characters visually similar to ASCII hyphen-minus (U+002D)
18
19
  const CONFUSABLE_HYPHENS = /[\u2010\u2011\u2012\u2013\u2014\u2212\uFE63\uFF0D]/g;
@@ -40,6 +41,24 @@ function restoreIndent(origLines, newLines) {
40
41
  });
41
42
  }
42
43
 
44
+ /**
45
+ * Build hash-annotated snippet around a position for error messages.
46
+ * @param {string[]} lines - file lines
47
+ * @param {number} centerIdx - 0-based center index
48
+ * @param {number} radius - lines before/after center (default 5)
49
+ * @returns {{ start: number, end: number, text: string }}
50
+ */
51
+ function buildErrorSnippet(lines, centerIdx, radius = 5) {
52
+ const start = Math.max(0, centerIdx - radius);
53
+ const end = Math.min(lines.length, centerIdx + radius + 1);
54
+ const text = lines.slice(start, end).map((line, i) => {
55
+ const num = start + i + 1;
56
+ const tag = lineTag(fnv1a(line));
57
+ return `${tag}.${num}\t${line}`;
58
+ }).join("\n");
59
+ return { start: start + 1, end, text };
60
+ }
61
+
43
62
  /**
44
63
  * Build a hash index of all lines, keeping only unique tags.
45
64
  * 2-char tags have collisions — duplicates are excluded to avoid wrong relocations.
@@ -65,21 +84,11 @@ function buildHashIndex(lines) {
65
84
  function findLine(lines, lineNum, expectedTag, hashIndex) {
66
85
  const idx = lineNum - 1;
67
86
  if (idx < 0 || idx >= lines.length) {
68
- const start = idx >= lines.length
69
- ? Math.max(0, lines.length - 10)
70
- : 0;
71
- const end = idx >= lines.length
72
- ? lines.length
73
- : Math.min(lines.length, 10);
74
- const snippet = lines.slice(start, end).map((line, i) => {
75
- const num = start + i + 1;
76
- const tag = lineTag(fnv1a(line));
77
- return `${tag}.${num}\t${line}`;
78
- }).join("\n");
79
-
87
+ const center = idx >= lines.length ? lines.length - 1 : 0;
88
+ const snip = buildErrorSnippet(lines, center);
80
89
  throw new Error(
81
90
  `Line ${lineNum} out of range (1-${lines.length}).\n\n` +
82
- `Current content (lines ${start + 1}-${end}):\n${snippet}\n\n` +
91
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
83
92
  `Tip: Use updated hashes above for retry.`
84
93
  );
85
94
  }
@@ -116,30 +125,14 @@ function findLine(lines, lineNum, expectedTag, hashIndex) {
116
125
  if (relocated !== undefined) return relocated;
117
126
  }
118
127
 
119
- // Build snippet with fresh hashes so agent can retry without re-reading
120
- const start = Math.max(0, idx - 5);
121
- const end = Math.min(lines.length, idx + 6);
122
- const snippet = lines.slice(start, end).map((line, i) => {
123
- const num = start + i + 1;
124
- const tag = lineTag(fnv1a(line));
125
- return `${tag}.${num}\t${line}`;
126
- }).join("\n");
127
-
128
+ const snip = buildErrorSnippet(lines, idx);
128
129
  throw new Error(
129
- `Hash mismatch line ${lineNum}: expected ${expectedTag}, got ${actual}.\n\n` +
130
- `Current content (lines ${start + 1}-${end}):\n${snippet}\n\n` +
130
+ `HASH_MISMATCH: line ${lineNum} expected ${expectedTag}, got ${actual}.\n\n` +
131
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
131
132
  `Tip: Use updated hashes above for retry.`
132
133
  );
133
134
  }
134
135
 
135
- /**
136
- * Parse a ref string: "ab.12" → { tag: "ab", line: 12 }
137
- */
138
- function parseRef(ref) {
139
- const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
140
- if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12"`);
141
- return { tag: m[1], line: parseInt(m[2], 10) };
142
- }
143
136
 
144
137
  /**
145
138
  * Context diff via `diff` package (Myers O(ND) algorithm).
@@ -243,7 +236,20 @@ function textReplace(content, oldText, newText, all) {
243
236
  const normNew = newText.replace(/\r\n/g, "\n");
244
237
 
245
238
  if (!all) {
246
- throw new Error("replace requires all:true (rename-all mode). For single replacements, use set_line or replace_lines with hash anchors.");
239
+ // Uniqueness check: count occurrences
240
+ const parts = norm.split(normOld);
241
+ const count = parts.length - 1;
242
+ if (count === 0) {
243
+ // Fall through to TEXT_NOT_FOUND below
244
+ } else if (count === 1) {
245
+ // Unique match — safe to replace single occurrence
246
+ return parts.join(normNew);
247
+ } else {
248
+ throw new Error(
249
+ `AMBIGUOUS_MATCH: "${normOld.slice(0, 80)}" found ${count} times. ` +
250
+ `Use all:true to replace all, or use set_line/replace_lines for a specific occurrence.`
251
+ );
252
+ }
247
253
  }
248
254
  let idx = norm.indexOf(normOld);
249
255
  let confusableMatch = false;
@@ -299,9 +305,10 @@ function textReplace(content, oldText, newText, all) {
299
305
  */
300
306
  export function editFile(filePath, edits, opts = {}) {
301
307
  const real = validatePath(filePath);
302
- const original = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
308
+ const original = readText(real);
303
309
  const lines = original.split("\n");
304
310
  const origLines = [...lines];
311
+ const hadTrailingNewline = original.endsWith("\n");
305
312
 
306
313
  // Build hash index once for global relocation in findLine
307
314
  const hashIndex = buildHashIndex(lines);
@@ -313,7 +320,35 @@ export function editFile(filePath, edits, opts = {}) {
313
320
  for (const e of edits) {
314
321
  if (e.set_line || e.replace_lines || e.insert_after) anchored.push(e);
315
322
  else if (e.replace) texts.push(e);
316
- else throw new Error(`Unknown edit type: ${JSON.stringify(e)}`);
323
+ else throw new Error(`BAD_INPUT: unknown edit type: ${JSON.stringify(e)}`);
324
+ }
325
+
326
+ // Overlap validation: reject duplicate/overlapping edit targets
327
+ const editTargets = [];
328
+ for (const e of anchored) {
329
+ if (e.set_line) {
330
+ const line = parseRef(e.set_line.anchor).line;
331
+ editTargets.push({ start: line, end: line });
332
+ } else if (e.replace_lines) {
333
+ const s = parseRef(e.replace_lines.start_anchor).line;
334
+ const en = parseRef(e.replace_lines.end_anchor).line;
335
+ editTargets.push({ start: s, end: en });
336
+ } else if (e.insert_after) {
337
+ const line = parseRef(e.insert_after.anchor).line;
338
+ editTargets.push({ start: line, end: line, insert: true });
339
+ }
340
+ }
341
+ for (let i = 0; i < editTargets.length; i++) {
342
+ for (let j = i + 1; j < editTargets.length; j++) {
343
+ const a = editTargets[i], b = editTargets[j];
344
+ if (a.insert || b.insert) continue; // insert_after doesn't overlap
345
+ if (a.start <= b.end && b.start <= a.end) {
346
+ throw new Error(
347
+ `OVERLAPPING_EDITS: lines ${a.start}-${a.end} and ${b.start}-${b.end} overlap. ` +
348
+ `Split into separate edit_file calls.`
349
+ );
350
+ }
351
+ }
317
352
  }
318
353
 
319
354
  // Sort anchor edits bottom-to-top
@@ -348,12 +383,45 @@ export function editFile(filePath, edits, opts = {}) {
348
383
  // Range checksum verification (mandatory)
349
384
  const rc = e.replace_lines.range_checksum;
350
385
  if (!rc) throw new Error("range_checksum required for replace_lines. Read the range first via read_file, then pass its checksum.");
351
- const rcHex = rc.includes(":") ? rc.split(":")[1] : rc;
386
+
387
+ // Checksum's range is authoritative (from read_file), not anchor range
388
+ const { start: csStart, end: csEnd, hex: csHex } = parseChecksum(rc);
389
+
390
+ // Coverage check: checksum range must contain ACTUAL edit range (after relocation)
391
+ const actualStart = si + 1;
392
+ const actualEnd = ei + 1;
393
+ if (csStart > actualStart || csEnd < actualEnd) {
394
+ const snip = buildErrorSnippet(origLines, actualStart - 1);
395
+ throw new Error(
396
+ `CHECKSUM_RANGE_GAP: range ${csStart}-${csEnd} does not cover edit range ${actualStart}-${actualEnd}.\n\n` +
397
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
398
+ `Tip: Use updated hashes above for retry.`
399
+ );
400
+ }
401
+
402
+ // Verify freshness over checksum's own range using origLines snapshot
403
+ const csStartIdx = csStart - 1;
404
+ const csEndIdx = csEnd - 1;
405
+ if (csStartIdx < 0 || csEndIdx >= origLines.length) {
406
+ const snip = buildErrorSnippet(origLines, origLines.length - 1);
407
+ throw new Error(
408
+ `CHECKSUM_OUT_OF_BOUNDS: range ${csStart}-${csEnd} exceeds file length ${origLines.length}.\n\n` +
409
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
410
+ `Tip: Use updated hashes above for retry.`
411
+ );
412
+ }
352
413
  const lineHashes = [];
353
- for (let i = si; i <= ei; i++) lineHashes.push(fnv1a(lines[i]));
354
- const actual = rangeChecksum(lineHashes, s.line, en.line);
414
+ for (let i = csStartIdx; i <= csEndIdx; i++) lineHashes.push(fnv1a(origLines[i]));
415
+ const actual = rangeChecksum(lineHashes, csStart, csEnd);
355
416
  const actualHex = actual.split(":")[1];
356
- if (rcHex !== actualHex) throw new Error(`Range checksum mismatch: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${s.line}-${en.line}.`);
417
+ if (csHex !== actualHex) {
418
+ const snip = buildErrorSnippet(origLines, csStartIdx);
419
+ throw new Error(
420
+ `CHECKSUM_MISMATCH: expected ${rc}, got ${actual}. File changed \u2014 re-read lines ${csStart}-${csEnd}.\n\n` +
421
+ `Current content (lines ${snip.start}-${snip.end}):\n${snip.text}\n\n` +
422
+ `Retry with fresh checksum ${actual}, or use set_line with hashes above.`
423
+ );
424
+ }
357
425
 
358
426
  const txt = e.replace_lines.new_text;
359
427
  if (!txt && txt !== 0) {
@@ -375,6 +443,8 @@ export function editFile(filePath, edits, opts = {}) {
375
443
 
376
444
  // Apply text replacements
377
445
  let content = lines.join("\n");
446
+ if (hadTrailingNewline && !content.endsWith("\n")) content += "\n";
447
+ if (!hadTrailingNewline && content.endsWith("\n")) content = content.slice(0, -1);
378
448
  for (const e of texts) {
379
449
  if (!e.replace.old_text) throw new Error("replace.old_text required");
380
450
  content = textReplace(content, e.replace.old_text, e.replace.new_text || "", e.replace.all || false);
@@ -421,5 +491,34 @@ export function editFile(filePath, edits, opts = {}) {
421
491
  }
422
492
  } catch { /* silent */ }
423
493
 
494
+ // Post-edit context: hash-annotated lines around changed region + checksums
495
+ const newLines = content.split("\n");
496
+ if (diff) {
497
+ const diffArr = diff.split("\n");
498
+ let minLine = Infinity, maxLine = 0;
499
+ for (const dl of diffArr) {
500
+ const m = dl.match(/^[+-](\d+)\|/);
501
+ if (m) { const n = +m[1]; if (n < minLine) minLine = n; if (n > maxLine) maxLine = n; }
502
+ }
503
+ if (minLine <= maxLine) {
504
+ const ctxStart = Math.max(0, minLine - 6);
505
+ const ctxEnd = Math.min(newLines.length, maxLine + 5);
506
+ const ctxLines = [];
507
+ const ctxHashes = [];
508
+ for (let i = ctxStart; i < ctxEnd; i++) {
509
+ const h = fnv1a(newLines[i]);
510
+ ctxHashes.push(h);
511
+ ctxLines.push(`${lineTag(h)}.${i + 1}\t${newLines[i]}`);
512
+ }
513
+ const ctxCs = rangeChecksum(ctxHashes, ctxStart + 1, ctxEnd);
514
+ msg += `\n\nPost-edit (lines ${ctxStart + 1}-${ctxEnd}):\n${ctxLines.join("\n")}\nchecksum: ${ctxCs}`;
515
+ }
516
+ }
517
+ // File-level checksum
518
+ const fileHashes = [];
519
+ for (let i = 0; i < newLines.length; i++) fileHashes.push(fnv1a(newLines[i]));
520
+ const fileCs = rangeChecksum(fileHashes, 1, newLines.length);
521
+ msg += `\nfile: ${fileCs}`;
522
+
424
523
  return msg;
425
524
  }
package/lib/format.mjs ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Shared format helpers for hex-line-mcp.
3
+ * Single source of truth for formatSize, relativeTime, countFileLines, listDirectory, readText, MAX_OUTPUT_CHARS.
4
+ */
5
+
6
+ import { readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ /**
10
+ * Format bytes as human-readable size string.
11
+ * @param {number} bytes
12
+ * @returns {string}
13
+ */
14
+ export function formatSize(bytes) {
15
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
16
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
17
+ return `${bytes}B`;
18
+ }
19
+
20
+ /**
21
+ * Format a Date as relative time string.
22
+ * @param {Date} date
23
+ * @param {boolean} compact - true: "5m ago", false: "5 min ago"
24
+ * @returns {string}
25
+ */
26
+ export function relativeTime(date, compact = false) {
27
+ const sec = Math.round((Date.now() - date.getTime()) / 1000);
28
+ if (compact) {
29
+ if (sec < 60) return "now";
30
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ago`;
31
+ if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`;
32
+ if (sec < 604800) return `${Math.floor(sec / 86400)}d ago`;
33
+ if (sec < 2592000) return `${Math.floor(sec / 604800)}w ago`;
34
+ return `${Math.floor(sec / 2592000)}mo ago`;
35
+ }
36
+ if (sec < 60) return "just now";
37
+ const min = Math.floor(sec / 60);
38
+ if (min < 60) return `${min} min ago`;
39
+ const hrs = Math.floor(min / 60);
40
+ if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
41
+ const days = Math.floor(hrs / 24);
42
+ if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
43
+ const months = Math.floor(days / 30);
44
+ if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
45
+ const years = Math.floor(months / 12);
46
+ return `${years} year${years === 1 ? "" : "s"} ago`;
47
+ }
48
+
49
+ /**
50
+ * Count lines in a text file. Returns null for binary or oversized files.
51
+ * Buffer-based single-pass with built-in binary detection.
52
+ * @param {string} filePath
53
+ * @param {number} size - file size in bytes
54
+ * @param {number} maxSize - skip if larger (default 1MB)
55
+ * @returns {number|null}
56
+ */
57
+ export function countFileLines(filePath, size, maxSize = 1_000_000) {
58
+ if (size === 0 || size > maxSize) return null;
59
+ try {
60
+ const buf = readFileSync(filePath);
61
+ const checkLen = Math.min(buf.length, 8192);
62
+ for (let i = 0; i < checkLen; i++) if (buf[i] === 0) return null; // binary
63
+ let count = 1;
64
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 0x0A) count++;
65
+ return count;
66
+ } catch { return null; }
67
+ }
68
+
69
+ /**
70
+ * Flat single-level directory listing with optional metadata.
71
+ * Sorted: directories first, then files, alphabetical.
72
+ * @param {string} dirPath - absolute directory path
73
+ * @param {object} opts
74
+ * @param {number} opts.limit - max entries (0 = all, default 0)
75
+ * @param {boolean} opts.metadata - show size/lines/time per entry (default false)
76
+ * @param {boolean} opts.compact - compact time format (default false)
77
+ * @param {string} opts.indent - prefix per line (default " ")
78
+ * @returns {{ text: string, total: number }}
79
+ */
80
+ export function listDirectory(dirPath, opts = {}) {
81
+ const { limit = 0, metadata = false, compact = false, indent = " " } = opts;
82
+
83
+ let entries;
84
+ try {
85
+ entries = readdirSync(dirPath, { withFileTypes: true });
86
+ } catch {
87
+ return { text: "", total: 0 };
88
+ }
89
+
90
+ // Sort: directories first, then files, alphabetical
91
+ entries.sort((a, b) => {
92
+ const aDir = a.isDirectory() ? 0 : 1;
93
+ const bDir = b.isDirectory() ? 0 : 1;
94
+ if (aDir !== bDir) return aDir - bDir;
95
+ return a.name.localeCompare(b.name);
96
+ });
97
+
98
+ const total = entries.length;
99
+ const visible = limit > 0 ? entries.slice(0, limit) : entries;
100
+
101
+ const lines = visible.map(entry => {
102
+ const isDir = entry.isDirectory();
103
+ if (!metadata) {
104
+ return `${indent}${isDir ? "d" : "f"} ${entry.name}`;
105
+ }
106
+ if (isDir) {
107
+ return `${indent}${entry.name}/`;
108
+ }
109
+ // File with metadata
110
+ const full = join(dirPath, entry.name);
111
+ const parts = [];
112
+ try {
113
+ const st = statSync(full);
114
+ const lineCount = countFileLines(full, st.size);
115
+ if (lineCount !== null) parts.push(`${lineCount}L`);
116
+ parts.push(formatSize(st.size));
117
+ if (st.mtime) parts.push(relativeTime(st.mtime, compact));
118
+ } catch {
119
+ parts.push("?");
120
+ }
121
+ return `${indent}${entry.name} (${parts.join(", ")})`;
122
+ });
123
+
124
+ return { text: lines.join("\n"), total };
125
+ }
126
+
127
+
128
+ /** Max output characters for read_file and bulk_replace. */
129
+ export const MAX_OUTPUT_CHARS = 80000;
130
+
131
+ /**
132
+ * Read a text file with CRLF normalization.
133
+ * @param {string} filePath
134
+ * @returns {string}
135
+ */
136
+ export function readText(filePath) {
137
+ return readFileSync(filePath, "utf-8").replace(/\r\n/g, "\n");
138
+ }
package/lib/hash.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * FNV-1a hashing for hash-verified file editing.
3
3
  *
4
- * Trueline-compatible: 2-char tags from 32-symbol alphabet,
4
+ * 2-char tags from 32-symbol alphabet,
5
5
  * range checksums as FNV-1a accumulator over line hashes.
6
6
  *
7
7
  * Line format: {tag}.{lineNum}\t{content}
package/lib/info.mjs CHANGED
@@ -4,8 +4,10 @@
4
4
  * Returns: size, line count, modification time, type, binary detection.
5
5
  */
6
6
 
7
- import { statSync, readFileSync } from "node:fs";
7
+ import { statSync, openSync, readSync, closeSync } from "node:fs";
8
8
  import { resolve, isAbsolute, extname, basename } from "node:path";
9
+ import { normalizePath } from "./security.mjs";
10
+ import { formatSize, relativeTime, countFileLines } from "./format.mjs";
9
11
 
10
12
  const MAX_LINE_COUNT_SIZE = 10 * 1024 * 1024; // 10 MB
11
13
 
@@ -31,32 +33,15 @@ const EXT_NAMES = {
31
33
  ".dockerfile": "Dockerfile", ".vue": "Vue component", ".svelte": "Svelte component",
32
34
  };
33
35
 
34
- function formatSize(bytes) {
35
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
36
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
37
- return `${bytes}B`;
38
- }
39
-
40
- function relativeTime(mtime) {
41
- const diff = Date.now() - mtime.getTime();
42
- const secs = Math.floor(diff / 1000);
43
- if (secs < 60) return "just now";
44
- const mins = Math.floor(secs / 60);
45
- if (mins < 60) return `${mins} minute${mins > 1 ? "s" : ""} ago`;
46
- const hours = Math.floor(mins / 60);
47
- if (hours < 24) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
48
- const days = Math.floor(hours / 24);
49
- if (days < 30) return `${days} day${days > 1 ? "s" : ""} ago`;
50
- const months = Math.floor(days / 30);
51
- return `${months} month${months > 1 ? "s" : ""} ago`;
52
- }
53
36
 
54
37
  function detectBinary(filePath, size) {
55
38
  if (size === 0) return false;
56
- const fd = readFileSync(filePath, { encoding: null, flag: "r" });
57
- const checkLen = Math.min(fd.length, 8192);
58
- for (let i = 0; i < checkLen; i++) {
59
- if (fd[i] === 0) return true;
39
+ const fd = openSync(filePath, "r");
40
+ const probe = Buffer.alloc(Math.min(size, 8192));
41
+ const bytesRead = readSync(fd, probe, 0, probe.length, 0);
42
+ closeSync(fd);
43
+ for (let i = 0; i < bytesRead; i++) {
44
+ if (probe[i] === 0) return true;
60
45
  }
61
46
  return false;
62
47
  }
@@ -68,7 +53,8 @@ function detectBinary(filePath, size) {
68
53
  */
69
54
  export function fileInfo(filePath) {
70
55
  if (!filePath) throw new Error("Empty file path");
71
- const abs = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
56
+ const normalized = normalizePath(filePath);
57
+ const abs = isAbsolute(normalized) ? normalized : resolve(process.cwd(), normalized);
72
58
 
73
59
  const stat = statSync(abs);
74
60
  if (!stat.isFile()) throw new Error(`Not a regular file: ${abs}`);
@@ -86,12 +72,8 @@ export function fileInfo(filePath) {
86
72
  // Binary detection
87
73
  const isBinary = size > 0 ? detectBinary(abs, size) : false;
88
74
 
89
- // Line count (only for non-binary, <10MB)
90
- let lineCount = null;
91
- if (!isBinary && size <= MAX_LINE_COUNT_SIZE && size > 0) {
92
- const content = readFileSync(abs, "utf-8");
93
- lineCount = content.split("\n").length;
94
- }
75
+ // Line count (only for non-binary, <=10MB)
76
+ const lineCount = !isBinary && size > 0 ? countFileLines(abs, size, MAX_LINE_COUNT_SIZE) : null;
95
77
 
96
78
  // Format output
97
79
  const sizeStr = lineCount !== null
package/lib/read.mjs CHANGED
@@ -5,31 +5,13 @@
5
5
  * Appends: checksum: {start}-{end}:{8hex}
6
6
  */
7
7
 
8
- import { readFileSync, statSync, readdirSync } from "node:fs";
8
+ import { statSync } from "node:fs";
9
9
  import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
10
10
  import { validatePath } from "./security.mjs";
11
11
  import { getGraphDB, fileAnnotations, getRelativePath } from "./graph-enrich.mjs";
12
-
13
- /**
14
- * Format a Date as relative time string: "just now", "5 min ago", etc.
15
- */
16
- function relativeTime(date) {
17
- const sec = Math.round((Date.now() - date.getTime()) / 1000);
18
- if (sec < 60) return "just now";
19
- const min = Math.floor(sec / 60);
20
- if (min < 60) return `${min} min ago`;
21
- const hrs = Math.floor(min / 60);
22
- if (hrs < 24) return `${hrs} hour${hrs === 1 ? "" : "s"} ago`;
23
- const days = Math.floor(hrs / 24);
24
- if (days < 30) return `${days} day${days === 1 ? "" : "s"} ago`;
25
- const months = Math.floor(days / 30);
26
- if (months < 12) return `${months} month${months === 1 ? "" : "s"} ago`;
27
- const years = Math.floor(months / 12);
28
- return `${years} year${years === 1 ? "" : "s"} ago`;
29
- }
12
+ import { relativeTime, listDirectory, readText, MAX_OUTPUT_CHARS } from "./format.mjs";
30
13
 
31
14
  const DEFAULT_LIMIT = 2000;
32
- const MAX_OUTPUT_CHARS = 80000;
33
15
 
34
16
  /**
35
17
  * Read a file with hash-annotated lines.
@@ -44,12 +26,11 @@ export function readFile(filePath, opts = {}) {
44
26
 
45
27
  // Directory listing fallback
46
28
  if (stat.isDirectory()) {
47
- const entries = readdirSync(real, { withFileTypes: true });
48
- const listing = entries.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`).join("\n");
49
- return `Directory: ${filePath}\n\n\`\`\`\n${listing}\n\`\`\``;
29
+ const { text } = listDirectory(real, { metadata: true });
30
+ return `Directory: ${filePath}\n\n\`\`\`\n${text}\n\`\`\``;
50
31
  }
51
32
 
52
- const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
33
+ const content = readText(real);
53
34
  const lines = content.split("\n");
54
35
  const total = lines.length;
55
36