@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.
- package/LICENSE +219 -0
- package/README.md +330 -0
- package/TODO.md +72 -0
- package/bin/hncode +9 -0
- package/bin/hncode.cmd +2 -0
- package/bin/hncode.ps1 +4 -0
- package/bin/install.js +96 -0
- package/bin/preinstall.js +42 -0
- package/package.json +26 -0
- package/src/agent.js +373 -0
- package/src/clipboard.js +114 -0
- package/src/colors.js +149 -0
- package/src/config.js +533 -0
- package/src/index.js +296 -0
- package/src/llm.js +449 -0
- package/src/plugin.js +191 -0
- package/src/session.js +73 -0
- package/src/term.js +292 -0
- package/src/toml.js +281 -0
- package/src/tools/bash.js +240 -0
- package/src/tools/edit.js +138 -0
- package/src/tools/fetch-url.js +108 -0
- package/src/tools/file-lines.js +36 -0
- package/src/tools/glob.js +47 -0
- package/src/tools/grep.js +152 -0
- package/src/tools/ignore.js +99 -0
- package/src/tools/index.js +61 -0
- package/src/tools/matchers.js +71 -0
- package/src/tools/read-media-file.js +54 -0
- package/src/tools/read.js +99 -0
- package/src/tools/tasks.js +126 -0
- package/src/tools/todo.js +33 -0
- package/src/tools/utils.js +71 -0
- package/src/tools/web-search.js +75 -0
- package/src/tools/write.js +29 -0
- package/src/tui.js +5503 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Grep tool — mirrors the hncode Grep tool schema & behavior.
|
|
2
|
+
// Primary path uses ripgrep (`rg`) for full fidelity; falls back to a pure-JS
|
|
3
|
+
// walker when rg is unavailable or multiline searching is requested.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import cp from 'node:child_process';
|
|
8
|
+
import { normalizeInput, isProbablyTextFile, truncateBuf } from './utils.js';
|
|
9
|
+
import { walkFiles } from './ignore.js';
|
|
10
|
+
import { matchGlob } from './matchers.js';
|
|
11
|
+
|
|
12
|
+
export const spec = {
|
|
13
|
+
name: 'Grep',
|
|
14
|
+
description: 'Search file contents by regex. output_mode: content (default) | files_with_matches | count_matches. Honors ignore files unless include_ignored.',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
pattern: { type: 'string', description: 'The regular expression to search for.' },
|
|
19
|
+
path: { type: 'string', description: 'Directory or file to search (default: cwd).', default: '.' },
|
|
20
|
+
glob: { type: 'string', description: 'Glob filter for which files are searched (e.g. *.ts).' },
|
|
21
|
+
type: { type: 'string', description: 'File-type filter (ripgrep -t, e.g. ts, py, go).' },
|
|
22
|
+
output_mode: { type: 'string', enum: ['content', 'files_with_matches', 'count_matches'], default: 'content', description: 'Matching lines, matching files, or per-file counts.' },
|
|
23
|
+
'-i': { type: 'boolean', default: false, description: 'Case-insensitive search.' },
|
|
24
|
+
'-n': { type: 'boolean', default: false, description: 'Prefix each match with its 1-based line number.' },
|
|
25
|
+
'-A': { type: 'integer', minimum: 0, description: 'Lines of context after each match.' },
|
|
26
|
+
'-B': { type: 'integer', minimum: 0, description: 'Lines of context before each match.' },
|
|
27
|
+
'-C': { type: 'integer', minimum: 0, description: 'Lines of context around each match (alias for -A/-B).' },
|
|
28
|
+
head_limit: { type: 'integer', minimum: 0, description: 'Limit output to the first N matches/lines.' },
|
|
29
|
+
offset: { type: 'integer', minimum: 0, description: 'Leading matches/lines to skip.' },
|
|
30
|
+
multiline: { type: 'boolean', default: false, description: 'Match across newlines.' },
|
|
31
|
+
include_ignored: { type: 'boolean', default: false, description: 'Search ignored files too.' },
|
|
32
|
+
},
|
|
33
|
+
required: ['pattern'],
|
|
34
|
+
},
|
|
35
|
+
async execute(args, ctx) {
|
|
36
|
+
const p = normalizeInput(args.path || '.');
|
|
37
|
+
const base = path.resolve(ctx.cwd || ctx.workspace, p);
|
|
38
|
+
if (!fs.existsSync(base)) return `Error: path does not exist: ${args.path}`;
|
|
39
|
+
if (!args.multiline && hasRg()) return grepRg(args, base, ctx);
|
|
40
|
+
return grepJs(args, base, ctx);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function hasRg() {
|
|
45
|
+
try { cp.spawnSync('rg', ['--version'], { stdio: 'ignore' }); return true; }
|
|
46
|
+
catch { return false; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRgArgs(args, base) {
|
|
50
|
+
const a = ['--no-heading', '--line-number', '--no-config', '-e', args.pattern];
|
|
51
|
+
if (args['-i']) a.push('-i');
|
|
52
|
+
const before = args['-B'] || (args['-C'] || 0);
|
|
53
|
+
const after = args['-A'] || (args['-C'] || 0);
|
|
54
|
+
if (before) a.push('-B', String(before));
|
|
55
|
+
if (after) a.push('-A', String(after));
|
|
56
|
+
if (args.glob) a.push('-g', args.glob);
|
|
57
|
+
if (args.type) a.push('-t', args.type);
|
|
58
|
+
if (args.output_mode === 'files_with_matches') a.push('-l');
|
|
59
|
+
else if (args.output_mode === 'count_matches') a.push('-c');
|
|
60
|
+
if (args.include_ignored) a.push('--no-ignore');
|
|
61
|
+
a.push('--', base);
|
|
62
|
+
return a;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function grepRg(args, base, ctx) {
|
|
66
|
+
let out;
|
|
67
|
+
try {
|
|
68
|
+
const r = cp.spawnSync('rg', buildRgArgs(args, base), {
|
|
69
|
+
cwd: ctx.cwd || ctx.workspace,
|
|
70
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
});
|
|
73
|
+
out = r.stdout || '';
|
|
74
|
+
if (r.status > 1 && r.stderr) return `rg error: ${r.stderr.trim()}`;
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return `rg unavailable: ${e.message}; falling back`;
|
|
77
|
+
}
|
|
78
|
+
return paginate(out, args);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function grepJs(args, base, ctx) {
|
|
82
|
+
const flags = 'g' + (args['-i'] ? 'i' : '') + (args.multiline ? 's' : '');
|
|
83
|
+
const re = new RegExp(args.pattern, flags);
|
|
84
|
+
const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false });
|
|
85
|
+
const filtered = files.filter((abs) => {
|
|
86
|
+
if (args.glob) {
|
|
87
|
+
const rel = path.relative(base, abs).split(path.sep).join('/');
|
|
88
|
+
if (!matchGlob(args.glob, rel) && !matchGlob(args.glob, path.basename(abs))) return false;
|
|
89
|
+
}
|
|
90
|
+
if (args.type) {
|
|
91
|
+
const ext = path.extname(abs).toLowerCase().slice(1);
|
|
92
|
+
if (ext !== args.type) return false;
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const abs of filtered) {
|
|
99
|
+
let txt;
|
|
100
|
+
try {
|
|
101
|
+
const buf = fs.readFileSync(abs);
|
|
102
|
+
if (!isProbablyTextFile(buf)) continue;
|
|
103
|
+
txt = truncateBuf(buf);
|
|
104
|
+
} catch { continue; }
|
|
105
|
+
const mode = args.output_mode;
|
|
106
|
+
|
|
107
|
+
if (args.multiline) {
|
|
108
|
+
const ms = Array.from(txt.matchAll(re));
|
|
109
|
+
if (ms.length === 0) continue;
|
|
110
|
+
if (mode === 'files_with_matches') { out.push(abs); continue; }
|
|
111
|
+
if (mode === 'count_matches') { out.push(`${abs}:${ms.length}`); continue; }
|
|
112
|
+
const lines = txt.split('\n');
|
|
113
|
+
for (const mm of ms) {
|
|
114
|
+
const lineNo = txt.slice(0, mm.index).split('\n').length; // 1-based
|
|
115
|
+
emitContext(out, abs, lineNo - 1, lines, args);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
const lines = txt.split('\n');
|
|
119
|
+
let count = 0;
|
|
120
|
+
for (let i = 0; i < lines.length; i++) {
|
|
121
|
+
if (re.test(lines[i])) {
|
|
122
|
+
count++;
|
|
123
|
+
if (mode === 'files_with_matches') { out.push(abs); break; }
|
|
124
|
+
if (mode === 'count_matches') continue;
|
|
125
|
+
emitContext(out, abs, i, lines, args);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (count > 0 && mode === 'count_matches') out.push(`${abs}:${count}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return paginate(out.join('\n'), args);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function emitContext(out, file, idx, lines, a) {
|
|
135
|
+
const before = a['-C'] || a['-B'] || 0;
|
|
136
|
+
const after = a['-C'] || a['-A'] || 0;
|
|
137
|
+
const start = Math.max(0, idx - before);
|
|
138
|
+
const end = Math.min(lines.length - 1, idx + after);
|
|
139
|
+
for (let k = start; k <= end; k++) {
|
|
140
|
+
if (k === idx) out.push(`${file}:${k + 1}:${lines[k]}`);
|
|
141
|
+
else out.push(`${file}-${k + 1}-${lines[k]}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function paginate(raw, args) {
|
|
146
|
+
if (raw === '' || raw === undefined || raw === null) return 'No matches found.';
|
|
147
|
+
let lines = raw.split('\n').filter((l) => l.length > 0);
|
|
148
|
+
if (args.offset) lines = lines.slice(args.offset);
|
|
149
|
+
if (args.head_limit) lines = lines.slice(0, args.head_limit);
|
|
150
|
+
if (lines.length === 0) return 'No matches found.';
|
|
151
|
+
return lines.join('\n');
|
|
152
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Best-effort .gitignore / .ignore / .rgignore matcher for Glob & Grep.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { globToRegexSource } from './matchers.js';
|
|
5
|
+
|
|
6
|
+
const IGNORE_FILES = ['.gitignore', '.ignore', '.rgignore'];
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
|
|
9
|
+
export function parseIgnoreFile(text) {
|
|
10
|
+
const out = [];
|
|
11
|
+
for (let line of text.split(/\r?\n/)) {
|
|
12
|
+
line = line.replace(/\s+#.*$/, ''); // strip trailing comments? no: only outside string; good enough
|
|
13
|
+
line = line.trimEnd();
|
|
14
|
+
if (!line || line.startsWith('#')) continue;
|
|
15
|
+
let negate = false;
|
|
16
|
+
if (line.startsWith('!')) { negate = true; line = line.slice(1); }
|
|
17
|
+
let dirOnly = false;
|
|
18
|
+
if (line.endsWith('/')) { dirOnly = true; line = line.slice(0, -1); }
|
|
19
|
+
let anchor = false;
|
|
20
|
+
if (line.startsWith('/')) { anchor = true; line = line.slice(1); }
|
|
21
|
+
if (!line) continue;
|
|
22
|
+
out.push({ raw: line, negate, dirOnly, anchor });
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ignorePatternsFor(dir) {
|
|
28
|
+
let cached = cache.get(dir);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
const arr = [];
|
|
31
|
+
for (const name of IGNORE_FILES) {
|
|
32
|
+
try {
|
|
33
|
+
const txt = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
34
|
+
arr.push(...parseIgnoreFile(txt));
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
cache.set(dir, arr);
|
|
38
|
+
return arr;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function globToRegex(pattern) {
|
|
42
|
+
return new RegExp(globToRegexSource(pattern));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function matchPattern(pat, relPath, isDir) {
|
|
46
|
+
if (pat.dirOnly && !isDir) return false;
|
|
47
|
+
const re = globToRegex(pat.raw);
|
|
48
|
+
if (pat.anchor) return re.test(relPath);
|
|
49
|
+
if (re.test(relPath)) return true;
|
|
50
|
+
const parts = relPath.split('/');
|
|
51
|
+
for (let i = 0; i < parts.length; i++) {
|
|
52
|
+
if (re.test(parts.slice(i).join('/'))) return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Walk dir applying ignore patterns from root..leaf. Returns list of absolute files
|
|
58
|
+
// (and dirs if includeDirs), honoring ignore unless includeIgnored.
|
|
59
|
+
export function walkFiles(base, opts = {}) {
|
|
60
|
+
const results = [];
|
|
61
|
+
const { includeIgnored = false, includeDirs = false } = opts;
|
|
62
|
+
const baseAbs = path.resolve(base);
|
|
63
|
+
_walk(baseAbs, '', [], results, { includeIgnored, includeDirs });
|
|
64
|
+
// sort by mtime desc for a "most recent first" feel
|
|
65
|
+
results.sort((a, b) => {
|
|
66
|
+
try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return b < a ? 1 : -1; }
|
|
67
|
+
});
|
|
68
|
+
if (results.length > 1000) results.length = 1000;
|
|
69
|
+
try { if (!includeDirs) return results.filter(p => { try { return fs.statSync(p).isFile(); } catch { return false; } }); } catch { return results; }
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _walk(dir, relPrefix, parentPatterns, results, opts) {
|
|
74
|
+
const { includeIgnored, includeDirs } = opts;
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
77
|
+
const acc = parentPatterns.concat(ignorePatternsFor(dir));
|
|
78
|
+
for (const ent of entries) {
|
|
79
|
+
const rel = relPrefix ? relPrefix + '/' + ent.name : ent.name;
|
|
80
|
+
const abs = path.join(dir, ent.name);
|
|
81
|
+
let isDir;
|
|
82
|
+
try { isDir = ent.isDirectory(); } catch { try { isDir = fs.statSync(abs).isDirectory(); } catch { isDir = false; } }
|
|
83
|
+
// ignore decision
|
|
84
|
+
let ignored = false;
|
|
85
|
+
for (const pat of acc) {
|
|
86
|
+
if (matchPattern(pat, rel, isDir)) ignored = !pat.negate;
|
|
87
|
+
}
|
|
88
|
+
if (ignored && !includeIgnored) {
|
|
89
|
+
if (isDir) continue; // prune dir subtree
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isDir) {
|
|
93
|
+
if (includeDirs) results.push(abs);
|
|
94
|
+
_walk(abs, rel, acc, results, opts);
|
|
95
|
+
} else {
|
|
96
|
+
results.push(abs);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Toolbelt registry. Each entry exposes the JSON schema (matching hncode's tools)
|
|
2
|
+
// plus an async execute(args, ctx) returning a result string.
|
|
3
|
+
|
|
4
|
+
import * as readMod from './read.js';
|
|
5
|
+
import * as writeMod from './write.js';
|
|
6
|
+
import * as editMod from './edit.js';
|
|
7
|
+
import * as globMod from './glob.js';
|
|
8
|
+
import * as grepMod from './grep.js';
|
|
9
|
+
import * as bashMod from './bash.js';
|
|
10
|
+
import * as todoMod from './todo.js';
|
|
11
|
+
import * as fetchUrlMod from './fetch-url.js';
|
|
12
|
+
import * as webSearchMod from './web-search.js';
|
|
13
|
+
import * as readMediaFileMod from './read-media-file.js';
|
|
14
|
+
import * as fileLinesMod from './file-lines.js';
|
|
15
|
+
import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
|
|
16
|
+
import { pluginTools } from '../plugin.js';
|
|
17
|
+
|
|
18
|
+
// Built-in tools.
|
|
19
|
+
const builtinTools = [
|
|
20
|
+
readMod.spec,
|
|
21
|
+
writeMod.spec,
|
|
22
|
+
editMod.spec,
|
|
23
|
+
globMod.spec,
|
|
24
|
+
grepMod.spec,
|
|
25
|
+
bashMod.spec,
|
|
26
|
+
todoMod.spec,
|
|
27
|
+
fetchUrlMod.spec,
|
|
28
|
+
webSearchMod.spec,
|
|
29
|
+
readMediaFileMod.spec,
|
|
30
|
+
fileLinesMod.spec,
|
|
31
|
+
TaskListSpec,
|
|
32
|
+
TaskOutputSpec,
|
|
33
|
+
TaskStopSpec,
|
|
34
|
+
TaskWaitSpec,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// Combined list: built-ins + plugin-registered tools. Plugin tools appear after
|
|
38
|
+
// built-ins so their names are visible, but built-in names take precedence in
|
|
39
|
+
// the byName lookup (first one wins).
|
|
40
|
+
export function combinedTools() {
|
|
41
|
+
return [...builtinTools, ...pluginTools];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const tools = builtinTools;
|
|
45
|
+
export { builtinTools };
|
|
46
|
+
|
|
47
|
+
const byName = new Map(builtinTools.map((t) => [t.name, t]));
|
|
48
|
+
export function getTool(name) { return byName.get(name); }
|
|
49
|
+
export function toolNames() { return combinedTools().map((t) => t.name); }
|
|
50
|
+
|
|
51
|
+
// Schemas for sending to an LLM (OpenAI `tools` array).
|
|
52
|
+
export function llmTools() {
|
|
53
|
+
return combinedTools().map((t) => ({
|
|
54
|
+
type: 'function',
|
|
55
|
+
function: {
|
|
56
|
+
name: t.name,
|
|
57
|
+
description: t.description,
|
|
58
|
+
parameters: t.parameters,
|
|
59
|
+
},
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
@@ -0,0 +1,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
|
+
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ReadMediaFile tool — reads media files and returns base64 for images,
|
|
2
|
+
// or metadata for other formats (video, audio).
|
|
3
|
+
// Allows multimodal models to view images and identify media types.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { resolvePath } from './utils.js';
|
|
8
|
+
|
|
9
|
+
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB max for images
|
|
10
|
+
const MAX_PREVIEW_SIZE = 100 * 1024; // 100KB text preview for non-images
|
|
11
|
+
|
|
12
|
+
const SUPPORTED_IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
|
|
13
|
+
const VIDEO_EXT = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
|
|
14
|
+
const AUDIO_EXT = ['.mp3', '.wav', '.ogg', '.flac', '.m4a'];
|
|
15
|
+
|
|
16
|
+
export const spec = {
|
|
17
|
+
name: 'ReadMediaFile',
|
|
18
|
+
description: 'Read a media file. Images return base64; other formats return metadata.',
|
|
19
|
+
parameters: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
path: { type: 'string', description: 'Path to the media file.' },
|
|
23
|
+
},
|
|
24
|
+
required: ['path'],
|
|
25
|
+
},
|
|
26
|
+
async execute(args, ctx) {
|
|
27
|
+
let p;
|
|
28
|
+
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
|
|
31
|
+
|
|
32
|
+
const stat = fs.statSync(p);
|
|
33
|
+
const ext = path.extname(p).toLowerCase();
|
|
34
|
+
|
|
35
|
+
if (SUPPORTED_IMAGE_EXT.includes(ext)) {
|
|
36
|
+
if (stat.size > MAX_IMAGE_SIZE) {
|
|
37
|
+
return `Error: image too large (${(stat.size / 1024 / 1024).toFixed(1)}MB, max 20MB): ${args.path}`;
|
|
38
|
+
}
|
|
39
|
+
const data = fs.readFileSync(p);
|
|
40
|
+
const base64 = data.toString('base64');
|
|
41
|
+
const mimeType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
|
42
|
+
: ext === '.png' ? 'image/png'
|
|
43
|
+
: ext === '.gif' ? 'image/gif'
|
|
44
|
+
: ext === '.webp' ? 'image/webp'
|
|
45
|
+
: ext === '.bmp' ? 'image/bmp'
|
|
46
|
+
: 'image/unknown';
|
|
47
|
+
return `data:${mimeType};base64,${base64}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fileType = VIDEO_EXT.includes(ext) ? 'video' : AUDIO_EXT.includes(ext) ? 'audio' : 'unknown';
|
|
51
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
52
|
+
return `[Media: ${fileType.toUpperCase()}] ${args.path} (${sizeMB}MB) - cannot preview in text mode.`;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Read tool — mirrors the hncode Read tool schema & behavior.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { resolvePath, isProbablyTextFile } from './utils.js';
|
|
5
|
+
|
|
6
|
+
// Normalize newlines so a text file's hash is stable regardless of CRLF/LF.
|
|
7
|
+
export function normalizeText(s) {
|
|
8
|
+
return String(s).split(/\r\n|\r|\n/).join('\n');
|
|
9
|
+
}
|
|
10
|
+
// FNV-1a-ish 32-bit hash of a string (cheap, deterministic).
|
|
11
|
+
export function hashStr(s) {
|
|
12
|
+
const str = normalizeText(s);
|
|
13
|
+
let h = 0x811c9dc5;
|
|
14
|
+
for (let i = 0; i < str.length; i++) {
|
|
15
|
+
h ^= str.charCodeAt(i);
|
|
16
|
+
h = (h * 0x01000193) >>> 0;
|
|
17
|
+
}
|
|
18
|
+
return h.toString(36);
|
|
19
|
+
}
|
|
20
|
+
// Hash the exact <start..end> 1-based line slice of `lines`.
|
|
21
|
+
function hashLines(lines, start, end) {
|
|
22
|
+
const slice = (lines.slice(start - 1, end) || []).join('\n');
|
|
23
|
+
return hashStr(slice);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const spec = {
|
|
27
|
+
name: 'Read',
|
|
28
|
+
description: 'Read a text file. Returns the whole file by default; use line_offset / n_lines for a range.',
|
|
29
|
+
parameters: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
path: { type: 'string', description: 'Path to the text file.' },
|
|
33
|
+
line_offset: {
|
|
34
|
+
type: 'integer',
|
|
35
|
+
description: '1-based start line. Negative counts from the end (e.g. -100 = last 100 lines).',
|
|
36
|
+
default: 1,
|
|
37
|
+
},
|
|
38
|
+
n_lines: { type: 'integer', minimum: 1, description: 'Lines to read (default: the rest of the file).' },
|
|
39
|
+
},
|
|
40
|
+
required: ['path'],
|
|
41
|
+
},
|
|
42
|
+
async execute(args, ctx) {
|
|
43
|
+
let p;
|
|
44
|
+
try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
|
|
45
|
+
let buf;
|
|
46
|
+
try { buf = fs.readFileSync(p); } catch (e) { return `Error reading ${args.path}: ${e.message}`; }
|
|
47
|
+
if (!isProbablyTextFile(buf)) {
|
|
48
|
+
return `Cannot read: ${args.path} appears to be a binary file (use a tool suited for binary).`;
|
|
49
|
+
}
|
|
50
|
+
let st;
|
|
51
|
+
try { st = fs.statSync(p); } catch { st = null; }
|
|
52
|
+
// NO truncation: the caller asked for the file, so the whole file is returned
|
|
53
|
+
// (line ranges still work via line_offset / n_lines). The old 1000-line and
|
|
54
|
+
// 128 KB caps silently cut the content, which also corrupted the
|
|
55
|
+
// staleness hashes recorded below because they were computed from the
|
|
56
|
+
// TRUNCATED text rather than the file on disk.
|
|
57
|
+
const text = buf.toString('utf8');
|
|
58
|
+
const lines = normalizeText(text).split('\n');
|
|
59
|
+
const total = lines.length;
|
|
60
|
+
// Default: every line. An explicit n_lines still narrows the window.
|
|
61
|
+
const nLines = args.n_lines ? args.n_lines : total;
|
|
62
|
+
let start = args.line_offset !== undefined ? args.line_offset : 1;
|
|
63
|
+
if (start < 0) start = Math.max(1, total + 1 + start);
|
|
64
|
+
start = Math.max(1, Math.min(start, total));
|
|
65
|
+
const end = Math.min(start + nLines - 1, total);
|
|
66
|
+
let out = '';
|
|
67
|
+
out += `${args.path} (lines ${start}-${end} of ${total})\n`;
|
|
68
|
+
for (let i = start; i <= end; i++) out += `${i}\t${lines[i - 1]}\n`;
|
|
69
|
+
// No trailing "truncated" banner: without an explicit n_lines every line is
|
|
70
|
+
// returned, so the only way to stop early is a deliberate range request — the
|
|
71
|
+
// header already states which lines those were.
|
|
72
|
+
|
|
73
|
+
// --- Record a hash fingerprint so a later Edit can verify the AI is
|
|
74
|
+
// editing against the same content it read (snippet-staleness guard). ---
|
|
75
|
+
// Store the FULL file's normalized content hash plus this call's line
|
|
76
|
+
// range + region hash. Edit compares old_string's location against these.
|
|
77
|
+
if (ctx) {
|
|
78
|
+
const pool = (ctx.readPool = ctx.readPool || new Map());
|
|
79
|
+
let entry = pool.get(p);
|
|
80
|
+
if (!entry) {
|
|
81
|
+
entry = {
|
|
82
|
+
mtimeMs: st ? st.mtimeMs : 0,
|
|
83
|
+
size: st ? st.size : 0,
|
|
84
|
+
fileHash: hashStr(text),
|
|
85
|
+
lines: total, // current total line count at read time
|
|
86
|
+
regions: [], // {start, end, hash} of each read slice
|
|
87
|
+
};
|
|
88
|
+
pool.set(p, entry);
|
|
89
|
+
}
|
|
90
|
+
entry.mtimeMs = st ? st.mtimeMs : entry.mtimeMs;
|
|
91
|
+
entry.size = st ? st.size : entry.size;
|
|
92
|
+
entry.fileHash = hashStr(text);
|
|
93
|
+
entry.lines = total;
|
|
94
|
+
// Record this call's exact region (only the lines actually returned).
|
|
95
|
+
entry.regions.push({ start, end, hash: hashLines(lines, start, end) });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// TaskList / TaskOutput / TaskStop — manage background Bash tasks spawned with
|
|
2
|
+
// run_in_background.
|
|
3
|
+
|
|
4
|
+
import cp from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const TaskListSpec = {
|
|
7
|
+
name: 'TaskList',
|
|
8
|
+
description: 'List background tasks (from Bash run_in_background). Reports id, status, description.',
|
|
9
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
10
|
+
async execute(_args, ctx) {
|
|
11
|
+
const tasks = Object.values(ctx.tasks || {}).sort((a, b) => b.start - a.start);
|
|
12
|
+
if (tasks.length === 0) return 'No background tasks.';
|
|
13
|
+
return tasks.map((t) => `#${t.id} [${t.status}] pid=${t.pid} ${t.description || t.command}`).join('\n');
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const TaskOutputSpec = {
|
|
18
|
+
name: 'TaskOutput',
|
|
19
|
+
description: 'Get the output of a background task (Bash run_in_background) by task_id. head_limit caps the lines returned.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
task_id: { type: 'string', description: 'Task id returned when the task was started.' },
|
|
24
|
+
head_limit: { type: 'integer', minimum: 0, description: 'Max output lines returned.' },
|
|
25
|
+
},
|
|
26
|
+
required: ['task_id'],
|
|
27
|
+
},
|
|
28
|
+
async execute(args, ctx) {
|
|
29
|
+
const t = (ctx.tasks || {})[args.task_id];
|
|
30
|
+
if (!t) return `No such task: ${args.task_id}`;
|
|
31
|
+
const lines = t.output ? t.output.split('\n').filter((l) => l.length > 0) : [];
|
|
32
|
+
const capped = args.head_limit ? lines.slice(0, args.head_limit) : lines;
|
|
33
|
+
const tail = lines.length > (capped.length);
|
|
34
|
+
let out = `#${t.id} [${t.status}] (pid ${t.pid})\n${capped.join('\n')}`;
|
|
35
|
+
if (tail) out += `\n... [${lines.length - capped.length} more lines; use head_limit]`;
|
|
36
|
+
return out;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const TaskStopSpec = {
|
|
41
|
+
name: 'TaskStop',
|
|
42
|
+
description: 'Stop a running background task (Bash run_in_background) by task_id. Use only when the task must be cancelled.',
|
|
43
|
+
parameters: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
task_id: { type: 'string', description: 'Background task id to stop.' },
|
|
47
|
+
reason: { type: 'string', description: 'Short reason recorded on stop.' },
|
|
48
|
+
},
|
|
49
|
+
required: ['task_id'],
|
|
50
|
+
},
|
|
51
|
+
async execute(args, ctx) {
|
|
52
|
+
const t = (ctx.tasks || {})[args.task_id];
|
|
53
|
+
if (!t) return `No such task: ${args.task_id}`;
|
|
54
|
+
if (t.status !== 'running') return `Task ${t.id} is not running (status: ${t.status}).`;
|
|
55
|
+
const reason = args.reason || 'Stopped by TaskStop';
|
|
56
|
+
try {
|
|
57
|
+
if (process.platform === 'win32' && t.pid) {
|
|
58
|
+
try { cp.spawnSync('taskkill', ['/pid', String(t.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
59
|
+
} else if (t.pid) {
|
|
60
|
+
try { process.kill(t.pid, 'SIGTERM'); } catch {}
|
|
61
|
+
}
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return `Failed to stop ${t.id}: ${e.message}`;
|
|
64
|
+
}
|
|
65
|
+
t.status = 'stopped';
|
|
66
|
+
t.stopReason = reason;
|
|
67
|
+
t.end = Date.now();
|
|
68
|
+
return `Stopped task ${t.id}. reason: ${reason}`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// WaitFor — block until a background task finishes (or the timeout elapses).
|
|
73
|
+
// Mirrors kimi-code's task-wait: a timeout is not an error; the result lists
|
|
74
|
+
// the tasks still running so the caller can wait again.
|
|
75
|
+
export const TaskWaitSpec = {
|
|
76
|
+
name: 'TaskWait',
|
|
77
|
+
description: 'Wait for background tasks (Bash run_in_background) to finish, timeout in seconds (1-600). With task_id, waits for that task; without it, returns when any running task finishes. A timeout is not an error; still-running tasks are listed, so you may call it again.',
|
|
78
|
+
parameters: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
timeout: { type: 'integer', minimum: 1, maximum: 600, description: 'Max seconds to wait (1-600).' },
|
|
82
|
+
task_id: { type: 'string', description: 'Task id to wait for; omit to wait for any running task.' },
|
|
83
|
+
},
|
|
84
|
+
required: ['timeout'],
|
|
85
|
+
},
|
|
86
|
+
async execute(args, ctx) {
|
|
87
|
+
const tasks = ctx.tasks || {};
|
|
88
|
+
const target = args.task_id ? tasks[args.task_id] : null;
|
|
89
|
+
if (args.task_id && !target) return `No such task: ${args.task_id}`;
|
|
90
|
+
const secs = Math.max(1, Math.min(600, Number(args.timeout) || 1));
|
|
91
|
+
const deadline = Date.now() + secs * 1000;
|
|
92
|
+
const signal = ctx && ctx.signal;
|
|
93
|
+
|
|
94
|
+
const isDone = (t) => t && t.status !== 'running';
|
|
95
|
+
const anyRunning = () => Object.values(tasks).some((t) => t.status === 'running');
|
|
96
|
+
|
|
97
|
+
// Nothing to wait for at call time.
|
|
98
|
+
if (args.task_id) {
|
|
99
|
+
if (isDone(target)) return `Task ${target.id} already ${target.status}${target.code !== undefined ? ` (exit ${target.code})` : ''}.`;
|
|
100
|
+
} else if (!anyRunning()) {
|
|
101
|
+
return 'No background tasks are running.';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await new Promise((resolve) => {
|
|
105
|
+
const tick = setInterval(() => {
|
|
106
|
+
const done = args.task_id ? isDone(target) : !anyRunning();
|
|
107
|
+
if (done || Date.now() >= deadline || (signal && signal.aborted)) {
|
|
108
|
+
clearInterval(tick);
|
|
109
|
+
resolve();
|
|
110
|
+
}
|
|
111
|
+
}, 100);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (signal && signal.aborted) return 'Interrupted by user';
|
|
115
|
+
const still = Object.values(tasks).filter((t) => t.status === 'running');
|
|
116
|
+
if (args.task_id) {
|
|
117
|
+
if (isDone(target)) {
|
|
118
|
+
return `Task ${target.id} finished: ${target.status}${target.code !== undefined ? ` (exit ${target.code})` : ''}${target.stopReason ? ` — ${target.stopReason}` : ''}.`;
|
|
119
|
+
}
|
|
120
|
+
return `Task ${target.id} still running after ${secs}s.`;
|
|
121
|
+
}
|
|
122
|
+
if (!still.length) return 'All background tasks have finished.';
|
|
123
|
+
const list = still.map((t) => `#${t.id} ${t.description || t.command}`).join('; ');
|
|
124
|
+
return `Timeout after ${secs}s; still running: ${list}`;
|
|
125
|
+
},
|
|
126
|
+
};
|