@hncode/hncode 0.2.0 → 0.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/src/tools/edit.js CHANGED
@@ -6,31 +6,90 @@ import fs from 'node:fs';
6
6
  import { resolvePath, ensureDir } from './utils.js';
7
7
  import { hashStr, normalizeText } from './read.js';
8
8
 
9
- // Hash the <start..end> 1-based line slice of an already-normalized line array.
10
- function hashLines(lines, start, end) {
11
- return hashStr((lines.slice(start - 1, end) || []).join('\n'));
12
- }
9
+ // Re-exported from read.js so the staleness guard has ONE definition of the
10
+ // region hash. Previously read.js and edit.js each had their own copy; any
11
+ // drift between them would silently disable staleness detection.
12
+ import { hashRegion as hashLines } from './read.js';
13
13
 
14
14
  export const spec = {
15
15
  name: 'Edit',
16
- description: 'Replace an exact substring (old_string) with new_string. Fails if old_string is missing or ambiguous (unless replace_all).',
16
+ description: 'Edit a file. PREFERRED: line-range mode (path + start_line/end_line/new_content) — replace by line numbers when you have Read the file and know the positions. Use line-range mode whenever possible; fall back to exact-substring mode (path + old_string/new_string) only when the exact text is known and a line range is not convenient.',
17
17
  parameters: {
18
18
  type: 'object',
19
19
  properties: {
20
20
  path: { type: 'string', description: 'Path to the file to edit.' },
21
- old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines.' },
22
- new_string: { type: 'string', description: 'Replacement text.' },
21
+ old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
22
+ new_string: { type: 'string', description: 'Replacement text for old_string.' },
23
23
  replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
24
+ start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_content). Use this line-range mode first.' },
25
+ end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
26
+ new_content: { type: 'string', description: 'PREFERRED MODE. Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
24
27
  },
25
- required: ['path', 'old_string', 'new_string'],
28
+ // Either line-range mode (start_line+end_line+new_content) OR exact-substring
29
+ // mode (old_string+new_string). Line-range is the preferred route — see the
30
+ // tool description and the PREFERRED MODE notes on the params.
31
+ anyOf: [
32
+ { required: ['path', 'start_line', 'end_line', 'new_content'] },
33
+ { required: ['path', 'old_string', 'new_string'] },
34
+ ],
26
35
  },
27
36
  async execute(args, ctx) {
28
37
  let p;
29
38
  try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
30
39
  let content;
31
40
  try { content = fs.readFileSync(p, 'utf8'); } catch (e) { return `Error reading ${args.path}: ${e.message}`; }
32
- const { old_string, new_string, replace_all = false } = args;
33
- if (old_string === '') return `Error: old_string must not be empty.`;
41
+ const { start_line, end_line, new_content } = args;
42
+
43
+ // ---- LINE-RANGE mode ---------------------------------------------------
44
+ // `start_line`..`end_line` replace the lines at those 1-based positions,
45
+ // with NO old_string involved at all. This is the path for "change line 12"
46
+ // where spelling out the exact current text is awkward. It is fully
47
+ // self-contained: validate -> check the Read cache -> splice -> write ->
48
+ // refresh the cache -> return.
49
+ if (start_line != null && end_line != null) {
50
+ const start = Number(start_line) | 0, end = Number(end_line) | 0;
51
+ if (start < 1 || end < start) {
52
+ return `Error: invalid line range ${start_line}-${end_line} (expected 1-based, start <= end).`;
53
+ }
54
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
55
+ const lines = content.split(/\r\n|\r|\n/);
56
+ if (end > lines.length) {
57
+ return `Error: end_line ${end_line} exceeds file length (${lines.length} lines).`;
58
+ }
59
+ // STALENESS: the range must sit inside a region this turn actually Read and
60
+ // that still matches. Same guarantee as the substring path, checked here
61
+ // directly (no old_string to search for).
62
+ const stale = checkStaleRange(p, lines, start, end, ctx);
63
+ if (stale) return stale;
64
+
65
+ const inserted = normalizeText(new_content).split('\n');
66
+ // The lines being REPLACED, captured before the splice. The TUI renders an
67
+ // Edit's diff from the tool ARGUMENTS, and line-range mode has no
68
+ // `old_string` to diff against — so without reporting them here the edit
69
+ // drew NO diff at all (no +/- rows, no +N/-N counts). Reporting them lets
70
+ // the caller diff `old_content` -> `new_content`.
71
+ const replacedText = lines.slice(start - 1, end).join('\n');
72
+ const out = [...lines.slice(0, start - 1), ...inserted, ...lines.slice(end)];
73
+ const normUpdated = out.join('\n');
74
+ const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
75
+ try {
76
+ ensureDir(p);
77
+ fs.writeFileSync(p, updated, 'utf8');
78
+ } catch (e) { return `Error writing ${args.path}: ${e.message}`; }
79
+ refreshReadPool(p, normUpdated, { start, end: start + inserted.length - 1 }, ctx);
80
+ const removed = end - start + 1;
81
+ // Publish the diff for the UI. The Edit line is drawn from these, so a
82
+ // line-range edit shows the same +/- rows and +N/-N counts as a substring
83
+ // edit instead of nothing. `ctx.lastEditDiff` is consumed (and cleared) by
84
+ // the agent loop right after this tool returns.
85
+ if (ctx) ctx.lastEditDiff = { old: replacedText, new: normalizeText(new_content), startLine: start };
86
+ return `Edited ${args.path}: replaced lines ${start}-${end} (${removed} line${removed === 1 ? '' : 's'}) with ${inserted.length} line${inserted.length === 1 ? '' : 's'}.`;
87
+ }
88
+
89
+
90
+ // ---- SUBSTRING mode (old_string -> new_string) --------------------------
91
+ let { old_string, new_string, replace_all = false } = args;
92
+ if (old_string === '' || old_string == null) return `Error: old_string must not be empty.`;
34
93
 
35
94
  // --- Newline normalization (Windows CRLF) ---
36
95
  // The Read tool normalizes \r\n -> \n, so an agent builds old_string/new_string
@@ -96,6 +155,14 @@ export const spec = {
96
155
  }
97
156
  }
98
157
  const occurrences = replace_all ? count : 1;
158
+ // Publish the diff for the UI (same contract as the line-range path above).
159
+ if (ctx) {
160
+ const startLine = (() => {
161
+ const at = normContent.split('\n').indexOf(String(normOld).split('\n')[0]);
162
+ return at >= 0 ? at + 1 : 1;
163
+ })();
164
+ ctx.lastEditDiff = { old: normalizeText(old_string), new: normalizeText(new_string), startLine };
165
+ }
99
166
  return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
100
167
  },
101
168
  };
@@ -135,4 +202,48 @@ function checkStale(p, normContent, normOld, ctx) {
135
202
  // (3) The file changed on disk since it was Read and the edit target is not in
136
203
  // an intact Read region. Reject so a stale snippet cannot corrupt the file.
137
204
  return `Edit rejected: ${p} changed since it was Read (the edited region is not in an intact Read snapshot). Re-run Read to get the current content, then Edit.`;
138
- }
205
+ }
206
+ // ---- helpers for the line-range path ---------------------------------------
207
+
208
+ // Same guarantee as checkStale, but for an explicit [start, end] line range:
209
+ // every line in the range must sit inside a region that was Read this turn and
210
+ // whose content still matches. Returns an error string, or null when safe.
211
+ function checkStaleRange(p, curLines, start, end, ctx) {
212
+ if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
213
+ const entry = ctx.readPool.get(p);
214
+ if (!entry) return null; // never Read this turn; allow
215
+
216
+ const normContent = curLines.join('\n');
217
+ const curFileHash = hashStr(normContent);
218
+
219
+ // A region covers the range when it fully contains it AND still hashes the
220
+ // same as when it was Read.
221
+ const covered = entry.regions.some(
222
+ (r) => start >= r.start && end <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
223
+ );
224
+ if (covered) return null;
225
+
226
+ if (curFileHash === entry.fileHash) {
227
+ // No "(read so far: …)" listing: the range is what the model must act on, and
228
+ // echoing the regions it HAS read only invited it to reason about them instead
229
+ // of re-reading the target lines.
230
+ return `Edit rejected: lines ${start}-${end} were not read in ${p}. Read the target lines first (Read with line_offset / n_lines), then Edit.`;
231
+ }
232
+ return `Edit rejected: ${p} changed since it was Read (lines ${start}-${end} are not in an intact Read snapshot). Re-run Read to get the current content, then Edit.`;
233
+ }
234
+
235
+ // Refresh the region cache after a successful edit: drop regions that no longer
236
+ // match the NEW content, and record the freshly-written span. Mirrors the
237
+ // re-arm block used by the substring path.
238
+ function refreshReadPool(p, normUpdated, written, ctx) {
239
+ if (!ctx || !ctx.readPool) return;
240
+ const entry = ctx.readPool.get(p);
241
+ if (!entry) return;
242
+ const newLines = normUpdated.split('\n');
243
+ entry.fileHash = hashStr(normUpdated);
244
+ entry.lines = newLines.length;
245
+ const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
246
+ kept.push({ start: written.start, end: Math.min(newLines.length, Math.max(written.start, written.end)), hash: hashLines(newLines, written.start, Math.min(newLines.length, Math.max(written.start, written.end))) });
247
+ entry.regions = kept;
248
+ try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
249
+ }
@@ -24,13 +24,46 @@ export const spec = {
24
24
  const stat = fs.statSync(p);
25
25
  if (!stat.isFile()) return `Error: not a file: ${args.path}`;
26
26
 
27
- // Count LINES only — never the content. Splitting on the same newline forms
28
- // Read normalizes keeps the count consistent with Read's own total.
29
- const lines = fs.readFileSync(p, 'utf8').split(/\r\n|\r|\n/);
30
- // A file ending in a newline splits with a trailing '' element; that is the
31
- // terminator of the previous line, not a line of its own.
32
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
33
-
34
- return `Lines: ${lines.length}`;
27
+ // Count LINES only — never the content. Read the file in bounded chunks and
28
+ // count newlines, so a huge file is not pulled wholesale into memory just to
29
+ // answer "how many lines". Any of \r\n, \r, or \n counts as one line break,
30
+ // matching the newline forms Read normalizes.
31
+ let fd;
32
+ try { fd = fs.openSync(p, 'r'); } catch { return `Error: cannot open ${args.path}`; }
33
+ const CHUNK = 256 * 1024;
34
+ const buf = Buffer.allocUnsafe(CHUNK);
35
+ let lineBreaks = 0; // count of \r\n, \r, or \n sequences
36
+ let prevCR = false;
37
+ let lastWasBreak = false; // whether the last byte seen was a line break
38
+ let read = 0;
39
+ try {
40
+ for (;;) {
41
+ const n = fs.readSync(fd, buf, 0, CHUNK, read);
42
+ if (n <= 0) break;
43
+ for (let i = 0; i < n; i++) {
44
+ const b = buf[i];
45
+ if (b === 0x0a) { // \n
46
+ if (!prevCR) lineBreaks++;
47
+ prevCR = false;
48
+ lastWasBreak = true;
49
+ } else if (b === 0x0d) { // \r
50
+ lineBreaks++;
51
+ prevCR = true;
52
+ lastWasBreak = true;
53
+ } else {
54
+ prevCR = false;
55
+ lastWasBreak = false;
56
+ }
57
+ }
58
+ if (n < CHUNK) break;
59
+ read += n;
60
+ }
61
+ } finally {
62
+ try { fs.closeSync(fd); } catch {}
63
+ }
64
+ // "a\nb\nc" is 3 lines; "a\nb\nc\n" is also 3 (the trailing \n terminates
65
+ // the last line, it does not open a new one). So drop the final break.
66
+ const lines = 1 + lineBreaks - (lastWasBreak ? 1 : 0);
67
+ return `Lines: ${Math.max(1, lines)}`;
35
68
  },
36
69
  };
package/src/tools/glob.js CHANGED
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { normalizeInput } from './utils.js';
8
8
  import { walkFiles } from './ignore.js';
9
- import { globRegex, expandBraces } from './matchers.js';
9
+ import { globToRegexSource, expandBraces } from './matchers.js';
10
10
 
11
11
  export const spec = {
12
12
  name: 'Glob',
@@ -22,17 +22,29 @@ export const spec = {
22
22
  required: ['pattern'],
23
23
  },
24
24
  async execute(args, ctx) {
25
- const fromDir = args.path ? args.path : '.';
25
+ // `pattern` is required, but the model can still omit it. Return an error
26
+ // string like every other tool instead of throwing — an uncaught throw here
27
+ // aborts the whole turn.
28
+ if (typeof args.pattern !== 'string' || !args.pattern.trim()) {
29
+ return 'Error: `pattern` is required (e.g. "src/**/*.ts").';
30
+ }
31
+ const fromDir = typeof args.path === 'string' && args.path ? args.path : '.';
26
32
  const p = normalizeInput(fromDir);
27
33
  let base;
28
34
  try { base = path.resolve(ctx.cwd || ctx.workspace, p); } catch (e) { return `Error: ${e.message}`; }
29
35
  if (!fs.existsSync(base)) return `Error: path does not exist: ${fromDir}`;
30
36
  if (!fs.statSync(base).isDirectory()) return `Error: --path is not a directory: ${fromDir}`;
31
37
 
32
- const alts = expandBraces(args.pattern);
33
- const re = new RegExp('^(?:' + alts.map((a) => globRegex(a).source.slice(1, -1)).join('|') + ')$');
38
+ const alts = expandBraces(args.pattern);
39
+ // Each alt is already anchored (globToRegexSource includes ^ and $), so
40
+ // join them into one anchored alternation. The previous code inferred the
41
+ // anchors by slicing the first/last char off globRegex(), which was fragile:
42
+ // a brace pattern has per-alt anchors that slicing stripped into the wrong
43
+ // shape.
44
+ const re = new RegExp('^(?:' + alts.map((a) => globToRegexSource(a)).join('|') + ')$');
45
+
34
46
 
35
- const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false });
47
+ const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: !!args.include_dirs });
36
48
  const matches = [];
37
49
  for (const abs of files) {
38
50
  const rel = path.relative(base, abs).split(path.sep).join('/');
package/src/tools/grep.js CHANGED
@@ -42,8 +42,14 @@ export const spec = {
42
42
  };
43
43
 
44
44
  function hasRg() {
45
- try { cp.spawnSync('rg', ['--version'], { stdio: 'ignore' }); return true; }
46
- catch { return false; }
45
+ try {
46
+ // spawnSync does NOT throw when the binary is missing — it returns
47
+ // { error: ENOENT, status: null }. The old try/catch therefore always
48
+ // reported "rg available", and grepRg() then returned an empty stdout as
49
+ // "No matches found" instead of falling back to the JS walker.
50
+ const r = cp.spawnSync('rg', ['--version'], { stdio: 'ignore' });
51
+ return !r.error && r.status === 0;
52
+ } catch { return false; }
47
53
  }
48
54
 
49
55
  function buildRgArgs(args, base) {
@@ -70,10 +76,13 @@ function grepRg(args, base, ctx) {
70
76
  maxBuffer: 64 * 1024 * 1024,
71
77
  encoding: 'utf8',
72
78
  });
79
+ // spawn failure: `r.error` set, status null. Must fall back to JS, not
80
+ // return an empty result as "No matches found".
81
+ if (r.error) return grepJs(args, base, ctx);
73
82
  out = r.stdout || '';
74
83
  if (r.status > 1 && r.stderr) return `rg error: ${r.stderr.trim()}`;
75
84
  } catch (e) {
76
- return `rg unavailable: ${e.message}; falling back`;
85
+ return grepJs(args, base, ctx);
77
86
  }
78
87
  return paginate(out, args);
79
88
  }
@@ -118,6 +127,10 @@ function grepJs(args, base, ctx) {
118
127
  const lines = txt.split('\n');
119
128
  let count = 0;
120
129
  for (let i = 0; i < lines.length; i++) {
130
+ // `re` carries the `g` flag, so RegExp.test() advances lastIndex and
131
+ // the NEXT call starts mid-string — every other matching line was
132
+ // skipped. Reset lastIndex before each test.
133
+ re.lastIndex = 0;
121
134
  if (re.test(lines[i])) {
122
135
  count++;
123
136
  if (mode === 'files_with_matches') { out.push(abs); break; }
@@ -12,6 +12,9 @@ import * as fetchUrlMod from './fetch-url.js';
12
12
  import * as webSearchMod from './web-search.js';
13
13
  import * as readMediaFileMod from './read-media-file.js';
14
14
  import * as fileLinesMod from './file-lines.js';
15
+ import * as askUserQuestionMod from './ask-user-question.js';
16
+ import { spec as agentSpec } from './agent.js';
17
+ import { spec as agentSwarmSpec } from './agent-swarm.js';
15
18
  import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
16
19
  import { pluginTools } from '../plugin.js';
17
20
 
@@ -28,6 +31,9 @@ const builtinTools = [
28
31
  webSearchMod.spec,
29
32
  readMediaFileMod.spec,
30
33
  fileLinesMod.spec,
34
+ askUserQuestionMod.spec,
35
+ agentSpec,
36
+ agentSwarmSpec,
31
37
  TaskListSpec,
32
38
  TaskOutputSpec,
33
39
  TaskStopSpec,
@@ -44,8 +50,19 @@ export function combinedTools() {
44
50
  export const tools = builtinTools;
45
51
  export { builtinTools };
46
52
 
47
- const byName = new Map(builtinTools.map((t) => [t.name, t]));
48
- export function getTool(name) { return byName.get(name); }
53
+ // NOTE: the map is built lazily over combinedTools() so plugin-registered
54
+ // tools are resolvable. The previous Map was built from builtinTools only,
55
+ // so `llmTools()` advertised a plugin tool to the model but `getTool(name)`
56
+ // returned undefined and the call failed with `unknown tool`.
57
+ let _byName = null;
58
+ function byNameMap() {
59
+ if (!_byName) {
60
+ _byName = new Map();
61
+ for (const t of combinedTools()) if (!_byName.has(t.name)) _byName.set(t.name, t);
62
+ }
63
+ return _byName;
64
+ }
65
+ export function getTool(name) { return byNameMap().get(name); }
49
66
  export function toolNames() { return combinedTools().map((t) => t.name); }
50
67
 
51
68
  // Schemas for sending to an LLM (OpenAI `tools` array).
@@ -1,71 +1,75 @@
1
- // Shared glob matching used by Glob, Grep and the .gitignore matcher.
2
- // Semantics: `*` matches within a path segment; `**` (optionally followed by `/`)
3
- // matches across segments; `?` matches one non-separator char; `[class]` char
4
- // classes; `{a,b}` brace expansion. `/` is the path separator used for matching.
5
-
6
- export function expandBraces(str) {
7
- const i = str.indexOf('{');
8
- if (i === -1) return [str];
9
- let depth = 1, j = i + 1, parts = [], part = '', ok = false;
10
- while (j < str.length && depth > 0) {
11
- const c = str[j];
12
- if (c === '{') { depth++; part += c; }
13
- else if (c === '}') { depth--; if (depth === 0) { parts.push(part); ok = true; break; } else part += c; }
14
- else if (c === ',' && depth === 1) { parts.push(part); part = ''; }
15
- else part += c;
16
- j++;
17
- }
18
- if (!ok) return [str];
19
- const out = [];
20
- const before = str.slice(0, i), after = str.slice(j + 1);
21
- for (const p of parts) {
22
- for (const b of expandBraces(before)) for (const pp of expandBraces(p)) for (const a of expandBraces(after)) out.push(b + pp + a);
23
- }
24
- return out;
25
- }
26
-
27
- function escapeRegex(c) {
28
- return /[.*+?^${}()|[\]\\]/.test(c) ? '\\' + c : c;
29
- }
30
-
31
- export function globToRegexSource(pattern) {
32
- let src = '^';
33
- for (let i = 0; i < pattern.length; i++) {
34
- const c = pattern[i];
35
- if (c === '*' && pattern[i + 1] === '*') {
36
- src += '[\\s\\S]*'; i++;
37
- if (pattern[i + 1] === '/') i++;
38
- } else if (c === '*') {
39
- src += '[^/]*';
40
- } else if (c === '?') {
41
- src += '[^/]';
42
- } else if (c === '[') {
43
- let j = i + 1, negated = false;
44
- if (pattern[j] === '!' || pattern[j] === '^') { negated = true; j++; }
45
- let body = '';
46
- while (j < pattern.length && pattern[j] !== ']') body += pattern[j++];
47
- if (pattern[j] === ']') {
48
- if (negated) body = '/' + body;
49
- src += negated ? '[^' + body + ']' : '[' + body + ']';
50
- i = j;
51
- } else {
52
- src += escapeRegex(c);
53
- }
54
- } else {
55
- src += escapeRegex(c);
56
- }
57
- }
58
- return src + '$';
59
- }
60
-
61
- // Match the whole string against a single pattern (anchored, like globToRegex).
62
- export function globRegex(pattern) {
63
- const alts = expandBraces(pattern);
64
- if (alts.length === 1) return new RegExp('^' + globToRegexSource(alts[0]));
65
- return new RegExp('^(?:' + alts.map(globToRegexSource).join('|') + ')');
66
- }
67
-
68
- // True if `s` matches `pattern` (full match on `s`).
69
- export function matchGlob(pattern, s) {
70
- return globRegex(pattern).test(s);
71
- }
1
+ // Shared glob matching used by Glob, Grep and the .gitignore matcher.
2
+ // Semantics: `*` matches within a path segment; `**` (optionally followed by `/`)
3
+ // matches across segments; `?` matches one non-separator char; `[class]` char
4
+ // classes; `{a,b}` brace expansion. `/` is the path separator used for matching.
5
+
6
+ export function expandBraces(str) {
7
+ const i = str.indexOf('{');
8
+ if (i === -1) return [str];
9
+ let depth = 1, j = i + 1, parts = [], part = '', ok = false;
10
+ while (j < str.length && depth > 0) {
11
+ const c = str[j];
12
+ if (c === '{') { depth++; part += c; }
13
+ else if (c === '}') { depth--; if (depth === 0) { parts.push(part); ok = true; break; } else part += c; }
14
+ else if (c === ',' && depth === 1) { parts.push(part); part = ''; }
15
+ else part += c;
16
+ j++;
17
+ }
18
+ if (!ok) return [str];
19
+ const out = [];
20
+ const before = str.slice(0, i), after = str.slice(j + 1);
21
+ for (const p of parts) {
22
+ for (const b of expandBraces(before)) for (const pp of expandBraces(p)) for (const a of expandBraces(after)) out.push(b + pp + a);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function escapeRegex(c) {
28
+ return /[.*+?^${}()|[\]\\]/.test(c) ? '\\' + c : c;
29
+ }
30
+
31
+ // Regex SOURCE (anchored, with ^ and $) for a single plain pattern.
32
+ export function globToRegexSource(pattern) {
33
+ let src = '^';
34
+ for (let i = 0; i < pattern.length; i++) {
35
+ const c = pattern[i];
36
+ if (c === '*' && pattern[i + 1] === '*') {
37
+ src += '[\\s\\S]*'; i++;
38
+ if (pattern[i + 1] === '/') i++;
39
+ } else if (c === '*') {
40
+ src += '[^/]*';
41
+ } else if (c === '?') {
42
+ src += '[^/]';
43
+ } else if (c === '[') {
44
+ let j = i + 1, negated = false;
45
+ if (pattern[j] === '!' || pattern[j] === '^') { negated = true; j++; }
46
+ let body = '';
47
+ while (j < pattern.length && pattern[j] !== ']') body += pattern[j++];
48
+ if (pattern[j] === ']') {
49
+ if (negated) body = '/' + body;
50
+ src += negated ? '[^' + body + ']' : '[' + body + ']';
51
+ i = j;
52
+ } else {
53
+ src += escapeRegex(c);
54
+ }
55
+ } else {
56
+ src += escapeRegex(c);
57
+ }
58
+ }
59
+ return src + '$';
60
+ }
61
+
62
+ // Match the whole string against a single pattern (anchored, like globToRegex).
63
+ // The single-alternative path wraps globToRegexSource directly; the multi
64
+ // (brace) path joins every alternative in braces so a `{a,b}` pattern cannot
65
+ // match a substring that only matches one branch.
66
+ export function globRegex(pattern) {
67
+ const alts = expandBraces(pattern);
68
+ if (alts.length === 1) return new RegExp(globToRegexSource(alts[0]));
69
+ return new RegExp('^(?:' + alts.map((a) => globToRegexSource(a)).join('|') + ')$');
70
+ }
71
+
72
+ // True if `s` matches `pattern` (full match on `s`).
73
+ export function matchGlob(pattern, s) {
74
+ return globRegex(pattern).test(s);
75
+ }
@@ -36,15 +36,29 @@ export const spec = {
36
36
  if (stat.size > MAX_IMAGE_SIZE) {
37
37
  return `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max 20MB): ${args.path}`;
38
38
  }
39
- const data = fs.readFileSync(p);
40
- const base64 = data.toString('base64');
39
+ // A tool result is plain TEXT that goes back into the model's context on
40
+ // the next request. A 20MB image becomes ~27MB of base64, which blows the
41
+ // context window and (for OpenAI/Anthropic chat completions) is not a
42
+ // recognised image payload anyway — the model only sees a wall of text.
43
+ // Return a REFERENCE the caller can act on instead of the bytes.
41
44
  const mimeType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
42
45
  : ext === '.png' ? 'image/png'
43
46
  : ext === '.gif' ? 'image/gif'
44
47
  : ext === '.webp' ? 'image/webp'
45
48
  : ext === '.bmp' ? 'image/bmp'
46
49
  : 'image/unknown';
47
- return `data:${mimeType};base64,${base64}`;
50
+ const sizeKB = Math.max(1, Math.round(stat.size / 1024));
51
+ const abs = p.replace(/\\/g, '/');
52
+ // Keep a small inline preview ONLY for tiny files (< 64KB), where the
53
+ // base64 is at most ~87KB and genuinely usable by a multimodal client.
54
+ if (stat.size <= 64 * 1024) {
55
+ const base64 = fs.readFileSync(p).toString('base64');
56
+ return `data:${mimeType};base64,${base64}`;
57
+ }
58
+ return `[Image: ${mimeType}] ${args.path} (${sizeKB}KB, ${stat.width || '?'}x${stat.height || '?'} unknown)\n`
59
+ + `Path: ${abs}\n`
60
+ + `This image is too large to inline as base64 (would be ~${Math.round(stat.size * 4 / 3 / 1024)}KB of text). `
61
+ + `To view it, use a client that renders image paths, or resize it first.`;
48
62
  }
49
63
 
50
64
  const fileType = VIDEO_EXT.includes(ext) ? 'video' : AUDIO_EXT.includes(ext) ? 'audio' : 'unknown';
package/src/tools/read.js CHANGED
@@ -22,6 +22,8 @@ function hashLines(lines, start, end) {
22
22
  const slice = (lines.slice(start - 1, end) || []).join('\n');
23
23
  return hashStr(slice);
24
24
  }
25
+ // Public alias so edit.js (and the line-range path) share ONE implementation.
26
+ export const hashRegion = hashLines;
25
27
 
26
28
  export const spec = {
27
29
  name: 'Read',