@levnikolaevich/hex-line-mcp 1.0.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/README.md +293 -0
- package/benchmark.mjs +1180 -0
- package/hook.mjs +299 -0
- package/lib/bulk-replace.mjs +55 -0
- package/lib/changes.mjs +174 -0
- package/lib/coerce.mjs +43 -0
- package/lib/edit.mjs +420 -0
- package/lib/graph-enrich.mjs +208 -0
- package/lib/hash.mjs +109 -0
- package/lib/info.mjs +109 -0
- package/lib/normalize.mjs +106 -0
- package/lib/outline.mjs +200 -0
- package/lib/read.mjs +129 -0
- package/lib/search.mjs +132 -0
- package/lib/security.mjs +114 -0
- package/lib/setup.mjs +132 -0
- package/lib/tree.mjs +162 -0
- package/lib/update-check.mjs +56 -0
- package/lib/verify.mjs +54 -0
- package/package.json +57 -0
- package/server.mjs +368 -0
package/lib/hash.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FNV-1a hashing for hash-verified file editing.
|
|
3
|
+
*
|
|
4
|
+
* Trueline-compatible: 2-char tags from 32-symbol alphabet,
|
|
5
|
+
* range checksums as FNV-1a accumulator over line hashes.
|
|
6
|
+
*
|
|
7
|
+
* Line format: {tag}.{lineNum}\t{content}
|
|
8
|
+
* Range checksum: checksum: {startLine}-{endLine}:{8hex}
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const FNV_OFFSET = 0x811c9dc5;
|
|
12
|
+
const FNV_PRIME = 0x01000193;
|
|
13
|
+
|
|
14
|
+
// 32 symbols — bitwise & 0x1f (power of 2, no division)
|
|
15
|
+
const TAG_CHARS = "abcdefghijklmnopqrstuvwxyz234567";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* FNV-1a 32-bit hash of a string (UTF-8 encoded).
|
|
19
|
+
* Whitespace is normalized (collapsed) before hashing.
|
|
20
|
+
*/
|
|
21
|
+
export function fnv1a(str) {
|
|
22
|
+
// Normalize: strip trailing \r, collapse whitespace
|
|
23
|
+
const normalized = str.replace(/\r$/, "").replace(/\s+/g, "");
|
|
24
|
+
let h = FNV_OFFSET;
|
|
25
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
26
|
+
let code = normalized.charCodeAt(i);
|
|
27
|
+
// Handle surrogate pairs for codepoints > U+FFFF
|
|
28
|
+
if (code >= 0xd800 && code <= 0xdbff && i + 1 < normalized.length) {
|
|
29
|
+
const lo = normalized.charCodeAt(i + 1);
|
|
30
|
+
if (lo >= 0xdc00 && lo <= 0xdfff) {
|
|
31
|
+
code = ((code - 0xd800) << 10) + (lo - 0xdc00) + 0x10000;
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Encode as UTF-8 bytes and feed to FNV-1a
|
|
36
|
+
if (code < 0x80) {
|
|
37
|
+
h = Math.imul(h ^ code, FNV_PRIME) >>> 0;
|
|
38
|
+
} else if (code < 0x800) {
|
|
39
|
+
h = Math.imul(h ^ (0xc0 | (code >> 6)), FNV_PRIME) >>> 0;
|
|
40
|
+
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
41
|
+
} else if (code < 0x10000) {
|
|
42
|
+
h = Math.imul(h ^ (0xe0 | (code >> 12)), FNV_PRIME) >>> 0;
|
|
43
|
+
h = Math.imul(h ^ (0x80 | ((code >> 6) & 0x3f)), FNV_PRIME) >>> 0;
|
|
44
|
+
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
45
|
+
} else {
|
|
46
|
+
h = Math.imul(h ^ (0xf0 | (code >> 18)), FNV_PRIME) >>> 0;
|
|
47
|
+
h = Math.imul(h ^ (0x80 | ((code >> 12) & 0x3f)), FNV_PRIME) >>> 0;
|
|
48
|
+
h = Math.imul(h ^ (0x80 | ((code >> 6) & 0x3f)), FNV_PRIME) >>> 0;
|
|
49
|
+
h = Math.imul(h ^ (0x80 | (code & 0x3f)), FNV_PRIME) >>> 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return h;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 2-character tag from 32-bit hash.
|
|
57
|
+
* Uses bits 0-4 and 8-12 for two characters from TAG_CHARS.
|
|
58
|
+
*/
|
|
59
|
+
export function lineTag(hash32) {
|
|
60
|
+
return TAG_CHARS[hash32 & 0x1f] + TAG_CHARS[(hash32 >>> 8) & 0x1f];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compute tag for a line's content.
|
|
65
|
+
*/
|
|
66
|
+
export function hashLine(content) {
|
|
67
|
+
return lineTag(fnv1a(content));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Format a line with hash prefix: {tag}.{lineNum}\t{content}
|
|
72
|
+
*/
|
|
73
|
+
export function formatLine(lineNum, content) {
|
|
74
|
+
return `${hashLine(content)}.${lineNum}\t${content}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Range checksum: FNV-1a accumulator over line hashes (little-endian bytes).
|
|
79
|
+
* Returns "{startLine}-{endLine}:{8hex}".
|
|
80
|
+
*/
|
|
81
|
+
export function rangeChecksum(lineHashes, startLine, endLine) {
|
|
82
|
+
let acc = FNV_OFFSET;
|
|
83
|
+
for (const h of lineHashes) {
|
|
84
|
+
// Feed 4 bytes of each 32-bit hash in little-endian order
|
|
85
|
+
acc = Math.imul(acc ^ (h & 0xff), FNV_PRIME) >>> 0;
|
|
86
|
+
acc = Math.imul(acc ^ ((h >>> 8) & 0xff), FNV_PRIME) >>> 0;
|
|
87
|
+
acc = Math.imul(acc ^ ((h >>> 16) & 0xff), FNV_PRIME) >>> 0;
|
|
88
|
+
acc = Math.imul(acc ^ ((h >>> 24) & 0xff), FNV_PRIME) >>> 0;
|
|
89
|
+
}
|
|
90
|
+
return `${startLine}-${endLine}:${acc.toString(16).padStart(8, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse a line reference: "ab.12" → { tag: "ab", line: 12 }
|
|
95
|
+
*/
|
|
96
|
+
export function parseRef(ref) {
|
|
97
|
+
const m = ref.trim().match(/^([a-z2-7]{2})\.(\d+)$/);
|
|
98
|
+
if (!m) throw new Error(`Bad ref: "${ref}". Expected "ab.12" (tag.lineNum)`);
|
|
99
|
+
return { tag: m[1], line: parseInt(m[2], 10) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse a range checksum: "1-50:f7e2a1b0" → { start: 1, end: 50, hex: "f7e2a1b0" }
|
|
104
|
+
*/
|
|
105
|
+
export function parseChecksum(cs) {
|
|
106
|
+
const m = cs.trim().match(/^(\d+)-(\d+):([0-9a-f]{8})$/);
|
|
107
|
+
if (!m) throw new Error(`Bad checksum: "${cs}". Expected "1-50:f7e2a1b0"`);
|
|
108
|
+
return { start: parseInt(m[1], 10), end: parseInt(m[2], 10), hex: m[3] };
|
|
109
|
+
}
|
package/lib/info.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File metadata without reading content.
|
|
3
|
+
*
|
|
4
|
+
* Returns: size, line count, modification time, type, binary detection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { statSync, readFileSync } from "node:fs";
|
|
8
|
+
import { resolve, isAbsolute, extname, basename } from "node:path";
|
|
9
|
+
|
|
10
|
+
const MAX_LINE_COUNT_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
11
|
+
|
|
12
|
+
const EXT_NAMES = {
|
|
13
|
+
".ts": "TypeScript source", ".tsx": "TypeScript JSX source",
|
|
14
|
+
".js": "JavaScript source", ".jsx": "JavaScript JSX source",
|
|
15
|
+
".mjs": "JavaScript ESM source", ".cjs": "JavaScript CJS source",
|
|
16
|
+
".py": "Python source", ".rb": "Ruby source", ".rs": "Rust source",
|
|
17
|
+
".go": "Go source", ".java": "Java source", ".kt": "Kotlin source",
|
|
18
|
+
".swift": "Swift source", ".c": "C source", ".cpp": "C++ source",
|
|
19
|
+
".h": "C/C++ header", ".cs": "C# source", ".php": "PHP source",
|
|
20
|
+
".sh": "Shell script", ".bash": "Bash script", ".zsh": "Zsh script",
|
|
21
|
+
".json": "JSON data", ".yaml": "YAML data", ".yml": "YAML data",
|
|
22
|
+
".toml": "TOML config", ".xml": "XML document", ".html": "HTML document",
|
|
23
|
+
".css": "CSS stylesheet", ".scss": "SCSS stylesheet", ".less": "LESS stylesheet",
|
|
24
|
+
".md": "Markdown document", ".txt": "Plain text", ".csv": "CSV data",
|
|
25
|
+
".sql": "SQL script", ".graphql": "GraphQL schema",
|
|
26
|
+
".png": "PNG image", ".jpg": "JPEG image", ".jpeg": "JPEG image",
|
|
27
|
+
".gif": "GIF image", ".svg": "SVG image", ".ico": "Icon file",
|
|
28
|
+
".pdf": "PDF document", ".zip": "ZIP archive", ".tar": "TAR archive",
|
|
29
|
+
".gz": "Gzip archive", ".wasm": "WebAssembly binary",
|
|
30
|
+
".lock": "Lock file", ".env": "Environment config",
|
|
31
|
+
".dockerfile": "Dockerfile", ".vue": "Vue component", ".svelte": "Svelte component",
|
|
32
|
+
};
|
|
33
|
+
|
|
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
|
+
|
|
54
|
+
function detectBinary(filePath, size) {
|
|
55
|
+
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;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get file metadata without reading full content.
|
|
66
|
+
* @param {string} filePath
|
|
67
|
+
* @returns {string} Formatted metadata
|
|
68
|
+
*/
|
|
69
|
+
export function fileInfo(filePath) {
|
|
70
|
+
if (!filePath) throw new Error("Empty file path");
|
|
71
|
+
const abs = isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath);
|
|
72
|
+
|
|
73
|
+
const stat = statSync(abs);
|
|
74
|
+
if (!stat.isFile()) throw new Error(`Not a regular file: ${abs}`);
|
|
75
|
+
|
|
76
|
+
const size = stat.size;
|
|
77
|
+
const mtime = stat.mtime;
|
|
78
|
+
const ext = extname(abs).toLowerCase();
|
|
79
|
+
const name = basename(abs);
|
|
80
|
+
|
|
81
|
+
// File type
|
|
82
|
+
let typeName = EXT_NAMES[ext] || (ext ? `${ext.slice(1).toUpperCase()} file` : "Unknown type");
|
|
83
|
+
if (name === "Dockerfile") typeName = "Dockerfile";
|
|
84
|
+
if (name === "Makefile") typeName = "Makefile";
|
|
85
|
+
|
|
86
|
+
// Binary detection
|
|
87
|
+
const isBinary = size > 0 ? detectBinary(abs, size) : false;
|
|
88
|
+
|
|
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
|
+
}
|
|
95
|
+
|
|
96
|
+
// Format output
|
|
97
|
+
const sizeStr = lineCount !== null
|
|
98
|
+
? `Size: ${formatSize(size)} (${lineCount} lines)`
|
|
99
|
+
: `Size: ${formatSize(size)}`;
|
|
100
|
+
const timeStr = `Modified: ${mtime.toISOString().replace("T", " ").slice(0, 19)} (${relativeTime(mtime)})`;
|
|
101
|
+
|
|
102
|
+
return [
|
|
103
|
+
`File: ${filePath}`,
|
|
104
|
+
sizeStr,
|
|
105
|
+
timeStr,
|
|
106
|
+
`Type: ${typeName}`,
|
|
107
|
+
`Binary: ${isBinary ? "yes" : "no"}`,
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output normalization and smart truncation.
|
|
3
|
+
* Based on shared/references/output_normalization.md patterns.
|
|
4
|
+
*
|
|
5
|
+
* - Normalizes runtime values (IPs, timestamps, UUIDs, large numbers)
|
|
6
|
+
* - Deduplicates identical normalized lines with (xN) counts
|
|
7
|
+
* - Smart truncation: first N + last N lines with gap indicator
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Normalization patterns (applied in order)
|
|
11
|
+
const NORM_RULES = [
|
|
12
|
+
[/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<UUID>"],
|
|
13
|
+
[/\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
14
|
+
[/\d{2}-\d{2}-\d{4}\s\d{2}:\d{2}:\d{2}/g, "<TS>"],
|
|
15
|
+
[/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?/g, "<IP>"],
|
|
16
|
+
[/\/[0-9a-f]{8,}/gi, "/<ID>"],
|
|
17
|
+
[/\b\d{3,}(?=\b|[a-zA-Z])/g, "<N>"],
|
|
18
|
+
[/trace_id=[0-9a-fA-F]{1,8}/g, "trace_id=<TRACE>"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalize a single line by replacing runtime-specific values.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeLine(line) {
|
|
25
|
+
let result = line;
|
|
26
|
+
for (const [rx, repl] of NORM_RULES) {
|
|
27
|
+
result = result.replace(rx, repl);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deduplicate lines: group identical normalized lines, append (xN).
|
|
34
|
+
*
|
|
35
|
+
* @param {string[]} lines - raw output lines
|
|
36
|
+
* @returns {string[]} deduplicated lines
|
|
37
|
+
*/
|
|
38
|
+
export function deduplicateLines(lines) {
|
|
39
|
+
const groups = new Map();
|
|
40
|
+
const order = [];
|
|
41
|
+
|
|
42
|
+
for (const line of lines) {
|
|
43
|
+
const norm = normalizeLine(line);
|
|
44
|
+
if (groups.has(norm)) {
|
|
45
|
+
groups.get(norm).count++;
|
|
46
|
+
} else {
|
|
47
|
+
const entry = { representative: line, count: 1 };
|
|
48
|
+
groups.set(norm, entry);
|
|
49
|
+
order.push(norm);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sort by count descending (stable within same count)
|
|
54
|
+
order.sort((a, b) => groups.get(b).count - groups.get(a).count);
|
|
55
|
+
|
|
56
|
+
return order.map((norm) => {
|
|
57
|
+
const { representative, count } = groups.get(norm);
|
|
58
|
+
return count > 1 ? `${representative} (x${count})` : representative;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Smart truncation: keep first N + last N lines with gap indicator.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} text - full output text
|
|
66
|
+
* @param {number} [headLines=40] - lines to keep from start
|
|
67
|
+
* @param {number} [tailLines=20] - lines to keep from end
|
|
68
|
+
* @returns {string} truncated text
|
|
69
|
+
*/
|
|
70
|
+
export function smartTruncate(text, headLines = 40, tailLines = 20) {
|
|
71
|
+
const lines = text.split("\n");
|
|
72
|
+
const total = lines.length;
|
|
73
|
+
const maxLines = headLines + tailLines;
|
|
74
|
+
|
|
75
|
+
if (total <= maxLines) return text;
|
|
76
|
+
|
|
77
|
+
const head = lines.slice(0, headLines);
|
|
78
|
+
const tail = lines.slice(total - tailLines);
|
|
79
|
+
const skipped = total - maxLines;
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
...head,
|
|
83
|
+
`\n--- ${skipped} lines omitted ---\n`,
|
|
84
|
+
...tail,
|
|
85
|
+
].join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Full normalization pipeline: normalize -> deduplicate -> truncate.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} text - raw output
|
|
92
|
+
* @param {object} [opts]
|
|
93
|
+
* @param {boolean} [opts.deduplicate=true]
|
|
94
|
+
* @param {number} [opts.headLines=40]
|
|
95
|
+
* @param {number} [opts.tailLines=20]
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeOutput(text, opts = {}) {
|
|
99
|
+
const { deduplicate = true, headLines = 40, tailLines = 20 } = opts;
|
|
100
|
+
const lines = text.split("\n");
|
|
101
|
+
|
|
102
|
+
const processed = deduplicate ? deduplicateLines(lines) : lines;
|
|
103
|
+
const result = processed.join("\n");
|
|
104
|
+
|
|
105
|
+
return smartTruncate(result, headLines, tailLines);
|
|
106
|
+
}
|
package/lib/outline.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AST-based file outline via tree-sitter WASM.
|
|
3
|
+
*
|
|
4
|
+
* Returns structural overview: functions, classes, interfaces with line ranges.
|
|
5
|
+
* 10-20 lines instead of 500 → 95% token reduction.
|
|
6
|
+
* Output maps directly to read_file ranges.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { resolve, extname } from "node:path";
|
|
11
|
+
import { validatePath } from "./security.mjs";
|
|
12
|
+
import { getGraphDB, symbolAnnotation, getRelativePath } from "./graph-enrich.mjs";
|
|
13
|
+
|
|
14
|
+
// Language configs: extension → { grammar, outline, skip, recurse }
|
|
15
|
+
const LANG_CONFIGS = {
|
|
16
|
+
".js": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
17
|
+
".mjs": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
18
|
+
".jsx": { grammar: "javascript", outline: ["function_declaration", "class_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
19
|
+
".ts": { grammar: "typescript", outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
20
|
+
".tsx": { grammar: "tsx", outline: ["function_declaration", "class_declaration", "interface_declaration", "type_alias_declaration", "enum_declaration", "variable_declaration", "export_statement", "lexical_declaration"], skip: ["import_statement"], recurse: ["class_body"] },
|
|
21
|
+
".py": { grammar: "python", outline: ["function_definition", "class_definition", "decorated_definition"], skip: ["import_statement", "import_from_statement"], recurse: ["class_body", "block"] },
|
|
22
|
+
".go": { grammar: "go", outline: ["function_declaration", "method_declaration", "type_declaration"], skip: ["import_declaration"], recurse: [] },
|
|
23
|
+
".rs": { grammar: "rust", outline: ["function_item", "struct_item", "enum_item", "impl_item", "trait_item", "const_item", "static_item"], skip: ["use_declaration"], recurse: ["impl_item"] },
|
|
24
|
+
".java": { grammar: "java", outline: ["class_declaration", "interface_declaration", "method_declaration", "enum_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
25
|
+
".c": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
26
|
+
".h": { grammar: "c", outline: ["function_definition", "struct_specifier", "enum_specifier", "type_definition"], skip: ["preproc_include"], recurse: [] },
|
|
27
|
+
".cpp": { grammar: "cpp", outline: ["function_definition", "class_specifier", "struct_specifier", "namespace_definition"], skip: ["preproc_include"], recurse: ["class_specifier"] },
|
|
28
|
+
".cs": { grammar: "c_sharp", outline: ["class_declaration", "interface_declaration", "method_declaration", "namespace_declaration"], skip: ["using_directive"], recurse: ["class_body"] },
|
|
29
|
+
".rb": { grammar: "ruby", outline: ["method", "class", "module"], skip: ["require", "require_relative"], recurse: ["class", "module"] },
|
|
30
|
+
".php": { grammar: "php", outline: ["function_definition", "class_declaration", "method_declaration"], skip: ["namespace_use_declaration"], recurse: ["class_body"] },
|
|
31
|
+
".kt": { grammar: "kotlin", outline: ["function_declaration", "class_declaration", "object_declaration"], skip: ["import_header"], recurse: ["class_body"] },
|
|
32
|
+
".swift": { grammar: "swift", outline: ["function_declaration", "class_declaration", "struct_declaration", "protocol_declaration"], skip: ["import_declaration"], recurse: ["class_body"] },
|
|
33
|
+
".sh": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
|
|
34
|
+
".bash": { grammar: "bash", outline: ["function_definition"], skip: [], recurse: [] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Parser cache (init once)
|
|
38
|
+
let _parser = null;
|
|
39
|
+
const _langCache = new Map();
|
|
40
|
+
|
|
41
|
+
async function getParser() {
|
|
42
|
+
if (_parser) return _parser;
|
|
43
|
+
try {
|
|
44
|
+
const { Parser } = await import("web-tree-sitter");
|
|
45
|
+
await Parser.init();
|
|
46
|
+
_parser = new Parser();
|
|
47
|
+
return _parser;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
throw new Error(`tree-sitter init failed: ${e.message}. Run: cd mcp/hex-line-mcp && npm install`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function getLanguage(grammar) {
|
|
54
|
+
if (_langCache.has(grammar)) return _langCache.get(grammar);
|
|
55
|
+
await getParser(); // ensure init
|
|
56
|
+
try {
|
|
57
|
+
const { Language } = await import("web-tree-sitter");
|
|
58
|
+
const { createRequire } = await import("node:module");
|
|
59
|
+
const require = createRequire(import.meta.url);
|
|
60
|
+
// Absolute path for Windows compatibility (Gemini finding #3)
|
|
61
|
+
const wasmPath = resolve(require.resolve("tree-sitter-wasms/package.json"), "..", "out", `tree-sitter-${grammar}.wasm`);
|
|
62
|
+
const lang = await Language.load(wasmPath);
|
|
63
|
+
_langCache.set(grammar, lang);
|
|
64
|
+
return lang;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
throw new Error(`Language "${grammar}" not available: ${e.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract structural outline entries from AST.
|
|
72
|
+
*/
|
|
73
|
+
function extractOutline(rootNode, config, sourceLines) {
|
|
74
|
+
const entries = [];
|
|
75
|
+
const skipTypes = new Set(config.skip);
|
|
76
|
+
const outlineTypes = new Set(config.outline);
|
|
77
|
+
const recurseTypes = new Set(config.recurse);
|
|
78
|
+
|
|
79
|
+
function walk(node, depth) {
|
|
80
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
81
|
+
const child = node.child(i);
|
|
82
|
+
const type = child.type;
|
|
83
|
+
const startLine = child.startPosition.row + 1;
|
|
84
|
+
const endLine = child.endPosition.row + 1;
|
|
85
|
+
|
|
86
|
+
if (skipTypes.has(type)) continue;
|
|
87
|
+
|
|
88
|
+
if (outlineTypes.has(type)) {
|
|
89
|
+
const firstLine = sourceLines[startLine - 1] || "";
|
|
90
|
+
// Extract symbol name for graph annotation
|
|
91
|
+
const nameMatch = firstLine.match(/(?:function|class|interface|type|enum|struct|def|fn|pub\s+fn)\s+(\w+)|(?:const|let|var|export\s+(?:const|let|var|function|class))\s+(\w+)/);
|
|
92
|
+
const name = nameMatch ? (nameMatch[1] || nameMatch[2]) : null;
|
|
93
|
+
|
|
94
|
+
entries.push({
|
|
95
|
+
start: startLine,
|
|
96
|
+
end: endLine,
|
|
97
|
+
depth,
|
|
98
|
+
text: firstLine.trim().slice(0, 120),
|
|
99
|
+
name,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Recurse into class/struct bodies
|
|
103
|
+
for (let j = 0; j < child.childCount; j++) {
|
|
104
|
+
const sub = child.child(j);
|
|
105
|
+
if (recurseTypes.has(sub.type)) {
|
|
106
|
+
walk(sub, depth + 1);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Collect skipped ranges for summary
|
|
114
|
+
const skippedRanges = [];
|
|
115
|
+
for (let i = 0; i < rootNode.childCount; i++) {
|
|
116
|
+
const child = rootNode.child(i);
|
|
117
|
+
if (skipTypes.has(child.type)) {
|
|
118
|
+
skippedRanges.push({
|
|
119
|
+
start: child.startPosition.row + 1,
|
|
120
|
+
end: child.endPosition.row + 1,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
walk(rootNode, 0);
|
|
126
|
+
return { entries, skippedRanges };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse content string into outline entries.
|
|
131
|
+
* Reusable core — no file I/O, no validatePath.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} content Source text (LF-normalized)
|
|
134
|
+
* @param {string} ext Lowercase extension including dot (e.g. ".mjs")
|
|
135
|
+
* @returns {Promise<{entries: Array, skippedRanges: Array} | null>} null if unsupported ext
|
|
136
|
+
*/
|
|
137
|
+
export async function outlineFromContent(content, ext) {
|
|
138
|
+
const config = LANG_CONFIGS[ext];
|
|
139
|
+
if (!config) return null;
|
|
140
|
+
|
|
141
|
+
const sourceLines = content.split("\n");
|
|
142
|
+
|
|
143
|
+
let lang;
|
|
144
|
+
try {
|
|
145
|
+
lang = await getLanguage(config.grammar);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
throw new Error(`Outline error: ${e.message}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parser = await getParser();
|
|
151
|
+
parser.setLanguage(lang);
|
|
152
|
+
const tree = parser.parse(content);
|
|
153
|
+
return extractOutline(tree.rootNode, config, sourceLines);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Format outline entries into display string.
|
|
158
|
+
*/
|
|
159
|
+
function formatOutline(entries, skippedRanges, sourceLineCount, db, relFile) {
|
|
160
|
+
const lines = [];
|
|
161
|
+
|
|
162
|
+
if (skippedRanges.length > 0) {
|
|
163
|
+
const first = skippedRanges[0].start;
|
|
164
|
+
const last = skippedRanges[skippedRanges.length - 1].end;
|
|
165
|
+
const count = skippedRanges.reduce((sum, r) => sum + (r.end - r.start + 1), 0);
|
|
166
|
+
lines.push(`${first}-${last}: (${count} imports/declarations)`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const e of entries) {
|
|
170
|
+
const indent = " ".repeat(e.depth);
|
|
171
|
+
const anno = db ? symbolAnnotation(db, relFile, e.name) : null;
|
|
172
|
+
const suffix = anno ? ` ${anno}` : "";
|
|
173
|
+
lines.push(`${indent}${e.start}-${e.end}: ${e.text}${suffix}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
lines.push("");
|
|
177
|
+
lines.push(`(${entries.length} symbols, ${sourceLineCount} source lines)`);
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Generate file outline.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} filePath
|
|
185
|
+
* @returns {Promise<string>} formatted outline
|
|
186
|
+
*/
|
|
187
|
+
export async function fileOutline(filePath) {
|
|
188
|
+
const real = validatePath(filePath);
|
|
189
|
+
const ext = extname(real).toLowerCase();
|
|
190
|
+
|
|
191
|
+
if (!LANG_CONFIGS[ext]) {
|
|
192
|
+
return `Outline unavailable for ${ext} files. Use read_file directly for non-code files (markdown, config, text). Supported code extensions: ${Object.keys(LANG_CONFIGS).join(", ")}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
|
|
196
|
+
const result = await outlineFromContent(content, ext);
|
|
197
|
+
const db = getGraphDB(real);
|
|
198
|
+
const relFile = db ? getRelativePath(real) : null;
|
|
199
|
+
return `File: ${filePath}\n\n${formatOutline(result.entries, result.skippedRanges, content.split("\n").length, db, relFile)}`;
|
|
200
|
+
}
|
package/lib/read.mjs
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File read with FNV-1a hash annotations and range checksums.
|
|
3
|
+
*
|
|
4
|
+
* Output format: {tag}.{lineNum}\t{content}
|
|
5
|
+
* Appends: checksum: {start}-{end}:{8hex}
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, statSync, readdirSync } from "node:fs";
|
|
9
|
+
import { fnv1a, lineTag, rangeChecksum } from "./hash.mjs";
|
|
10
|
+
import { validatePath } from "./security.mjs";
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_LIMIT = 2000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read a file with hash-annotated lines.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} filePath
|
|
37
|
+
* @param {object} opts - { offset, limit, plain, ranges }
|
|
38
|
+
* @returns {string} formatted output
|
|
39
|
+
*/
|
|
40
|
+
export function readFile(filePath, opts = {}) {
|
|
41
|
+
const real = validatePath(filePath);
|
|
42
|
+
const stat = statSync(real);
|
|
43
|
+
|
|
44
|
+
// Directory listing fallback
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
const entries = readdirSync(real, { withFileTypes: true });
|
|
47
|
+
const listing = entries.map((e) => `${e.isDirectory() ? "d" : "f"} ${e.name}`).join("\n");
|
|
48
|
+
return `Directory: ${filePath}\n\n\`\`\`\n${listing}\n\`\`\``;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const content = readFileSync(real, "utf-8").replace(/\r\n/g, "\n");
|
|
52
|
+
const lines = content.split("\n");
|
|
53
|
+
const total = lines.length;
|
|
54
|
+
|
|
55
|
+
// Determine ranges to read
|
|
56
|
+
let ranges;
|
|
57
|
+
if (opts.ranges && opts.ranges.length > 0) {
|
|
58
|
+
ranges = opts.ranges.map((r) => ({
|
|
59
|
+
start: Math.max(1, r.start || 1),
|
|
60
|
+
end: Math.min(total, r.end || total),
|
|
61
|
+
}));
|
|
62
|
+
} else {
|
|
63
|
+
const startLine = Math.max(1, opts.offset || 1);
|
|
64
|
+
const maxLines = (opts.limit && opts.limit > 0) ? opts.limit : DEFAULT_LIMIT;
|
|
65
|
+
ranges = [{ start: startLine, end: Math.min(total, startLine - 1 + maxLines) }];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parts = [];
|
|
69
|
+
|
|
70
|
+
for (const range of ranges) {
|
|
71
|
+
const selected = lines.slice(range.start - 1, range.end);
|
|
72
|
+
const lineHashes = [];
|
|
73
|
+
|
|
74
|
+
let formatted;
|
|
75
|
+
if (opts.plain) {
|
|
76
|
+
formatted = selected.map((line, i) => {
|
|
77
|
+
const num = range.start + i;
|
|
78
|
+
lineHashes.push(fnv1a(line));
|
|
79
|
+
return `${num}|${line}`;
|
|
80
|
+
}).join("\n");
|
|
81
|
+
} else {
|
|
82
|
+
formatted = selected.map((line, i) => {
|
|
83
|
+
const num = range.start + i;
|
|
84
|
+
const hash32 = fnv1a(line);
|
|
85
|
+
lineHashes.push(hash32);
|
|
86
|
+
const tag = lineTag(hash32);
|
|
87
|
+
return `${tag}.${num}\t${line}`;
|
|
88
|
+
}).join("\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
parts.push(formatted);
|
|
92
|
+
|
|
93
|
+
// Range checksum
|
|
94
|
+
const cs = rangeChecksum(lineHashes, range.start, range.end);
|
|
95
|
+
parts.push(`\nchecksum: ${cs}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Header
|
|
99
|
+
const sizeKB = (stat.size / 1024).toFixed(1);
|
|
100
|
+
const mtime = stat.mtime;
|
|
101
|
+
const ago = relativeTime(mtime);
|
|
102
|
+
let header = `File: ${filePath} (${total} lines, ${sizeKB}KB, ${ago})`;
|
|
103
|
+
if (ranges.length === 1) {
|
|
104
|
+
const r = ranges[0];
|
|
105
|
+
if (r.start > 1 || r.end < total) {
|
|
106
|
+
header += ` [showing ${r.start}-${r.end}]`;
|
|
107
|
+
}
|
|
108
|
+
if (r.end < total) {
|
|
109
|
+
header += ` (${total - r.end} more below)`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Graph enrichment (optional — silent if no DB)
|
|
114
|
+
const db = getGraphDB(real);
|
|
115
|
+
const relFile = db ? getRelativePath(real) : null;
|
|
116
|
+
let graphLine = "";
|
|
117
|
+
if (db && relFile) {
|
|
118
|
+
const annos = fileAnnotations(db, relFile);
|
|
119
|
+
if (annos.length > 0) {
|
|
120
|
+
const items = annos.map(a => {
|
|
121
|
+
const counts = (a.callees || a.callers) ? ` ${a.callees}\u2193 ${a.callers}\u2191` : "";
|
|
122
|
+
return `${a.name} [${a.kind}${counts}]`;
|
|
123
|
+
});
|
|
124
|
+
graphLine = `\nGraph: ${items.join(" | ")}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return `${header}${graphLine}\n\n\`\`\`\n${parts.join("\n")}\n\`\`\``;
|
|
129
|
+
}
|