@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/benchmark.mjs
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hex-line Combo Benchmark v2
|
|
4
|
+
*
|
|
5
|
+
* Compares "agent without hex-line" vs "agent with hex-line" across
|
|
6
|
+
* read-only and write scenarios. Measures chars in response (proxy for tokens).
|
|
7
|
+
*
|
|
8
|
+
* Usage: node mcp/hex-line-mcp/benchmark.mjs [--repo /path/to/repo]
|
|
9
|
+
* Default repo: current working directory.
|
|
10
|
+
*
|
|
11
|
+
* Zero external deps beyond hex-line lib modules.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, unlinkSync, statSync, readdirSync } from "node:fs";
|
|
15
|
+
import { resolve, extname, basename, join } from "node:path";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { fnv1a, lineTag, rangeChecksum } from "./lib/hash.mjs";
|
|
18
|
+
import { readFile } from "./lib/read.mjs";
|
|
19
|
+
import { directoryTree } from "./lib/tree.mjs";
|
|
20
|
+
import { fileInfo } from "./lib/info.mjs";
|
|
21
|
+
import { verifyChecksums } from "./lib/verify.mjs";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// CLI
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
let repoRoot = process.cwd();
|
|
29
|
+
const repoIdx = args.indexOf("--repo");
|
|
30
|
+
if (repoIdx !== -1 && args[repoIdx + 1]) {
|
|
31
|
+
repoRoot = resolve(args[repoIdx + 1]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CODE_EXTS = new Set([".js", ".ts", ".py", ".mjs", ".go", ".rs", ".java", ".c", ".cpp", ".rb", ".php"]);
|
|
35
|
+
const MAX_FILES_PER_CAT = 3;
|
|
36
|
+
const RUNS = 3;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// File discovery
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function walkDir(dir, depth = 0) {
|
|
43
|
+
if (depth > 10) return [];
|
|
44
|
+
const results = [];
|
|
45
|
+
let entries;
|
|
46
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
47
|
+
catch { return results; }
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
const full = resolve(dir, e.name);
|
|
50
|
+
if (e.isDirectory()) {
|
|
51
|
+
if (e.name.startsWith(".") || e.name === "node_modules" || e.name === "vendor"
|
|
52
|
+
|| e.name === "dist" || e.name === "__pycache__" || e.name === "target") continue;
|
|
53
|
+
results.push(...walkDir(full, depth + 1));
|
|
54
|
+
} else if (e.isFile() && CODE_EXTS.has(extname(e.name).toLowerCase())) {
|
|
55
|
+
try {
|
|
56
|
+
const st = statSync(full);
|
|
57
|
+
if (st.size > 0 && st.size < 1_000_000) results.push(full);
|
|
58
|
+
} catch { /* skip */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getFileLines(f) {
|
|
65
|
+
try { return readFileSync(f, "utf-8").replace(/\r\n/g, "\n").split("\n"); }
|
|
66
|
+
catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function categorize(files) {
|
|
70
|
+
const cats = { small: [], medium: [], large: [], xl: [] };
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const lines = getFileLines(f);
|
|
73
|
+
if (!lines) continue;
|
|
74
|
+
const n = lines.length;
|
|
75
|
+
if (n >= 10 && n <= 50) cats.small.push(f);
|
|
76
|
+
else if (n > 50 && n <= 200) cats.medium.push(f);
|
|
77
|
+
else if (n > 200 && n <= 500) cats.large.push(f);
|
|
78
|
+
else if (n > 500) cats.xl.push(f);
|
|
79
|
+
}
|
|
80
|
+
for (const key of Object.keys(cats)) {
|
|
81
|
+
const arr = cats[key];
|
|
82
|
+
if (arr.length > MAX_FILES_PER_CAT) {
|
|
83
|
+
const step = Math.floor(arr.length / MAX_FILES_PER_CAT);
|
|
84
|
+
cats[key] = Array.from({ length: MAX_FILES_PER_CAT }, (_, i) => arr[i * step]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return cats;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Temp file: 200 lines of realistic JS
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function generateTempCode() {
|
|
95
|
+
const lines = [];
|
|
96
|
+
lines.push('import { readFileSync } from "node:fs";');
|
|
97
|
+
lines.push('import { resolve, basename } from "node:path";');
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push("const DEFAULT_TIMEOUT = 5000;");
|
|
100
|
+
lines.push("const MAX_RETRIES = 3;");
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push("/**");
|
|
103
|
+
lines.push(" * Configuration manager for application settings.");
|
|
104
|
+
lines.push(" * Supports file-based and environment-based config sources.");
|
|
105
|
+
lines.push(" */");
|
|
106
|
+
lines.push("class ConfigManager {");
|
|
107
|
+
lines.push(" constructor(configPath) {");
|
|
108
|
+
lines.push(" this.configPath = resolve(configPath);");
|
|
109
|
+
lines.push(" this.cache = new Map();");
|
|
110
|
+
lines.push(" this.watchers = [];");
|
|
111
|
+
lines.push(" this.loaded = false;");
|
|
112
|
+
lines.push(" }");
|
|
113
|
+
lines.push("");
|
|
114
|
+
lines.push(" load() {");
|
|
115
|
+
lines.push(" const raw = readFileSync(this.configPath, 'utf-8');");
|
|
116
|
+
lines.push(" const parsed = JSON.parse(raw);");
|
|
117
|
+
lines.push(" for (const [key, value] of Object.entries(parsed)) {");
|
|
118
|
+
lines.push(" this.cache.set(key, value);");
|
|
119
|
+
lines.push(" }");
|
|
120
|
+
lines.push(" this.loaded = true;");
|
|
121
|
+
lines.push(" this.notifyWatchers('load', parsed);");
|
|
122
|
+
lines.push(" return this;");
|
|
123
|
+
lines.push(" }");
|
|
124
|
+
lines.push("");
|
|
125
|
+
lines.push(" get(key, defaultValue = undefined) {");
|
|
126
|
+
lines.push(" if (!this.loaded) this.load();");
|
|
127
|
+
lines.push(" return this.cache.has(key) ? this.cache.get(key) : defaultValue;");
|
|
128
|
+
lines.push(" }");
|
|
129
|
+
lines.push("");
|
|
130
|
+
lines.push(" set(key, value) {");
|
|
131
|
+
lines.push(" this.cache.set(key, value);");
|
|
132
|
+
lines.push(" this.notifyWatchers('set', { key, value });");
|
|
133
|
+
lines.push(" }");
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push(" watch(callback) {");
|
|
136
|
+
lines.push(" this.watchers.push(callback);");
|
|
137
|
+
lines.push(" return () => {");
|
|
138
|
+
lines.push(" this.watchers = this.watchers.filter(w => w !== callback);");
|
|
139
|
+
lines.push(" };");
|
|
140
|
+
lines.push(" }");
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(" notifyWatchers(event, data) {");
|
|
143
|
+
lines.push(" for (const watcher of this.watchers) {");
|
|
144
|
+
lines.push(" try { watcher(event, data); }");
|
|
145
|
+
lines.push(" catch (e) { console.error('Watcher error:', e.message); }");
|
|
146
|
+
lines.push(" }");
|
|
147
|
+
lines.push(" }");
|
|
148
|
+
lines.push("}");
|
|
149
|
+
lines.push("");
|
|
150
|
+
lines.push("/**");
|
|
151
|
+
lines.push(" * Retry wrapper with exponential backoff.");
|
|
152
|
+
lines.push(" */");
|
|
153
|
+
lines.push("async function withRetry(fn, options = {}) {");
|
|
154
|
+
lines.push(" const { retries = MAX_RETRIES, delay = 100, backoff = 2 } = options;");
|
|
155
|
+
lines.push(" let lastError;");
|
|
156
|
+
lines.push(" for (let attempt = 0; attempt <= retries; attempt++) {");
|
|
157
|
+
lines.push(" try {");
|
|
158
|
+
lines.push(" return await fn(attempt);");
|
|
159
|
+
lines.push(" } catch (err) {");
|
|
160
|
+
lines.push(" lastError = err;");
|
|
161
|
+
lines.push(" if (attempt < retries) {");
|
|
162
|
+
lines.push(" const wait = delay * Math.pow(backoff, attempt);");
|
|
163
|
+
lines.push(" await new Promise(r => setTimeout(r, wait));");
|
|
164
|
+
lines.push(" }");
|
|
165
|
+
lines.push(" }");
|
|
166
|
+
lines.push(" }");
|
|
167
|
+
lines.push(" throw lastError;");
|
|
168
|
+
lines.push("}");
|
|
169
|
+
lines.push("");
|
|
170
|
+
lines.push("/**");
|
|
171
|
+
lines.push(" * HTTP client with timeout and retry support.");
|
|
172
|
+
lines.push(" */");
|
|
173
|
+
lines.push("class HttpClient {");
|
|
174
|
+
lines.push(" constructor(baseUrl, options = {}) {");
|
|
175
|
+
lines.push(" this.baseUrl = baseUrl.replace(/\\/$/, '');");
|
|
176
|
+
lines.push(" this.timeout = options.timeout || DEFAULT_TIMEOUT;");
|
|
177
|
+
lines.push(" this.headers = options.headers || {};");
|
|
178
|
+
lines.push(" this.retries = options.retries || MAX_RETRIES;");
|
|
179
|
+
lines.push(" }");
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(" async request(method, path, body = null) {");
|
|
182
|
+
lines.push(" const url = `${this.baseUrl}${path}`;");
|
|
183
|
+
lines.push(" const controller = new AbortController();");
|
|
184
|
+
lines.push(" const timer = setTimeout(() => controller.abort(), this.timeout);");
|
|
185
|
+
lines.push("");
|
|
186
|
+
lines.push(" try {");
|
|
187
|
+
lines.push(" return await withRetry(async () => {");
|
|
188
|
+
lines.push(" const opts = {");
|
|
189
|
+
lines.push(" method,");
|
|
190
|
+
lines.push(" headers: { ...this.headers },");
|
|
191
|
+
lines.push(" signal: controller.signal,");
|
|
192
|
+
lines.push(" };");
|
|
193
|
+
lines.push(" if (body) {");
|
|
194
|
+
lines.push(" opts.headers['Content-Type'] = 'application/json';");
|
|
195
|
+
lines.push(" opts.body = JSON.stringify(body);");
|
|
196
|
+
lines.push(" }");
|
|
197
|
+
lines.push(" const response = await fetch(url, opts);");
|
|
198
|
+
lines.push(" if (!response.ok) {");
|
|
199
|
+
lines.push(" throw new Error(`HTTP ${response.status}: ${response.statusText}`);");
|
|
200
|
+
lines.push(" }");
|
|
201
|
+
lines.push(" return response.json();");
|
|
202
|
+
lines.push(" }, { retries: this.retries });");
|
|
203
|
+
lines.push(" } finally {");
|
|
204
|
+
lines.push(" clearTimeout(timer);");
|
|
205
|
+
lines.push(" }");
|
|
206
|
+
lines.push(" }");
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push(" get(path) { return this.request('GET', path); }");
|
|
209
|
+
lines.push(" post(path, body) { return this.request('POST', path, body); }");
|
|
210
|
+
lines.push(" put(path, body) { return this.request('PUT', path, body); }");
|
|
211
|
+
lines.push(" delete(path) { return this.request('DELETE', path); }");
|
|
212
|
+
lines.push("}");
|
|
213
|
+
lines.push("");
|
|
214
|
+
lines.push("/**");
|
|
215
|
+
lines.push(" * Simple event emitter for pub/sub patterns.");
|
|
216
|
+
lines.push(" */");
|
|
217
|
+
lines.push("class EventEmitter {");
|
|
218
|
+
lines.push(" constructor() {");
|
|
219
|
+
lines.push(" this.listeners = new Map();");
|
|
220
|
+
lines.push(" }");
|
|
221
|
+
lines.push("");
|
|
222
|
+
lines.push(" on(event, handler) {");
|
|
223
|
+
lines.push(" if (!this.listeners.has(event)) {");
|
|
224
|
+
lines.push(" this.listeners.set(event, []);");
|
|
225
|
+
lines.push(" }");
|
|
226
|
+
lines.push(" this.listeners.get(event).push(handler);");
|
|
227
|
+
lines.push(" return this;");
|
|
228
|
+
lines.push(" }");
|
|
229
|
+
lines.push("");
|
|
230
|
+
lines.push(" off(event, handler) {");
|
|
231
|
+
lines.push(" const handlers = this.listeners.get(event);");
|
|
232
|
+
lines.push(" if (handlers) {");
|
|
233
|
+
lines.push(" this.listeners.set(event, handlers.filter(h => h !== handler));");
|
|
234
|
+
lines.push(" }");
|
|
235
|
+
lines.push(" return this;");
|
|
236
|
+
lines.push(" }");
|
|
237
|
+
lines.push("");
|
|
238
|
+
lines.push(" emit(event, ...args) {");
|
|
239
|
+
lines.push(" const handlers = this.listeners.get(event) || [];");
|
|
240
|
+
lines.push(" for (const handler of handlers) {");
|
|
241
|
+
lines.push(" handler(...args);");
|
|
242
|
+
lines.push(" }");
|
|
243
|
+
lines.push(" }");
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(" once(event, handler) {");
|
|
246
|
+
lines.push(" const wrapper = (...args) => {");
|
|
247
|
+
lines.push(" handler(...args);");
|
|
248
|
+
lines.push(" this.off(event, wrapper);");
|
|
249
|
+
lines.push(" };");
|
|
250
|
+
lines.push(" return this.on(event, wrapper);");
|
|
251
|
+
lines.push(" }");
|
|
252
|
+
lines.push("}");
|
|
253
|
+
lines.push("");
|
|
254
|
+
lines.push("/**");
|
|
255
|
+
lines.push(" * Validate and sanitize user input.");
|
|
256
|
+
lines.push(" */");
|
|
257
|
+
lines.push("function validateInput(schema, data) {");
|
|
258
|
+
lines.push(" const errors = [];");
|
|
259
|
+
lines.push(" for (const [field, rules] of Object.entries(schema)) {");
|
|
260
|
+
lines.push(" const value = data[field];");
|
|
261
|
+
lines.push(" if (rules.required && (value === undefined || value === null)) {");
|
|
262
|
+
lines.push(" errors.push(`${field} is required`);");
|
|
263
|
+
lines.push(" continue;");
|
|
264
|
+
lines.push(" }");
|
|
265
|
+
lines.push(" if (value !== undefined && rules.type && typeof value !== rules.type) {");
|
|
266
|
+
lines.push(" errors.push(`${field} must be ${rules.type}`);");
|
|
267
|
+
lines.push(" }");
|
|
268
|
+
lines.push(" if (typeof value === 'string' && rules.maxLength && value.length > rules.maxLength) {");
|
|
269
|
+
lines.push(" errors.push(`${field} exceeds max length ${rules.maxLength}`);");
|
|
270
|
+
lines.push(" }");
|
|
271
|
+
lines.push(" if (typeof value === 'number' && rules.min !== undefined && value < rules.min) {");
|
|
272
|
+
lines.push(" errors.push(`${field} must be >= ${rules.min}`);");
|
|
273
|
+
lines.push(" }");
|
|
274
|
+
lines.push(" }");
|
|
275
|
+
lines.push(" return errors.length > 0 ? { valid: false, errors } : { valid: true };");
|
|
276
|
+
lines.push("}");
|
|
277
|
+
lines.push("");
|
|
278
|
+
lines.push("/**");
|
|
279
|
+
lines.push(" * Format bytes to human-readable string.");
|
|
280
|
+
lines.push(" */");
|
|
281
|
+
lines.push("function formatBytes(bytes) {");
|
|
282
|
+
lines.push(" if (bytes === 0) return '0 B';");
|
|
283
|
+
lines.push(" const units = ['B', 'KB', 'MB', 'GB', 'TB'];");
|
|
284
|
+
lines.push(" const exp = Math.floor(Math.log(bytes) / Math.log(1024));");
|
|
285
|
+
lines.push(" const value = bytes / Math.pow(1024, exp);");
|
|
286
|
+
lines.push(" return `${value.toFixed(exp > 0 ? 1 : 0)} ${units[exp]}`;");
|
|
287
|
+
lines.push("}");
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push("/**");
|
|
290
|
+
lines.push(" * Deep merge two objects (source into target).");
|
|
291
|
+
lines.push(" */");
|
|
292
|
+
lines.push("function deepMerge(target, source) {");
|
|
293
|
+
lines.push(" const result = { ...target };");
|
|
294
|
+
lines.push(" for (const key of Object.keys(source)) {");
|
|
295
|
+
lines.push(" if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {");
|
|
296
|
+
lines.push(" result[key] = deepMerge(result[key] || {}, source[key]);");
|
|
297
|
+
lines.push(" } else {");
|
|
298
|
+
lines.push(" result[key] = source[key];");
|
|
299
|
+
lines.push(" }");
|
|
300
|
+
lines.push(" }");
|
|
301
|
+
lines.push(" return result;");
|
|
302
|
+
lines.push("}");
|
|
303
|
+
lines.push("");
|
|
304
|
+
lines.push("export { ConfigManager, HttpClient, EventEmitter, withRetry, validateInput, formatBytes, deepMerge };");
|
|
305
|
+
|
|
306
|
+
// Pad to exactly 200 lines
|
|
307
|
+
while (lines.length < 200) lines.push("");
|
|
308
|
+
return lines.slice(0, 200);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Simulators — "without hex-line" (built-in tool output)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/** Simulate built-in Read: `cat -n` full file with header */
|
|
316
|
+
function simBuiltInReadFull(filePath, lines) {
|
|
317
|
+
const body = lines.map((l, i) => ` ${String(i + 1).padStart(5)}\t${l}`).join("\n");
|
|
318
|
+
return `Contents of ${filePath}:\n\n${body}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Simulate outline via full read — agent reads entire file to understand structure */
|
|
322
|
+
function simBuiltInOutlineFull(filePath, lines) {
|
|
323
|
+
return simBuiltInReadFull(filePath, lines);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Simulate ripgrep raw output (no hashes) */
|
|
327
|
+
function simBuiltInGrep(filePath, lines, pattern) {
|
|
328
|
+
const re = new RegExp(pattern, "i");
|
|
329
|
+
const matches = [];
|
|
330
|
+
for (let i = 0; i < lines.length; i++) {
|
|
331
|
+
if (re.test(lines[i])) {
|
|
332
|
+
matches.push(`${filePath}:${i + 1}:${lines[i]}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return matches.join("\n") || "No matches found.";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Simulate `ls -laR` style output for a directory */
|
|
339
|
+
function simBuiltInLsR(dirPath, depth = 0, maxDepth = 3) {
|
|
340
|
+
if (depth > maxDepth) return "";
|
|
341
|
+
const out = [];
|
|
342
|
+
let entries;
|
|
343
|
+
try { entries = readdirSync(dirPath, { withFileTypes: true }); }
|
|
344
|
+
catch { return ""; }
|
|
345
|
+
|
|
346
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", "__pycache__", "coverage"]);
|
|
347
|
+
|
|
348
|
+
out.push(`${dirPath}:`);
|
|
349
|
+
out.push("total " + entries.length);
|
|
350
|
+
|
|
351
|
+
for (const e of entries) {
|
|
352
|
+
if (SKIP.has(e.name) && e.isDirectory()) continue;
|
|
353
|
+
const full = join(dirPath, e.name);
|
|
354
|
+
try {
|
|
355
|
+
const st = statSync(full);
|
|
356
|
+
const type = e.isDirectory() ? "d" : "-";
|
|
357
|
+
const size = String(st.size).padStart(8);
|
|
358
|
+
const date = st.mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
359
|
+
out.push(`${type}rw-r--r-- 1 user group ${size} ${date} ${e.name}`);
|
|
360
|
+
} catch { /* skip */ }
|
|
361
|
+
}
|
|
362
|
+
out.push("");
|
|
363
|
+
|
|
364
|
+
for (const e of entries) {
|
|
365
|
+
if (!e.isDirectory()) continue;
|
|
366
|
+
if (SKIP.has(e.name)) continue;
|
|
367
|
+
const full = join(dirPath, e.name);
|
|
368
|
+
const sub = simBuiltInLsR(full, depth + 1, maxDepth);
|
|
369
|
+
if (sub) out.push(sub);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return out.join("\n");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Simulate `stat` output for a file */
|
|
376
|
+
function simBuiltInStat(filePath) {
|
|
377
|
+
const st = statSync(filePath);
|
|
378
|
+
return [
|
|
379
|
+
` File: ${filePath}`,
|
|
380
|
+
` Size: ${st.size}\tBlocks: ${Math.ceil(st.size / 512)}\tIO Block: 4096\tregular file`,
|
|
381
|
+
`Device: 0h/0d\tInode: 0\tLinks: 1`,
|
|
382
|
+
`Access: (0644/-rw-r--r--)\tUid: ( 1000/ user)\tGid: ( 1000/ group)`,
|
|
383
|
+
`Access: ${st.atime.toISOString()}`,
|
|
384
|
+
`Modify: ${st.mtime.toISOString()}`,
|
|
385
|
+
`Change: ${st.ctime.toISOString()}`,
|
|
386
|
+
` Birth: ${st.birthtime.toISOString()}`,
|
|
387
|
+
].join("\n");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Simulate built-in write response */
|
|
391
|
+
function simBuiltInWrite(filePath, content) {
|
|
392
|
+
const lineCount = content.split("\n").length;
|
|
393
|
+
return `File ${filePath} has been created successfully (${lineCount} lines).`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Simulate built-in edit: old_string/new_string context blocks */
|
|
397
|
+
function simBuiltInEdit(filePath, origLines, newLines) {
|
|
398
|
+
let changeStart = -1, changeEnd = -1;
|
|
399
|
+
for (let i = 0; i < Math.max(origLines.length, newLines.length); i++) {
|
|
400
|
+
if (origLines[i] !== newLines[i]) {
|
|
401
|
+
if (changeStart === -1) changeStart = i;
|
|
402
|
+
changeEnd = i;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (changeStart === -1) return "";
|
|
406
|
+
|
|
407
|
+
const ctxBefore = Math.max(0, changeStart - 3);
|
|
408
|
+
const ctxAfter = Math.min(origLines.length, changeEnd + 4);
|
|
409
|
+
const old_string = origLines.slice(ctxBefore, ctxAfter).join("\n");
|
|
410
|
+
const new_string = newLines.slice(ctxBefore, Math.min(newLines.length, changeEnd + 4)).join("\n");
|
|
411
|
+
return `The file ${filePath} has been edited. Here's the result of running \`cat -n\` on a snippet:\n` +
|
|
412
|
+
`old_string:\n${old_string}\nnew_string:\n${new_string}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Simulate built-in verify: full re-read to check if file changed */
|
|
416
|
+
function simBuiltInVerify(filePath, lines) {
|
|
417
|
+
return simBuiltInReadFull(filePath, lines);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Simulators — "with hex-line" (lib function output)
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/** Hex-line outline — regex heuristic (no tree-sitter in benchmark) */
|
|
425
|
+
function simHexLineOutline(lines) {
|
|
426
|
+
const structural = /^\s*(export\s+)?(function|class|def|async\s+def|impl|fn|pub\s+fn|struct|interface|type|enum|const|let|var)\b/;
|
|
427
|
+
const importLine = /^\s*(import|from|require|use|#include)/;
|
|
428
|
+
const entries = [];
|
|
429
|
+
let importStart = -1, importEnd = -1, importCount = 0;
|
|
430
|
+
|
|
431
|
+
for (let i = 0; i < lines.length; i++) {
|
|
432
|
+
if (importLine.test(lines[i])) {
|
|
433
|
+
if (importStart === -1) importStart = i + 1;
|
|
434
|
+
importEnd = i + 1;
|
|
435
|
+
importCount++;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (structural.test(lines[i])) {
|
|
439
|
+
let end = lines.length;
|
|
440
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
441
|
+
if (structural.test(lines[j])) { end = j; break; }
|
|
442
|
+
}
|
|
443
|
+
entries.push(`${i + 1}-${end}: ${lines[i].trim().slice(0, 120)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const parts = [];
|
|
448
|
+
if (importCount > 0) parts.push(`${importStart}-${importEnd}: (${importCount} imports/declarations)`);
|
|
449
|
+
parts.push(...entries);
|
|
450
|
+
parts.push("", `(${entries.length} symbols, ${lines.length} source lines)`);
|
|
451
|
+
return `File: benchmark-target\n\n${parts.join("\n")}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Hex-line outline + targeted read of first function (30 lines) */
|
|
455
|
+
function simHexLineOutlinePlusRead(filePath, lines) {
|
|
456
|
+
const outlineStr = simHexLineOutline(lines);
|
|
457
|
+
const structural = /^\s*(export\s+)?(function|class|def|async\s+def|impl|fn|pub\s+fn|struct)\b/;
|
|
458
|
+
let funcStart = 0;
|
|
459
|
+
for (let i = 0; i < lines.length; i++) {
|
|
460
|
+
if (structural.test(lines[i])) { funcStart = i + 1; break; }
|
|
461
|
+
}
|
|
462
|
+
const start = Math.max(1, funcStart);
|
|
463
|
+
const readStr = readFile(filePath, { offset: start, limit: 30 });
|
|
464
|
+
return outlineStr + "\n---\n" + readStr;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Hex-line grep — hash-annotated format */
|
|
468
|
+
function simHexLineGrep(filePath, lines, pattern) {
|
|
469
|
+
const re = new RegExp(pattern, "i");
|
|
470
|
+
const matches = [];
|
|
471
|
+
for (let i = 0; i < lines.length; i++) {
|
|
472
|
+
if (re.test(lines[i])) {
|
|
473
|
+
const tag = lineTag(fnv1a(lines[i]));
|
|
474
|
+
matches.push(`${filePath}:>>${tag}.${i + 1}\t${lines[i]}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return matches.length > 0
|
|
478
|
+
? "```\n" + matches.join("\n") + "\n```"
|
|
479
|
+
: "No matches found.";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Hex-line write response */
|
|
483
|
+
function simHexLineWrite(filePath, content) {
|
|
484
|
+
const lineCount = content.split("\n").length;
|
|
485
|
+
return `Created ${filePath} (${lineCount} lines)`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Hex-line edit response: compact diff hunks */
|
|
489
|
+
function simHexLineEditDiff(origLines, newLines, ctx = 3) {
|
|
490
|
+
const out = [];
|
|
491
|
+
const maxLen = Math.max(origLines.length, newLines.length);
|
|
492
|
+
let i = 0;
|
|
493
|
+
|
|
494
|
+
while (i < maxLen) {
|
|
495
|
+
if (i < origLines.length && i < newLines.length && origLines[i] === newLines[i]) {
|
|
496
|
+
i++;
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
// Found a difference — show context before
|
|
500
|
+
const ctxStart = Math.max(0, i - ctx);
|
|
501
|
+
if (ctxStart < i) {
|
|
502
|
+
if (ctxStart > 0) out.push("...");
|
|
503
|
+
for (let k = ctxStart; k < i; k++) {
|
|
504
|
+
out.push(` ${k + 1}| ${origLines[k]}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Show changed lines
|
|
508
|
+
const changeStart = i;
|
|
509
|
+
while (i < maxLen && (i >= origLines.length || i >= newLines.length || origLines[i] !== newLines[i])) {
|
|
510
|
+
if (i < origLines.length) out.push(`-${i + 1}| ${origLines[i]}`);
|
|
511
|
+
i++;
|
|
512
|
+
}
|
|
513
|
+
for (let k = changeStart; k < i && k < newLines.length; k++) {
|
|
514
|
+
out.push(`+${k + 1}| ${newLines[k]}`);
|
|
515
|
+
}
|
|
516
|
+
// Context after
|
|
517
|
+
const ctxEnd = Math.min(maxLen, i + ctx);
|
|
518
|
+
for (let k = i; k < ctxEnd && k < origLines.length; k++) {
|
|
519
|
+
out.push(` ${k + 1}| ${origLines[k]}`);
|
|
520
|
+
}
|
|
521
|
+
if (ctxEnd < maxLen) out.push("...");
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const diff = out.join("\n");
|
|
526
|
+
return diff
|
|
527
|
+
? `Updated benchmark-file\n\nDiff:\n\`\`\`diff\n${diff}\n\`\`\``
|
|
528
|
+
: "Updated benchmark-file";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// Runner utilities
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
function median(arr) {
|
|
536
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
537
|
+
const mid = Math.floor(sorted.length / 2);
|
|
538
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function runN(fn, n = RUNS) {
|
|
542
|
+
const results = [];
|
|
543
|
+
for (let i = 0; i < n; i++) results.push(fn());
|
|
544
|
+
return median(results);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function fmt(n) {
|
|
548
|
+
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function pctSavings(without, withSL) {
|
|
552
|
+
if (without === 0) return "N/A";
|
|
553
|
+
const pct = ((without - withSL) / without) * 100;
|
|
554
|
+
return pct >= 0 ? `${pct.toFixed(0)}%` : `-${Math.abs(pct).toFixed(0)}%`;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Main
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
function main() {
|
|
562
|
+
const allFiles = walkDir(repoRoot);
|
|
563
|
+
if (allFiles.length === 0) {
|
|
564
|
+
console.log(`No code files found in ${repoRoot}`);
|
|
565
|
+
process.exit(1);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const totalLines = allFiles.reduce((sum, f) => {
|
|
569
|
+
const lines = getFileLines(f);
|
|
570
|
+
return lines ? sum + lines.length : sum;
|
|
571
|
+
}, 0);
|
|
572
|
+
|
|
573
|
+
const cats = categorize(allFiles);
|
|
574
|
+
const repoName = basename(repoRoot);
|
|
575
|
+
|
|
576
|
+
// Temp file setup
|
|
577
|
+
const ts = Date.now();
|
|
578
|
+
const tmpPath = resolve(tmpdir(), `hex-line-bench-${ts}.js`);
|
|
579
|
+
const tmpLines = generateTempCode();
|
|
580
|
+
const tmpContent = tmpLines.join("\n");
|
|
581
|
+
writeFileSync(tmpPath, tmpContent, "utf-8");
|
|
582
|
+
|
|
583
|
+
const results = [];
|
|
584
|
+
|
|
585
|
+
// ===================================================================
|
|
586
|
+
// TEST 1: Read full file
|
|
587
|
+
// ===================================================================
|
|
588
|
+
for (const [cat, files] of Object.entries(cats)) {
|
|
589
|
+
if (files.length === 0) continue;
|
|
590
|
+
const withoutArr = [];
|
|
591
|
+
const withArr = [];
|
|
592
|
+
|
|
593
|
+
for (const f of files) {
|
|
594
|
+
const lines = getFileLines(f);
|
|
595
|
+
if (!lines) continue;
|
|
596
|
+
withoutArr.push(runN(() => simBuiltInReadFull(f, lines).length));
|
|
597
|
+
withArr.push(runN(() => readFile(f).length));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (withoutArr.length === 0) continue;
|
|
601
|
+
const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b, 0) / withoutArr.length);
|
|
602
|
+
const avgWith = Math.round(withArr.reduce((a, b) => a + b, 0) / withArr.length);
|
|
603
|
+
|
|
604
|
+
const label = { small: "<50L", medium: "50-200L", large: "200-500L", xl: "500L+" }[cat];
|
|
605
|
+
results.push({
|
|
606
|
+
num: 1, scenario: `Read full (${label})`,
|
|
607
|
+
without: avgWithout, withSL: avgWith,
|
|
608
|
+
savings: pctSavings(avgWithout, avgWith),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ===================================================================
|
|
613
|
+
// TEST 2: Read with outline — full read vs outline + targeted read
|
|
614
|
+
// ===================================================================
|
|
615
|
+
for (const cat of ["large", "xl"]) {
|
|
616
|
+
const files = cats[cat] || [];
|
|
617
|
+
if (files.length === 0) continue;
|
|
618
|
+
const withoutArr = [];
|
|
619
|
+
const withArr = [];
|
|
620
|
+
|
|
621
|
+
for (const f of files) {
|
|
622
|
+
const lines = getFileLines(f);
|
|
623
|
+
if (!lines) continue;
|
|
624
|
+
withoutArr.push(runN(() => simBuiltInOutlineFull(f, lines).length));
|
|
625
|
+
withArr.push(runN(() => simHexLineOutlinePlusRead(f, lines).length));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (withoutArr.length === 0) continue;
|
|
629
|
+
const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b, 0) / withoutArr.length);
|
|
630
|
+
const avgWith = Math.round(withArr.reduce((a, b) => a + b, 0) / withArr.length);
|
|
631
|
+
|
|
632
|
+
const label = cat === "large" ? "200-500L" : "500L+";
|
|
633
|
+
results.push({
|
|
634
|
+
num: 2, scenario: `Outline+read (${label})`,
|
|
635
|
+
without: avgWithout, withSL: avgWith,
|
|
636
|
+
savings: pctSavings(avgWithout, avgWith),
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ===================================================================
|
|
641
|
+
// TEST 3: Grep search
|
|
642
|
+
// ===================================================================
|
|
643
|
+
{
|
|
644
|
+
const grepFiles = [...(cats.medium || []), ...(cats.large || []), ...(cats.xl || [])].slice(0, 3);
|
|
645
|
+
if (grepFiles.length > 0) {
|
|
646
|
+
const withoutArr = [];
|
|
647
|
+
const withArr = [];
|
|
648
|
+
|
|
649
|
+
for (const f of grepFiles) {
|
|
650
|
+
const lines = getFileLines(f);
|
|
651
|
+
if (!lines) continue;
|
|
652
|
+
const pattern = "function|class|const";
|
|
653
|
+
withoutArr.push(runN(() => simBuiltInGrep(f, lines, pattern).length));
|
|
654
|
+
withArr.push(runN(() => simHexLineGrep(f, lines, pattern).length));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (withoutArr.length > 0) {
|
|
658
|
+
const avgWithout = Math.round(withoutArr.reduce((a, b) => a + b, 0) / withoutArr.length);
|
|
659
|
+
const avgWith = Math.round(withArr.reduce((a, b) => a + b, 0) / withArr.length);
|
|
660
|
+
results.push({
|
|
661
|
+
num: 3, scenario: "Grep search",
|
|
662
|
+
without: avgWithout, withSL: avgWith,
|
|
663
|
+
savings: pctSavings(avgWithout, avgWith),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ===================================================================
|
|
670
|
+
// TEST 4: Directory tree
|
|
671
|
+
// ===================================================================
|
|
672
|
+
{
|
|
673
|
+
const without = runN(() => simBuiltInLsR(repoRoot, 0, 3).length);
|
|
674
|
+
const withSL = runN(() => directoryTree(repoRoot, { max_depth: 3 }).length);
|
|
675
|
+
results.push({
|
|
676
|
+
num: 4, scenario: "Directory tree",
|
|
677
|
+
without, withSL,
|
|
678
|
+
savings: pctSavings(without, withSL),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ===================================================================
|
|
683
|
+
// TEST 5: File info
|
|
684
|
+
// ===================================================================
|
|
685
|
+
{
|
|
686
|
+
const infoFile = allFiles[Math.floor(allFiles.length / 2)] || allFiles[0];
|
|
687
|
+
const without = runN(() => simBuiltInStat(infoFile).length);
|
|
688
|
+
const withSL = runN(() => fileInfo(infoFile).length);
|
|
689
|
+
results.push({
|
|
690
|
+
num: 5, scenario: "File info",
|
|
691
|
+
without, withSL,
|
|
692
|
+
savings: pctSavings(without, withSL),
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ===================================================================
|
|
697
|
+
// TEST 6: Create file (write)
|
|
698
|
+
// ===================================================================
|
|
699
|
+
{
|
|
700
|
+
const without = runN(() => simBuiltInWrite(tmpPath, tmpContent).length);
|
|
701
|
+
const withSL = runN(() => simHexLineWrite(tmpPath, tmpContent).length);
|
|
702
|
+
results.push({
|
|
703
|
+
num: 6, scenario: "Create file (200L)",
|
|
704
|
+
without, withSL,
|
|
705
|
+
savings: pctSavings(without, withSL),
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ===================================================================
|
|
710
|
+
// TEST 7: Edit x5 sequential
|
|
711
|
+
// ===================================================================
|
|
712
|
+
{
|
|
713
|
+
const editTargets = [
|
|
714
|
+
{ line: 13, new: ' this.configPath = resolve(configPath || ".");' },
|
|
715
|
+
{ line: 55, new: " const { retries = MAX_RETRIES, delay = 200, backoff = 3 } = options;" },
|
|
716
|
+
{ line: 75, new: " this.timeout = options.timeout ?? DEFAULT_TIMEOUT;" },
|
|
717
|
+
{ line: 116, new: " return this; // chainable" },
|
|
718
|
+
{ line: 148, new: " /** @type {string[]} */\n const errors = [];" },
|
|
719
|
+
];
|
|
720
|
+
|
|
721
|
+
let totalWithout = 0;
|
|
722
|
+
let totalWith = 0;
|
|
723
|
+
|
|
724
|
+
for (const edit of editTargets) {
|
|
725
|
+
const origLines = [...tmpLines];
|
|
726
|
+
const newLines = [...tmpLines];
|
|
727
|
+
const idx = edit.line - 1;
|
|
728
|
+
if (idx < newLines.length) {
|
|
729
|
+
newLines[idx] = edit.new;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
totalWithout += runN(() => simBuiltInEdit(tmpPath, origLines, newLines).length);
|
|
733
|
+
totalWith += runN(() => simHexLineEditDiff(origLines, newLines).length);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
results.push({
|
|
737
|
+
num: 7, scenario: "Edit x5 sequential",
|
|
738
|
+
without: totalWithout, withSL: totalWith,
|
|
739
|
+
savings: pctSavings(totalWithout, totalWith),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ===================================================================
|
|
744
|
+
// TEST 8: Verify checksums
|
|
745
|
+
// ===================================================================
|
|
746
|
+
{
|
|
747
|
+
const fileLines = readFileSync(tmpPath, "utf-8").replace(/\r\n/g, "\n").split("\n");
|
|
748
|
+
const hashes = fileLines.map(l => fnv1a(l));
|
|
749
|
+
const cs1 = rangeChecksum(hashes.slice(0, 50), 1, 50);
|
|
750
|
+
const cs2 = rangeChecksum(hashes.slice(50, 100), 51, 100);
|
|
751
|
+
const cs3 = rangeChecksum(hashes.slice(100, 150), 101, 150);
|
|
752
|
+
const cs4 = rangeChecksum(hashes.slice(150, 200), 151, 200);
|
|
753
|
+
const checksums = [cs1, cs2, cs3, cs4];
|
|
754
|
+
|
|
755
|
+
const without = runN(() => simBuiltInVerify(tmpPath, fileLines).length);
|
|
756
|
+
const withSL = runN(() => verifyChecksums(tmpPath, checksums).length);
|
|
757
|
+
|
|
758
|
+
results.push({
|
|
759
|
+
num: 8, scenario: "Verify checksums (4 ranges)",
|
|
760
|
+
without, withSL,
|
|
761
|
+
savings: pctSavings(without, withSL),
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ===================================================================
|
|
766
|
+
// TEST 9: Multi-file read (batch)
|
|
767
|
+
// ===================================================================
|
|
768
|
+
{
|
|
769
|
+
const batchFiles = (cats.small || []).slice(0, 3);
|
|
770
|
+
if (batchFiles.length >= 2) {
|
|
771
|
+
// Without hex-line: N separate Read calls
|
|
772
|
+
const without = runN(() => {
|
|
773
|
+
let total = 0;
|
|
774
|
+
for (const f of batchFiles) {
|
|
775
|
+
const lines = getFileLines(f);
|
|
776
|
+
if (lines) total += simBuiltInReadFull(f, lines).length;
|
|
777
|
+
}
|
|
778
|
+
return total;
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// With hex-line: 1 read_file call with paths:[] — concatenated output
|
|
782
|
+
const withSL = runN(() => {
|
|
783
|
+
const parts = [];
|
|
784
|
+
for (const f of batchFiles) {
|
|
785
|
+
parts.push(readFile(f));
|
|
786
|
+
}
|
|
787
|
+
return parts.join("\n\n---\n\n").length;
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
results.push({
|
|
791
|
+
num: 9, scenario: `Multi-file read (${batchFiles.length} files)`,
|
|
792
|
+
without, withSL,
|
|
793
|
+
savings: pctSavings(without, withSL),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ===================================================================
|
|
799
|
+
// TEST 10: bulk_replace dry_run
|
|
800
|
+
// ===================================================================
|
|
801
|
+
{
|
|
802
|
+
const bulkTmpPaths = [];
|
|
803
|
+
for (let i = 0; i < 5; i++) {
|
|
804
|
+
const p = resolve(tmpdir(), `hex-line-bulk-${ts}-${i}.js`);
|
|
805
|
+
writeFileSync(p, tmpContent, "utf-8");
|
|
806
|
+
bulkTmpPaths.push(p);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const editLine = 13;
|
|
810
|
+
const editNew = ' this.configPath = resolve(configPath || ".");';
|
|
811
|
+
|
|
812
|
+
// Without hex-line: 5 separate edit_file calls
|
|
813
|
+
const without = runN(() => {
|
|
814
|
+
let total = 0;
|
|
815
|
+
for (const p of bulkTmpPaths) {
|
|
816
|
+
const origLines = [...tmpLines];
|
|
817
|
+
const newLines = [...tmpLines];
|
|
818
|
+
newLines[editLine - 1] = editNew;
|
|
819
|
+
total += simBuiltInEdit(p, origLines, newLines).length;
|
|
820
|
+
}
|
|
821
|
+
return total;
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// With hex-line: 1 bulk_replace — summary + per-file compact diff
|
|
825
|
+
const withSL = runN(() => {
|
|
826
|
+
let response = "5 files changed, 0 errors\n";
|
|
827
|
+
for (const p of bulkTmpPaths) {
|
|
828
|
+
const origLines = [...tmpLines];
|
|
829
|
+
const newLines = [...tmpLines];
|
|
830
|
+
newLines[editLine - 1] = editNew;
|
|
831
|
+
response += simHexLineEditDiff(origLines, newLines) + "\n";
|
|
832
|
+
}
|
|
833
|
+
return response.length;
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
results.push({
|
|
837
|
+
num: 10, scenario: "bulk_replace dry_run (5 files)",
|
|
838
|
+
without, withSL,
|
|
839
|
+
savings: pctSavings(without, withSL),
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
for (const p of bulkTmpPaths) {
|
|
843
|
+
try { unlinkSync(p); } catch { /* ok */ }
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ===================================================================
|
|
848
|
+
// TEST 11: changes (semantic diff)
|
|
849
|
+
// ===================================================================
|
|
850
|
+
{
|
|
851
|
+
// Without hex-line: raw unified diff output
|
|
852
|
+
const without = runN(() => {
|
|
853
|
+
const diffLines = [
|
|
854
|
+
`diff --git a/benchmark-target.js b/benchmark-target.js`,
|
|
855
|
+
`index abc1234..def5678 100644`,
|
|
856
|
+
`--- a/benchmark-target.js`,
|
|
857
|
+
`+++ b/benchmark-target.js`,
|
|
858
|
+
`@@ -10,6 +10,12 @@ const DEFAULT_TIMEOUT = 5000;`,
|
|
859
|
+
];
|
|
860
|
+
// Simulate ~15 context + change lines typical of a small diff
|
|
861
|
+
for (let i = 0; i < 5; i++) {
|
|
862
|
+
diffLines.push(` ${tmpLines[i + 5] || " // context line"}`); // context
|
|
863
|
+
}
|
|
864
|
+
diffLines.push(`-${tmpLines[12] || " old line"}`);
|
|
865
|
+
diffLines.push(`+ this.configPath = resolve(configPath || ".");`);
|
|
866
|
+
for (let i = 0; i < 5; i++) {
|
|
867
|
+
diffLines.push(` ${tmpLines[i + 14] || " // context line"}`); // context
|
|
868
|
+
}
|
|
869
|
+
// Second hunk — added function
|
|
870
|
+
diffLines.push(`@@ -195,0 +201,8 @@`);
|
|
871
|
+
for (let i = 0; i < 3; i++) {
|
|
872
|
+
diffLines.push(` ${tmpLines[i + 150] || " // context"}`);
|
|
873
|
+
}
|
|
874
|
+
for (let i = 0; i < 5; i++) {
|
|
875
|
+
diffLines.push(`+ // new function line ${i}`);
|
|
876
|
+
}
|
|
877
|
+
for (let i = 0; i < 3; i++) {
|
|
878
|
+
diffLines.push(` ${tmpLines[i + 155] || " // context"}`);
|
|
879
|
+
}
|
|
880
|
+
return diffLines.join("\n").length;
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// With hex-line: semantic changes summary
|
|
884
|
+
const withSL = runN(() => {
|
|
885
|
+
const changes = [
|
|
886
|
+
"Added:",
|
|
887
|
+
" + formatDuration (line 201, 5 lines)",
|
|
888
|
+
"Modified:",
|
|
889
|
+
" ~ ConfigManager.constructor (line 12, +1 line)",
|
|
890
|
+
"Summary: 1 added, 1 modified",
|
|
891
|
+
];
|
|
892
|
+
return changes.join("\n").length;
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
results.push({
|
|
896
|
+
num: 11, scenario: "Changes (semantic diff)",
|
|
897
|
+
without, withSL,
|
|
898
|
+
savings: pctSavings(without, withSL),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ===================================================================
|
|
903
|
+
// TEST 12: FILE_NOT_FOUND recovery
|
|
904
|
+
// ===================================================================
|
|
905
|
+
{
|
|
906
|
+
const missingPath = resolve(repoRoot, "src/utils/halper.js");
|
|
907
|
+
const parentDir = resolve(repoRoot, "src/utils");
|
|
908
|
+
|
|
909
|
+
// Without hex-line: 3 round-trips (error → ls → retry)
|
|
910
|
+
const without = runN(() => {
|
|
911
|
+
// Round 1: error
|
|
912
|
+
const r1 = `Error: ENOENT: no such file or directory, open '${missingPath}'`;
|
|
913
|
+
// Round 2: agent calls ls to find correct name
|
|
914
|
+
const dirEntries = [];
|
|
915
|
+
for (let i = 0; i < 10; i++) {
|
|
916
|
+
dirEntries.push(`-rw-r--r-- 1 user group 1234 2025-03-20 10:00 file_${i}.js`);
|
|
917
|
+
}
|
|
918
|
+
const r2 = `${parentDir}:\ntotal 10\n${dirEntries.join("\n")}`;
|
|
919
|
+
// Round 3: agent re-reads correct file (small file ~30 lines)
|
|
920
|
+
const r3 = simBuiltInReadFull(missingPath, tmpLines.slice(0, 30));
|
|
921
|
+
return (r1 + r2 + r3).length;
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// With hex-line: 1 round-trip (error + parent dir listing)
|
|
925
|
+
const withSL = runN(() => {
|
|
926
|
+
const entries = [];
|
|
927
|
+
for (let i = 0; i < 10; i++) {
|
|
928
|
+
entries.push(` file_${i}.js`);
|
|
929
|
+
}
|
|
930
|
+
const response = `FILE_NOT_FOUND: ${missingPath}\n` +
|
|
931
|
+
`Parent directory (${parentDir}):\n${entries.join("\n")}`;
|
|
932
|
+
return response.length;
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
results.push({
|
|
936
|
+
num: 12, scenario: "FILE_NOT_FOUND recovery",
|
|
937
|
+
without, withSL,
|
|
938
|
+
savings: pctSavings(without, withSL),
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ===================================================================
|
|
943
|
+
// TEST 13: Hash mismatch recovery
|
|
944
|
+
// ===================================================================
|
|
945
|
+
{
|
|
946
|
+
// Without hex-line: 3 round-trips (stale error → re-read full → retry edit)
|
|
947
|
+
const without = runN(() => {
|
|
948
|
+
// Round 1: error
|
|
949
|
+
const r1 = 'Error: file content has changed (stale). Please re-read the file.';
|
|
950
|
+
// Round 2: full re-read
|
|
951
|
+
const r2 = simBuiltInReadFull(tmpPath, tmpLines);
|
|
952
|
+
// Round 3: retry edit response
|
|
953
|
+
const origLines = [...tmpLines];
|
|
954
|
+
const newLines = [...tmpLines];
|
|
955
|
+
newLines[12] = ' this.configPath = resolve(configPath || ".");';
|
|
956
|
+
const r3 = simBuiltInEdit(tmpPath, origLines, newLines);
|
|
957
|
+
return (r1 + r2 + r3).length;
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
// With hex-line: 1 round-trip (error + fresh snippet +/-5 lines around target)
|
|
961
|
+
const withSL = runN(() => {
|
|
962
|
+
const targetLine = 13;
|
|
963
|
+
const snippetStart = Math.max(0, targetLine - 6);
|
|
964
|
+
const snippetEnd = Math.min(tmpLines.length, targetLine + 5);
|
|
965
|
+
const snippet = tmpLines.slice(snippetStart, snippetEnd);
|
|
966
|
+
const annotated = snippet.map((l, i) => {
|
|
967
|
+
const lineNum = snippetStart + i + 1;
|
|
968
|
+
const tag = lineTag(fnv1a(l));
|
|
969
|
+
return `${tag}.${lineNum}\t${l}`;
|
|
970
|
+
}).join("\n");
|
|
971
|
+
const response = `HASH_MISMATCH at line ${targetLine}. Fresh snippet:\n\`\`\`\n${annotated}\n\`\`\``;
|
|
972
|
+
return response.length;
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
results.push({
|
|
976
|
+
num: 13, scenario: "Hash mismatch recovery",
|
|
977
|
+
without, withSL,
|
|
978
|
+
savings: pctSavings(without, withSL),
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ===================================================================
|
|
983
|
+
// TEST 14: Bash redirect savings
|
|
984
|
+
// ===================================================================
|
|
985
|
+
{
|
|
986
|
+
const infoFile = allFiles[Math.floor(allFiles.length / 2)] || allFiles[0];
|
|
987
|
+
const infoLines = getFileLines(infoFile);
|
|
988
|
+
if (infoLines) {
|
|
989
|
+
// Sub-test A: cat vs read_file
|
|
990
|
+
const catWithout = runN(() => {
|
|
991
|
+
// cat output: raw lines, no line numbers (agent redirect)
|
|
992
|
+
return infoLines.join("\n").length;
|
|
993
|
+
});
|
|
994
|
+
const catWith = runN(() => readFile(infoFile).length);
|
|
995
|
+
|
|
996
|
+
// Sub-test B: ls -la vs directory_tree
|
|
997
|
+
const dirTarget = resolve(repoRoot);
|
|
998
|
+
const lsWithout = runN(() => simBuiltInLsR(dirTarget, 0, 1).length);
|
|
999
|
+
const lsWith = runN(() => directoryTree(dirTarget, { max_depth: 1 }).length);
|
|
1000
|
+
|
|
1001
|
+
// Sub-test C: stat vs get_file_info
|
|
1002
|
+
const statWithout = runN(() => simBuiltInStat(infoFile).length);
|
|
1003
|
+
const statWith = runN(() => fileInfo(infoFile).length);
|
|
1004
|
+
|
|
1005
|
+
// Combined: without = raw outputs (no follow-up possible)
|
|
1006
|
+
// With = structured output (enables follow-up without extra calls)
|
|
1007
|
+
const totalWithout = catWithout + lsWithout + statWithout;
|
|
1008
|
+
const totalWith = catWith + lsWith + statWith;
|
|
1009
|
+
|
|
1010
|
+
results.push({
|
|
1011
|
+
num: 14, scenario: "Bash redirects (cat+ls+stat)",
|
|
1012
|
+
without: totalWithout, withSL: totalWith,
|
|
1013
|
+
savings: pctSavings(totalWithout, totalWith),
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ===================================================================
|
|
1019
|
+
// Cleanup
|
|
1020
|
+
// ===================================================================
|
|
1021
|
+
try { unlinkSync(tmpPath); } catch { /* ok */ }
|
|
1022
|
+
|
|
1023
|
+
// ===================================================================
|
|
1024
|
+
// Report
|
|
1025
|
+
// ===================================================================
|
|
1026
|
+
const out = [];
|
|
1027
|
+
out.push("# Hex-line Benchmark v2");
|
|
1028
|
+
out.push("");
|
|
1029
|
+
out.push(`Repository: ${repoName} (${fmt(allFiles.length)} code files, ${fmt(totalLines)} lines) `);
|
|
1030
|
+
out.push(`Temp file: ${tmpPath} (200 lines) `);
|
|
1031
|
+
out.push(`Date: ${new Date().toISOString().slice(0, 10)} `);
|
|
1032
|
+
out.push(`Runs per scenario: ${RUNS} (median) `);
|
|
1033
|
+
out.push("");
|
|
1034
|
+
|
|
1035
|
+
// Results table
|
|
1036
|
+
out.push("## Results");
|
|
1037
|
+
out.push("");
|
|
1038
|
+
out.push("| # | Scenario | Without Hex-line | With Hex-line | Savings |");
|
|
1039
|
+
out.push("|---|----------|-------------------|----------------|---------|");
|
|
1040
|
+
|
|
1041
|
+
for (const r of results) {
|
|
1042
|
+
out.push(`| ${r.num} | ${r.scenario} | ${fmt(r.without)} chars | ${fmt(r.withSL)} chars | ${r.savings} |`);
|
|
1043
|
+
}
|
|
1044
|
+
out.push("");
|
|
1045
|
+
|
|
1046
|
+
// Verdict
|
|
1047
|
+
out.push("## Verdict");
|
|
1048
|
+
out.push("");
|
|
1049
|
+
|
|
1050
|
+
const readResults = results.filter(r => r.num === 1);
|
|
1051
|
+
const outlineResults = results.filter(r => r.num === 2);
|
|
1052
|
+
const editResult = results.find(r => r.num === 7);
|
|
1053
|
+
const verifyResult = results.find(r => r.num === 8);
|
|
1054
|
+
const treeResult = results.find(r => r.num === 4);
|
|
1055
|
+
const batchResult = results.find(r => r.num === 9);
|
|
1056
|
+
const bulkResult = results.find(r => r.num === 10);
|
|
1057
|
+
const changesResult = results.find(r => r.num === 11);
|
|
1058
|
+
const notFoundResult = results.find(r => r.num === 12);
|
|
1059
|
+
const mismatchResult = results.find(r => r.num === 13);
|
|
1060
|
+
const bashResult = results.find(r => r.num === 14);
|
|
1061
|
+
|
|
1062
|
+
const allSavingsNums = results.map(r => {
|
|
1063
|
+
if (r.without === 0) return 0;
|
|
1064
|
+
return ((r.without - r.withSL) / r.without) * 100;
|
|
1065
|
+
});
|
|
1066
|
+
const avgSavings = allSavingsNums.reduce((a, b) => a + b, 0) / allSavingsNums.length;
|
|
1067
|
+
|
|
1068
|
+
// Read verdict
|
|
1069
|
+
const readVerdict = [];
|
|
1070
|
+
const smallRead = readResults.find(r => r.scenario.includes("<50L"));
|
|
1071
|
+
const xlRead = readResults.find(r => r.scenario.includes("500L+"));
|
|
1072
|
+
if (smallRead) {
|
|
1073
|
+
const pct = Math.abs(((smallRead.without - smallRead.withSL) / smallRead.without * 100)).toFixed(0);
|
|
1074
|
+
const verb = smallRead.withSL <= smallRead.without ? "saves" : "costs";
|
|
1075
|
+
readVerdict.push(`Small files (<50L): hash annotations ${verb} ~${pct}%.`);
|
|
1076
|
+
}
|
|
1077
|
+
if (xlRead) {
|
|
1078
|
+
const pct = Math.abs(((xlRead.without - xlRead.withSL) / xlRead.without * 100)).toFixed(0);
|
|
1079
|
+
const verb = xlRead.withSL <= xlRead.without ? "saves" : "costs";
|
|
1080
|
+
readVerdict.push(`Large files (500L+): full read ${verb} ~${pct}%.`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
out.push("**Read:**");
|
|
1084
|
+
for (const v of readVerdict) out.push(`- ${v}`);
|
|
1085
|
+
if (outlineResults.length > 0) {
|
|
1086
|
+
const best = outlineResults.reduce((a, b) =>
|
|
1087
|
+
((a.without - a.withSL) / a.without) > ((b.without - b.withSL) / b.without) ? a : b
|
|
1088
|
+
);
|
|
1089
|
+
const savPct = ((best.without - best.withSL) / best.without * 100).toFixed(0);
|
|
1090
|
+
out.push(`- Outline+targeted read saves ${savPct}% on large files vs full read.`);
|
|
1091
|
+
}
|
|
1092
|
+
out.push("");
|
|
1093
|
+
|
|
1094
|
+
if (editResult) {
|
|
1095
|
+
const editSav = ((editResult.without - editResult.withSL) / editResult.without * 100).toFixed(0);
|
|
1096
|
+
out.push(`**Edit:** Compact diff output saves ${editSav}% vs old_string/new_string context blocks (5 edits).`);
|
|
1097
|
+
out.push("");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (verifyResult) {
|
|
1101
|
+
const verifySav = ((verifyResult.without - verifyResult.withSL) / verifyResult.without * 100).toFixed(0);
|
|
1102
|
+
out.push(`**Verify:** Checksum verification saves ${verifySav}% vs full re-read for staleness check.`);
|
|
1103
|
+
out.push("");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (treeResult) {
|
|
1107
|
+
const pct = Math.abs(((treeResult.without - treeResult.withSL) / treeResult.without * 100)).toFixed(0);
|
|
1108
|
+
const verb = treeResult.withSL <= treeResult.without ? "saves" : "costs";
|
|
1109
|
+
out.push(`**Tree:** Compact directory tree ${verb} ${pct}% vs \`ls -laR\`.`);
|
|
1110
|
+
out.push("");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (batchResult) {
|
|
1114
|
+
const batchSav = ((batchResult.without - batchResult.withSL) / batchResult.without * 100).toFixed(0);
|
|
1115
|
+
out.push(`**Batch read:** Multi-file read saves ${batchSav}% vs separate Read calls (${batchResult.scenario.match(/\d+ files/)?.[0] || 'N files'}).`);
|
|
1116
|
+
out.push("");
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (bulkResult) {
|
|
1120
|
+
const bulkSav = ((bulkResult.without - bulkResult.withSL) / bulkResult.without * 100).toFixed(0);
|
|
1121
|
+
out.push(`**Bulk replace:** Single bulk_replace saves ${bulkSav}% vs 5 separate edit_file calls.`);
|
|
1122
|
+
out.push("");
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (changesResult) {
|
|
1126
|
+
const changesSav = ((changesResult.without - changesResult.withSL) / changesResult.without * 100).toFixed(0);
|
|
1127
|
+
out.push(`**Changes:** Semantic diff summary saves ${changesSav}% vs raw unified diff output.`);
|
|
1128
|
+
out.push("");
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
if (notFoundResult) {
|
|
1132
|
+
const notFoundSav = ((notFoundResult.without - notFoundResult.withSL) / notFoundResult.without * 100).toFixed(0);
|
|
1133
|
+
out.push(`**Error recovery (FILE_NOT_FOUND):** Inline dir listing saves ${notFoundSav}% vs 3 round-trips.`);
|
|
1134
|
+
out.push("");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (mismatchResult) {
|
|
1138
|
+
const mismatchSav = ((mismatchResult.without - mismatchResult.withSL) / mismatchResult.without * 100).toFixed(0);
|
|
1139
|
+
out.push(`**Error recovery (hash mismatch):** Fresh snippet saves ${mismatchSav}% vs full re-read + retry.`);
|
|
1140
|
+
out.push("");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
if (bashResult) {
|
|
1144
|
+
const bashSav = ((bashResult.without - bashResult.withSL) / bashResult.without * 100).toFixed(0);
|
|
1145
|
+
const verb = bashResult.withSL <= bashResult.without ? "saves" : "costs";
|
|
1146
|
+
out.push(`**Bash redirects:** Structured hex-line output ${verb} ${bashSav}% vs cat+ls+stat combined.`);
|
|
1147
|
+
out.push("");
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Break-even
|
|
1151
|
+
out.push("## Break-even");
|
|
1152
|
+
out.push("");
|
|
1153
|
+
if (outlineResults.length > 0) {
|
|
1154
|
+
out.push("- **Outline workflow** breaks even at ~50 lines. Above that, savings grow linearly.");
|
|
1155
|
+
}
|
|
1156
|
+
if (verifyResult && verifyResult.withSL < verifyResult.without) {
|
|
1157
|
+
const ratio = (verifyResult.without / verifyResult.withSL).toFixed(0);
|
|
1158
|
+
out.push(`- **Verify** is ${ratio}x cheaper than re-reading. Pays for hash overhead after first staleness check.`);
|
|
1159
|
+
}
|
|
1160
|
+
if (editResult && editResult.withSL < editResult.without) {
|
|
1161
|
+
out.push("- **Edit** compact diff is always cheaper than old_string/new_string blocks.");
|
|
1162
|
+
}
|
|
1163
|
+
if (notFoundResult && notFoundResult.withSL < notFoundResult.without) {
|
|
1164
|
+
const ratio = (notFoundResult.without / notFoundResult.withSL).toFixed(0);
|
|
1165
|
+
out.push(`- **Error recovery** eliminates round-trips: ${ratio}x cheaper for FILE_NOT_FOUND.`);
|
|
1166
|
+
}
|
|
1167
|
+
if (mismatchResult && mismatchResult.withSL < mismatchResult.without) {
|
|
1168
|
+
const ratio = (mismatchResult.without / mismatchResult.withSL).toFixed(0);
|
|
1169
|
+
out.push(`- **Hash mismatch** recovery with fresh snippet is ${ratio}x cheaper than full re-read + retry.`);
|
|
1170
|
+
}
|
|
1171
|
+
if (changesResult && changesResult.withSL < changesResult.without) {
|
|
1172
|
+
out.push("- **Semantic diff** always cheaper than raw unified diff for understanding changes.");
|
|
1173
|
+
}
|
|
1174
|
+
out.push(`- **Average savings across all ${results.length} scenarios:** ${avgSavings.toFixed(0)}%`);
|
|
1175
|
+
out.push("");
|
|
1176
|
+
|
|
1177
|
+
console.log(out.join("\n"));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
main();
|