@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/README.md +63 -37
- package/hook.mjs +106 -24
- package/lib/bulk-replace.mjs +8 -6
- package/lib/changes.mjs +7 -6
- package/lib/coerce.mjs +0 -6
- package/lib/edit.mjs +140 -41
- package/lib/format.mjs +138 -0
- package/lib/hash.mjs +1 -1
- package/lib/info.mjs +13 -31
- package/lib/read.mjs +5 -24
- package/lib/search.mjs +213 -77
- package/lib/security.mjs +6 -8
- package/lib/setup.mjs +37 -5
- package/lib/tree.mjs +82 -83
- package/lib/verify.mjs +2 -2
- package/output-style.md +1 -1
- package/package.json +12 -4
- package/server.mjs +39 -24
package/lib/edit.mjs
CHANGED
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
* - dry_run preview, noop detection, diff output
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
|
69
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
130
|
-
`Current content (lines ${start
|
|
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
|
-
|
|
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 =
|
|
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(`
|
|
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
|
-
|
|
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 =
|
|
354
|
-
const actual = rangeChecksum(lineHashes,
|
|
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 (
|
|
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
|
-
*
|
|
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,
|
|
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 =
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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,
|
|
90
|
-
|
|
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 {
|
|
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
|
|
48
|
-
|
|
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 =
|
|
33
|
+
const content = readText(real);
|
|
53
34
|
const lines = content.split("\n");
|
|
54
35
|
const total = lines.length;
|
|
55
36
|
|