@hncode/hncode 0.1.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.
@@ -0,0 +1,240 @@
1
+ // Bash tool — mirrors the kimi-code Bash tool schema & behavior.
2
+ // Runs commands via PowerShell (pwsh). Supports
3
+ // timeout/cwd/run_in_background/disable_timeout. Background tasks are tracked so
4
+ // TaskList/TaskOutput can inspect them.
5
+
6
+ import fs from 'node:fs';
7
+ import cp from 'node:child_process';
8
+ import { resolvePath, truncateBuf } from './utils.js';
9
+
10
+ // kimi-code's timeout policy (agent-core-v2/agent/tools/os/bash/bash.ts).
11
+ export const DEFAULT_TIMEOUT_S = 60;
12
+ export const MAX_TIMEOUT_S = 5 * 60;
13
+ export const DEFAULT_BACKGROUND_TIMEOUT_S = 10 * 60;
14
+ export const MAX_BACKGROUND_TIMEOUT_S = 24 * 60 * 60;
15
+
16
+ // ---- output sanitizing -------------------------------------------------------
17
+ // Captured output can contain terminal control sequences (colours, cursor
18
+ // moves, `\r` spinners, bells, …). hncode's renderer passes result strings to
19
+ // the terminal verbatim, so a surviving escape fights the differential
20
+ // renderer ("text bleeding onto the next row"). It also inflates the token
21
+ // count of what we send back to the model. Strip everything a terminal would
22
+ // interpret as a command, keeping only `\n` and `\t`.
23
+ const OSC_RE = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g;
24
+ const CSI_RE = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g;
25
+ const ESC_SINGLE_RE = /\u001B(?:[ -/][0-~]|[0-~])/g;
26
+ const C0_RE = /[\u0000-\u0008\u000B-\u001F]/g;
27
+
28
+ export function sanitizeShellOutput(text) {
29
+ const s = String(text ?? '');
30
+ if (!s) return s;
31
+ return s
32
+ .replace(OSC_RE, '')
33
+ .replace(CSI_RE, '')
34
+ .replace(ESC_SINGLE_RE, '')
35
+ .replace(C0_RE, '');
36
+ }
37
+
38
+ // PowerShell emits UTF-8 by default, so decode output as UTF-8.
39
+ function decodeBuffer(buf) {
40
+ if (buf.length === 0) return '';
41
+ try {
42
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
43
+ } catch {
44
+ try { return new TextDecoder('gbk').decode(buf); } catch { return buf.toString('utf8'); }
45
+ }
46
+ }
47
+ function decodeChunks(chunks) {
48
+ return decodeBuffer(Buffer.concat(chunks));
49
+ }
50
+
51
+ function killTree(child) {
52
+ try {
53
+ if (child.killed) return;
54
+ if (process.platform === 'win32') {
55
+ try { cp.spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
56
+ } else {
57
+ child.kill('SIGTERM');
58
+ }
59
+ } catch {}
60
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch {} }, 1000);
61
+ }
62
+
63
+ export const spec = {
64
+ name: 'Bash',
65
+ description: 'Run a shell command via pwsh. Returns combined stdout+stderr. timeout default 60s (max 300s); run_in_background detaches and returns a task id.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ command: { type: 'string', description: 'Shell command to execute.' },
70
+ description: { type: 'string', description: 'Short description (shown in task lists; required for background tasks).' },
71
+ timeout: { type: 'integer', minimum: 1, description: 'Seconds before the command is killed. Foreground default 60s/max 300s; background default 600s/max 86400s.' },
72
+ cwd: { type: 'string', description: 'Working directory (default: workspace/project root).' },
73
+ run_in_background: { type: 'boolean', default: false, description: 'Run detached and return a task id instead of waiting.' },
74
+ disable_timeout: { type: 'boolean', default: false, description: 'Disable the timeout (background only).' },
75
+ },
76
+ required: ['command'],
77
+ },
78
+ async execute(args, ctx) {
79
+ if (!args.command || !String(args.command).length) return 'Command cannot be empty.';
80
+ const cwdRaw = args.cwd || ctx.workspace;
81
+ let cwd;
82
+ try { cwd = resolvePath(cwdRaw, ctx); } catch (e) { return e.message; }
83
+ if (!fs.existsSync(cwd)) return `Error: cwd does not exist: ${cwdRaw}`;
84
+
85
+ if (args.run_in_background) return spawnBackground(args, cwd, ctx);
86
+
87
+ const bg = false;
88
+ const cap = bg ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S;
89
+ const def = bg ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S;
90
+ const timeoutMs = args.disable_timeout ? undefined : Math.min(args.timeout ?? def, cap) * 1000;
91
+ const command = args.command;
92
+ // stdin is 'ignore' (NUL / /dev/null): leaving it as an open pipe made any
93
+ // command that reads stdin (`cat`, `npm init`, …) block until the timeout.
94
+ const child = cp.spawn('pwsh', ['-Command', command], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
95
+
96
+ const chunks = [];
97
+ let settled = false;
98
+ // Live stream sink: hand decoded output to the agent (ctx.onOutput) so the UI
99
+ // can show a running command's output in real time, exactly like the
100
+ // finished "Used Bash" row does. Foreground runs only — a background task's
101
+ // output is read through TaskOutput.
102
+ //
103
+ // Decoding is STREAMING (a multi-byte char can be split across chunks) and
104
+ // sanitizing runs over the whole accumulated text, then only the NEW tail is
105
+ // emitted: stripping per-chunk would cut an escape sequence in half and leak
106
+ // the remainder into the display.
107
+ const sink = typeof ctx.onOutput === 'function' ? ctx.onOutput : null;
108
+ const liveDecoder = sink ? new TextDecoder('utf-8') : null;
109
+ let liveAcc = '';
110
+ let liveSent = 0;
111
+ const push = (d) => {
112
+ chunks.push(d);
113
+ if (!sink || settled) return;
114
+ try {
115
+ liveAcc += liveDecoder.decode(d, { stream: true });
116
+ const clean = sanitizeShellOutput(liveAcc);
117
+ if (clean.length > liveSent) {
118
+ const delta = clean.slice(liveSent);
119
+ liveSent = clean.length;
120
+ sink(delta);
121
+ }
122
+ } catch {}
123
+ };
124
+ if (child.stdout) child.stdout.on('data', push);
125
+ if (child.stderr) child.stderr.on('data', push);
126
+
127
+ let timer = null;
128
+ if (timeoutMs) timer = setTimeout(() => { killTree(child); }, timeoutMs);
129
+
130
+ // Esc / Ctrl-C in the TUI aborts the turn: kill the running command too,
131
+ // otherwise a long command keeps running after the user interrupted.
132
+ const signal = ctx.signal;
133
+ const onAbort = () => { killTree(child); };
134
+ if (signal) {
135
+ if (signal.aborted) return 'Interrupted by user';
136
+ signal.addEventListener('abort', onAbort, { once: true });
137
+ }
138
+ const cleanup = () => {
139
+ if (timer) clearTimeout(timer);
140
+ if (signal) signal.removeEventListener('abort', onAbort);
141
+ };
142
+
143
+ return new Promise((resolve) => {
144
+ // Expose a detach hook so the TUI's ctrl+b can move a long-running
145
+ // foreground command into the background WITHOUT killing it: the child
146
+ // keeps running, gets registered as a task, and this promise resolves
147
+ // immediately with a "moved to background" note so the agent continues.
148
+ if (ctx) {
149
+ ctx._foreground = {
150
+ command: args.command,
151
+ description: args.description || args.command,
152
+ child, chunks,
153
+ detach: () => {
154
+ if (settled) return null;
155
+ settled = true;
156
+ cleanup();
157
+ // Stop treating this as foreground: drop the abort listener so an
158
+ // Esc after detaching does not kill the now-background task.
159
+ if (signal) signal.removeEventListener('abort', onAbort);
160
+ const id = registerBackgroundTask(args, child, ctx, chunks);
161
+ resolve(`Moved to background as task #${id}. It keeps running; inspect with TaskList / TaskOutput {task_id: "${id}"}.`);
162
+ return id;
163
+ },
164
+ };
165
+ }
166
+ child.on('close', (code) => {
167
+ cleanup();
168
+ if (ctx) ctx._foreground = null;
169
+ if (settled) return;
170
+ settled = true;
171
+ if (signal && signal.aborted) { resolve('Interrupted by user'); return; }
172
+ let out = truncateBuf(sanitizeShellOutput(decodeChunks(chunks)));
173
+ out += `\n[exit code: ${code === null ? 'killed' : code}]`;
174
+ resolve(out);
175
+ });
176
+ child.on('error', (e) => {
177
+ cleanup();
178
+ if (ctx) ctx._foreground = null;
179
+ if (settled) return;
180
+ settled = true;
181
+ resolve(truncateBuf(sanitizeShellOutput(decodeChunks(chunks))) + `\n[error: ${e.message}]`);
182
+ });
183
+ });
184
+ },
185
+ };
186
+
187
+ // Register an already-spawned child (foreground -> background detach) as a task.
188
+ // Reuses the same task shape and lazy output decoding as spawnBackground. The
189
+ // foreground path's existing stdout/stderr listeners keep writing into the SAME
190
+ // `existingChunks` array the task reads, so we must NOT add new listeners here
191
+ // (doing so doubled every line of output).
192
+ function registerBackgroundTask(args, child, ctx, existingChunks) {
193
+ const id = `task_${Date.now()}`;
194
+ const task = {
195
+ id, pid: child.pid, command: args.command,
196
+ description: args.description || args.command,
197
+ status: 'running', start: Date.now(),
198
+ _chunks: existingChunks || [],
199
+ };
200
+ Object.defineProperty(task, 'output', {
201
+ enumerable: true,
202
+ get() { return sanitizeShellOutput(decodeChunks(this._chunks)); },
203
+ });
204
+ ctx.tasks[id] = task;
205
+ child.on('close', (code) => { task.status = code === 0 ? 'done' : 'failed'; task.code = code; task.end = Date.now(); });
206
+ return id;
207
+ }
208
+
209
+ function spawnBackground(args, cwd, ctx) {
210
+ const command = args.command;
211
+ const child = cp.spawn('pwsh', ['-Command', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
212
+ const id = `task_${Date.now()}`;
213
+ const task = {
214
+ id, pid: child.pid, command: args.command,
215
+ description: args.description || args.command,
216
+ status: 'running', start: Date.now(),
217
+ _chunks: [],
218
+ };
219
+ // Decode lazily: the accumulated bytes are decoded only when the output is
220
+ // actually read, so a chatty background task costs nothing until queried.
221
+ Object.defineProperty(task, 'output', {
222
+ enumerable: true,
223
+ get() { return sanitizeShellOutput(decodeChunks(this._chunks)); },
224
+ });
225
+ ctx.tasks[id] = task;
226
+ const push = (d) => { task._chunks.push(d); };
227
+ if (child.stdout) child.stdout.on('data', push);
228
+ if (child.stderr) child.stderr.on('data', push);
229
+ child.on('close', (code) => { task.status = code === 0 ? 'done' : 'failed'; task.code = code; task.end = Date.now(); });
230
+ // Background timeout: default 600s, capped at 86400s; `disable_timeout` removes
231
+ // it. The timer is unref'd so it never keeps the process alive on its own.
232
+ if (!args.disable_timeout) {
233
+ const bgTimeoutMs = Math.min(args.timeout ?? DEFAULT_BACKGROUND_TIMEOUT_S, MAX_BACKGROUND_TIMEOUT_S) * 1000;
234
+ const t = setTimeout(() => { killTree(child); task.status = 'failed'; task.stopReason = 'timed out'; }, bgTimeoutMs);
235
+ if (t.unref) t.unref();
236
+ }
237
+ child.unref();
238
+ return `Background task #${id} started (pid ${child.pid}): ${args.description || args.command}
239
+ Inspect with TaskList (running tasks) and TaskOutput {task_id: "${id}"} (output).`;
240
+ }
@@ -0,0 +1,138 @@
1
+ // Edit tool — mirrors the hncode Edit tool schema & behavior.
2
+ // Snippet-based exact replacement (old_string -> new_string), NOT a whole-file rewrite,
3
+ // so edits stay token-cheap (unlike kimi-code-cli's whole-file Edit).
4
+
5
+ import fs from 'node:fs';
6
+ import { resolvePath, ensureDir } from './utils.js';
7
+ import { hashStr, normalizeText } from './read.js';
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
+ }
13
+
14
+ export const spec = {
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).',
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
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.' },
23
+ replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
24
+ },
25
+ required: ['path', 'old_string', 'new_string'],
26
+ },
27
+ async execute(args, ctx) {
28
+ let p;
29
+ try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
30
+ let content;
31
+ 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.`;
34
+
35
+ // --- Newline normalization (Windows CRLF) ---
36
+ // The Read tool normalizes \r\n -> \n, so an agent builds old_string/new_string
37
+ // with LF. But the file on disk may be CRLF, making a raw LF match fail. We
38
+ // normalize the FILE content to LF for matching, then restore the file's own
39
+ // line ending style when writing back, so we never corrupt or mix endings.
40
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
41
+ const normContent = content.split(/\r\n|\r|\n/).join('\n');
42
+ const normOld = normalizeText(old_string);
43
+ const normNew = normalizeText(new_string);
44
+
45
+ const count = normContent.split(normOld).length - 1;
46
+ if (count === 0) return `Error: old_string not found in ${args.path}.`;
47
+ if (count > 1 && !replace_all) {
48
+ return `Error: old_string matched ${count} occurrences in ${args.path}. Use replace_all=true or make old_string unique.`;
49
+ }
50
+
51
+ // --- Staleness guard: the AI must edit against content it actually Read.
52
+ // We stored per-region + whole-file hashes in ctx.readPool when Read ran.
53
+ // If the file changed on disk since (external edit, stream, Ctrl-C side
54
+ // effect), reject so a snippet edit cannot silently corrupt the file. ---
55
+ const stale = checkStale(p, normContent, normOld, ctx);
56
+ if (stale) return stale;
57
+
58
+ const normUpdated = replace_all
59
+ ? normContent.replaceAll(normOld, normNew)
60
+ : normContent.replace(normOld, normNew);
61
+ const updated = eol === '\r\n' ? normUpdated.split('\n').join('\r\n') : normUpdated;
62
+ try {
63
+ ensureDir(p);
64
+ fs.writeFileSync(p, updated, 'utf8');
65
+ } catch (e) { return `Error writing ${args.path}: ${e.message}`; }
66
+ // Silently re-arm the Read snapshot for the edited region so the AI can keep
67
+ // editing on top of its own change WITHOUT another Read: we recompute the
68
+ // affected line range + hash from the NEW content and refresh the pool. We
69
+ // also drop any previously-recorded region whose content no longer matches
70
+ // (those are genuinely stale now). Nothing here is told to the model.
71
+ if (ctx && ctx.readPool) {
72
+ const entry = ctx.readPool.get(p);
73
+ if (entry) {
74
+ const newLines = normUpdated.split('\n');
75
+ entry.fileHash = hashStr(normUpdated);
76
+ entry.lines = newLines.length;
77
+ // Locate the edit site in the NEW content: the first line of the new
78
+ // text if present, else fall back to the old target's location.
79
+ const anchor = String(normNew).split('\n')[0];
80
+ let at = newLines.indexOf(anchor);
81
+ if (at < 0) at = newLines.indexOf(String(normOld).split('\n')[0]);
82
+ if (at >= 0) {
83
+ const newSpan = String(normNew).split('\n').length;
84
+ const start = at + 1;
85
+ const end = Math.min(newLines.length, at + Math.max(1, newSpan));
86
+ // Re-hash every surviving old region against the new content, and add
87
+ // the freshly-edited span. Regions that no longer match are dropped.
88
+ const kept = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
89
+ kept.push({ start, end, hash: hashLines(newLines, start, end) });
90
+ entry.regions = kept;
91
+ } else {
92
+ // Could not locate the site: drop stale regions so nothing false stays.
93
+ entry.regions = entry.regions.filter((r) => hashLines(newLines, r.start, r.end) === r.hash);
94
+ }
95
+ try { const st = fs.statSync(p); entry.size = st.size; entry.mtimeMs = st.mtimeMs; } catch {}
96
+ }
97
+ }
98
+ const occurrences = replace_all ? count : 1;
99
+ return `Edited ${args.path}: replaced ${occurrences} occurrence(s).`;
100
+ },
101
+ };
102
+
103
+ // Returns an error message when the edit is stale, else null (safe to edit).
104
+ function checkStale(p, normContent, normOld, ctx) {
105
+ if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
106
+ const entry = ctx.readPool.get(p);
107
+ if (!entry) return null; // this file was never Read this turn; allow
108
+
109
+ const curLines = normContent.split('\n');
110
+ const curFileHash = hashStr(normContent);
111
+
112
+ // Locate the edit target's first line in the CURRENT file (1-based).
113
+ const firstLine = String(normOld).split('\n')[0];
114
+ const idx = curLines.indexOf(firstLine);
115
+ const editLine = idx >= 0 ? idx + 1 : -1;
116
+
117
+ // (1) The edit target must sit inside a region the AI actually Read, and that
118
+ // region's content must still match what was Read. Reading the WHOLE file
119
+ // records a region covering every line, so this also covers "read full,
120
+ // edit a part". This is what rejects "read lines 1-3, edit line 7".
121
+ if (editLine > 0) {
122
+ const coveredByIntactRegion = entry.regions.some(
123
+ (r) => editLine >= r.start && editLine <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
124
+ );
125
+ if (coveredByIntactRegion) return null;
126
+ }
127
+
128
+ // (2) Whole file unchanged since Read, but the edit target was never Read
129
+ // (no region covers it). Tell the AI to read the target lines first.
130
+ if (curFileHash === entry.fileHash) {
131
+ const ranges = entry.regions.map((r) => `${r.start}-${r.end}`).join(', ') || 'none';
132
+ return `Edit rejected: you have not read the lines you are editing in ${p} (Read so far: lines ${ranges}). Read the target lines first (Read with line_offset / n_lines), then Edit.`;
133
+ }
134
+
135
+ // (3) The file changed on disk since it was Read and the edit target is not in
136
+ // an intact Read region. Reject so a stale snippet cannot corrupt the file.
137
+ 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
+ }
@@ -0,0 +1,108 @@
1
+ // FetchURL tool — fetch a web page's main text content (Node built-in fetch, no deps).
2
+
3
+ import os from 'node:os';
4
+
5
+ const MAX_BYTES = 200 * 1024; // generous cap for large pages
6
+
7
+ // Strip HTML tags and collapse whitespace into readable plain text.
8
+ export function htmlToText(html) {
9
+ let s = String(html || '');
10
+ // Drop script/style contents entirely.
11
+ s = s.replace(/<script[\s\S]*?<\/script>/gi, ' ')
12
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
13
+ .replace(/<noscript[\s\S]*?<\/noscript>/gi, ' ');
14
+ // Replace block-level tags with newlines so paragraphs survive.
15
+ s = s.replace(/<(br|p|div|li|h[1-6]|tr|section|article|blockquote)[^>]*>/gi, '\n')
16
+ .replace(/<\/(p|div|li|h[1-6]|tr|section|article|blockquote|ul|ol)>/gi, '\n');
17
+ // Decode a few common entities to ASCII.
18
+ s = s.replace(/&nbsp;/gi, ' ')
19
+ .replace(/&amp;/gi, '&')
20
+ .replace(/&lt;/gi, '<')
21
+ .replace(/&gt;/gi, '>')
22
+ .replace(/&quot;/gi, '"')
23
+ .replace(/&#39;|&apos;/gi, "'")
24
+ .replace(/&mdash;/gi, '—')
25
+ .replace(/&ndash;/gi, '–');
26
+ // Remove any remaining tags and collapse whitespace.
27
+ s = s.replace(/<[^>]*>/g, ' ');
28
+ s = s.replace(/[ \t]+/g, ' ').replace(/\n\s*\n+/g, '\n').trim();
29
+ return s;
30
+ }
31
+
32
+ // Reject non-http(s) URLs. No SSRF guard: hncode is a local tool the user
33
+ // controls, the model may legitimately fetch a localhost service or intranet
34
+ // page, and Bash can already run anything. Only enforce http/https.
35
+ function rejectUrl(raw) {
36
+ if (!raw || typeof raw !== 'string') return 'Error: url must be a non-empty string.';
37
+ let u;
38
+ try { u = new URL(raw.trim()); } catch { return `Error: invalid URL: ${raw}`; }
39
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
40
+ return `Error: only http/https URLs are supported (got ${u.protocol}//).`;
41
+ }
42
+ return null; // ok
43
+ }
44
+
45
+ export const spec = {
46
+ name: 'FetchURL',
47
+ description: 'Fetch a URL and return its text (main page text, or the full body; a leading note says which). Any http/https URL, including localhost.',
48
+ parameters: {
49
+ type: 'object',
50
+ properties: {
51
+ url: { type: 'string', description: 'URL to fetch.' },
52
+ },
53
+ required: ['url'],
54
+ },
55
+ async execute(args, ctx) {
56
+ const rejected = rejectUrl(args.url);
57
+ if (rejected) return rejected;
58
+ const signal = (ctx && ctx.signal) || undefined;
59
+ try {
60
+ const res = await fetch(args.url.trim(), { signal, redirect: 'follow' });
61
+ if (!res.ok) {
62
+ return `Error: ${res.status} ${res.statusText} from ${args.url}`;
63
+ }
64
+ const contentType = (res.headers.get('content-type') || '').toLowerCase();
65
+ const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml');
66
+ let isTruncated = false;
67
+ let body;
68
+ if (res.body) {
69
+ const reader = res.body.getReader();
70
+ const chunks = [];
71
+ let got = 0;
72
+ while (got < MAX_BYTES) {
73
+ const { done, value } = await reader.read();
74
+ if (done) break;
75
+ chunks.push(value);
76
+ got += value.length;
77
+ if (got >= MAX_BYTES) { isTruncated = true; break; }
78
+ }
79
+ if (isTruncated) { try { await reader.cancel(); } catch {} }
80
+ body = Buffer.concat(chunks);
81
+ } else {
82
+ body = Buffer.from(await res.arrayBuffer());
83
+ }
84
+
85
+ let text;
86
+ if (isHtml) {
87
+ text = htmlToText(body.toString('utf8'));
88
+ } else if (contentType.includes('json')) {
89
+ text = 'The returned content is the full response body, returned verbatim:\n\n' + body.toString('utf8');
90
+ } else if (contentType.includes('text/')) {
91
+ text = body.toString('utf8');
92
+ } else {
93
+ const type = contentType.split(';')[0] || 'unknown';
94
+ return `The response is binary (content-type: ${type}); no text content to return.`;
95
+ }
96
+ if (!text || !text.trim()) text = 'The response body is empty.';
97
+ let out = isHtml
98
+ ? 'The returned content is the main text extracted from the page.\n\n'
99
+ : 'The returned content is the full response body, returned verbatim.\n\n';
100
+ out += text;
101
+ if (isTruncated) out += `\n\n[... truncated; page larger than ${Math.round(MAX_BYTES / 1024)} KB]`;
102
+ return out;
103
+ } catch (e) {
104
+ const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
105
+ return `Error fetching ${args.url}: ${msg}`;
106
+ }
107
+ },
108
+ };
@@ -0,0 +1,36 @@
1
+ // FileLines tool — reports how many lines a file has. Line count ONLY: it never
2
+ // returns file content (use Read for that). This lets an agent size a file
3
+ // cheaply before deciding whether/where to Read it.
4
+
5
+ import fs from 'node:fs';
6
+ import { resolvePath } from './utils.js';
7
+
8
+ export const spec = {
9
+ name: 'FileLines',
10
+ description: 'Count the lines of a file. Does NOT return content; use Read for that.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ path: { type: 'string', description: 'Path to the file.' },
15
+ },
16
+ required: ['path'],
17
+ },
18
+ async execute(args, ctx) {
19
+ let p;
20
+ try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
21
+
22
+ if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
23
+
24
+ const stat = fs.statSync(p);
25
+ if (!stat.isFile()) return `Error: not a file: ${args.path}`;
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}`;
35
+ },
36
+ };
@@ -0,0 +1,47 @@
1
+ // Glob tool — mirrors the hncode Glob tool schema & behavior.
2
+ // Recursive glob from `path` (default cwd), honoring .gitignore/.ignore/.rgignore
3
+ // unless include_ignored. Returns up to 100 matches sorted most-recent-modified first.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { normalizeInput } from './utils.js';
8
+ import { walkFiles } from './ignore.js';
9
+ import { globRegex, expandBraces } from './matchers.js';
10
+
11
+ export const spec = {
12
+ name: 'Glob',
13
+ description: 'Find files by glob pattern. Honors ignore files unless include_ignored. Up to 100 matches, newest first.',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ pattern: { type: 'string', description: 'Glob pattern (e.g. src/**/*.ts).' },
18
+ path: { type: 'string', description: 'Directory to search from (default: project root).', default: '.' },
19
+ include_ignored: { type: 'boolean', default: false, description: 'Also match git-ignored paths.' },
20
+ include_dirs: { type: 'boolean', default: false, description: 'Include directories in results.' },
21
+ },
22
+ required: ['pattern'],
23
+ },
24
+ async execute(args, ctx) {
25
+ const fromDir = args.path ? args.path : '.';
26
+ const p = normalizeInput(fromDir);
27
+ let base;
28
+ try { base = path.resolve(ctx.cwd || ctx.workspace, p); } catch (e) { return `Error: ${e.message}`; }
29
+ if (!fs.existsSync(base)) return `Error: path does not exist: ${fromDir}`;
30
+ if (!fs.statSync(base).isDirectory()) return `Error: --path is not a directory: ${fromDir}`;
31
+
32
+ const alts = expandBraces(args.pattern);
33
+ const re = new RegExp('^(?:' + alts.map((a) => globRegex(a).source.slice(1, -1)).join('|') + ')$');
34
+
35
+ const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false });
36
+ const matches = [];
37
+ for (const abs of files) {
38
+ const rel = path.relative(base, abs).split(path.sep).join('/');
39
+ const winRel = abs.replace(/\\/g, '/');
40
+ const base_ = path.basename(abs);
41
+ if (re.test(rel) || re.test(winRel) || re.test(base_)) matches.push(abs);
42
+ }
43
+ if (matches.length > 100) matches.length = 100;
44
+ if (matches.length === 0) return `No files matched \`${args.pattern}\` in ${fromDir}`;
45
+ return matches.join('\n');
46
+ },
47
+ };