@hmharness/agent 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/dist/i18n.d.ts +105 -0
- package/dist/i18n.js +181 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +20 -0
- package/dist/runner.d.ts +76 -0
- package/dist/runner.js +311 -0
- package/dist/spawn.d.ts +32 -0
- package/dist/spawn.js +129 -0
- package/dist/tools.d.ts +52 -0
- package/dist/tools.js +590 -0
- package/package.json +34 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hmharness/agent - base tools
|
|
3
|
+
* The general coding-agent toolset: read, write, list, a guarded shell
|
|
4
|
+
* runner, long-term memory, and image viewing (vision model). Deny-first
|
|
5
|
+
* guard on obviously destructive one-liners; approvals live in the kernel.
|
|
6
|
+
*/
|
|
7
|
+
import { exec } from 'node:child_process';
|
|
8
|
+
import { copyFile, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { chatVision, homeDir, loadConfig, resolveProvider } from '@hmharness/kernel';
|
|
12
|
+
const execCb = promisify(exec);
|
|
13
|
+
const DENY_PATTERNS = [
|
|
14
|
+
// recursive deletes aimed at ROOT/HOME/SYSTEM targets only - relative
|
|
15
|
+
// subdirectories are legitimate work (the approval gate still covers them;
|
|
16
|
+
// a blanket ban made the agent unable to clean its own scratch dirs)
|
|
17
|
+
{ re: /rm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+["']?[/~"']|rm\s+-rf?\s+[CcJj]:\\?\/?(\s|$)/i, why: 'recursive delete of a root/home path' },
|
|
18
|
+
{ re: /(?:rd|rmdir)\s+\/s[^|;&]{0,24}["']?(?:[a-z]:\\(?:\s|["']|$)|[a-z]:\\(?:windows|program files(?: \(x86\))?|users|programdata)(?:\\|\s|["']|$)|\/(?:\s|["']|$)|~|%userprofile%|%homedrive%)/i, why: 'recursive delete of a root/system/home path' },
|
|
19
|
+
{ re: /remove-item\s+[^|;&]{0,40}-(?:recurse|force)[^|;&]{0,8}-(?:force|recurse)[^|;&]{0,12}["']?(?:[a-z]:\\(?:\s|["']|$)|[a-z]:\\(?:windows|program files(?: \(x86\))?|users|programdata)(?:\\|\s|["']|$)|~|%userprofile%|%homedrive%)/i, why: 'recursive delete of a system/home path' },
|
|
20
|
+
{ re: /format\s+[a-z]:/i, why: 'drive format' },
|
|
21
|
+
{ re: /shutdown|restart\s+computer|taskkill\s+\/f\s+\/im\s+explorer/i, why: 'system power/shell action' },
|
|
22
|
+
{ re: /reg\s+(delete|add).*(Run|CurrentVersion)/i, why: 'autostart registry mutation' },
|
|
23
|
+
{ re: /curl[^|;&]{0,80}\|\s*(ba)?sh|iwr[^|;&]{0,80}\|\s*iex|set-executionpolicy\s+unrestricted/i, why: 'remote-script-to-shell pipe / unrestricted execution policy' },
|
|
24
|
+
];
|
|
25
|
+
function safePath(p, cwd) {
|
|
26
|
+
return isAbsolute(p) ? p : resolve(cwd, p);
|
|
27
|
+
}
|
|
28
|
+
export const readFileTool = {
|
|
29
|
+
name: 'read_file',
|
|
30
|
+
description: 'Read a text file. Returns the full content (truncated at 60k chars).',
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: { path: { type: 'string', description: 'file path (absolute or relative to cwd)' } },
|
|
34
|
+
required: ['path'],
|
|
35
|
+
},
|
|
36
|
+
async execute(args, ctx) {
|
|
37
|
+
try {
|
|
38
|
+
const text = await readFile(safePath(String(args.path), ctx.cwd), 'utf8');
|
|
39
|
+
return { output: text.length > 60_000 ? text.slice(0, 60_000) + '\n...[truncated]' : text };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return { output: String(err), isError: true };
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
export const writeFileTool = {
|
|
47
|
+
name: 'write_file',
|
|
48
|
+
description: 'Write a text file (creates or overwrites). Use for code, config, docs.',
|
|
49
|
+
parameters: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
path: { type: 'string', description: 'file path' },
|
|
53
|
+
content: { type: 'string', description: 'full file content' },
|
|
54
|
+
},
|
|
55
|
+
required: ['path', 'content'],
|
|
56
|
+
},
|
|
57
|
+
needsApproval: () => true,
|
|
58
|
+
async execute(args, ctx) {
|
|
59
|
+
try {
|
|
60
|
+
const p = safePath(String(args.path), ctx.cwd);
|
|
61
|
+
// the agent overwriting its own live config is high-blast-radius:
|
|
62
|
+
// snapshot first so a bad rewrite is one copy away from recovery
|
|
63
|
+
let backupNote = '';
|
|
64
|
+
if (p === join(ctx.home, 'config.json')) {
|
|
65
|
+
const bak = p + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-');
|
|
66
|
+
await copyFile(p, bak).then(() => { backupNote = ` (backup: ${bak})`; }).catch(() => { });
|
|
67
|
+
}
|
|
68
|
+
await writeFile(p, String(args.content ?? ''), 'utf8');
|
|
69
|
+
return { output: `wrote ${String(args.content ?? '').length} chars to ${p}${backupNote}` };
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return { output: String(err), isError: true };
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export const listDirTool = {
|
|
77
|
+
name: 'list_dir',
|
|
78
|
+
description: 'List a directory: names with d/- prefix and size.',
|
|
79
|
+
parameters: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { path: { type: 'string', description: 'directory path (default cwd)' } },
|
|
82
|
+
required: [],
|
|
83
|
+
},
|
|
84
|
+
async execute(args, ctx) {
|
|
85
|
+
try {
|
|
86
|
+
const dir = args.path ? safePath(String(args.path), ctx.cwd) : ctx.cwd;
|
|
87
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
88
|
+
const lines = entries.slice(0, 300).map((e) => `${e.isDirectory() ? 'd' : '-'} ${e.name}`);
|
|
89
|
+
return { output: `${dir}\n${lines.join('\n') || '(empty)'}` };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return { output: String(err), isError: true };
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
/** Host-shell pipe preflight: on Windows cmd, Unix-isms fail with cryptic
|
|
97
|
+
* mojibake and the model retries for many turns. Only the FIRST word of
|
|
98
|
+
* each host segment (split on | || && ;) is checked - Unix words inside
|
|
99
|
+
* arguments (docker exec c ls /app) belong to the container and stay legal.
|
|
100
|
+
* (Pure, testable.) */
|
|
101
|
+
export function unixPipeOnWindows(command, platform = process.platform) {
|
|
102
|
+
if (platform !== 'win32')
|
|
103
|
+
return null;
|
|
104
|
+
const eq = {
|
|
105
|
+
head: 'more (pager) or node -e', tail: 'powershell -NoProfile -Command "Get-Content -Tail N"',
|
|
106
|
+
grep: 'findstr /i "pattern" file', awk: 'node -e', sed: 'node -e',
|
|
107
|
+
wc: 'powershell -Command "(Get-Content f).Count"', cat: 'type file',
|
|
108
|
+
ls: 'dir /b', less: 'more', which: 'where name',
|
|
109
|
+
};
|
|
110
|
+
for (const seg of command.split(/\|\||&&|[|;]/)) {
|
|
111
|
+
const first = (/^[\s"']*([\w.-]+)/.exec(seg)?.[1] ?? '').toLowerCase();
|
|
112
|
+
if (eq[first]) {
|
|
113
|
+
return `Refused before running: '${first}' does not exist in cmd.exe (the host shell). Use: ${eq[first]} . Do NOT retry the same pipeline.`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
/** Repeat-failure short-circuit: the same command that already failed twice
|
|
119
|
+
* is refused without executing - the model repeating it 10x burned ~700k
|
|
120
|
+
* prompt tokens in one audited session. (Module-level, session-scoped.)
|
|
121
|
+
* P2 CRITIC (Reflexion's signal amplification): on the SECOND failure the
|
|
122
|
+
* refusal isn't just "stop" - it demands a structured diagnosis first
|
|
123
|
+
* (locate-then-fix; Self-Refine: 94% of refinement failures are bad
|
|
124
|
+
* feedback - 33% wrong location, 61% wrong fix). The third attempt shorts
|
|
125
|
+
* hard. */
|
|
126
|
+
const failedCommands = new Map();
|
|
127
|
+
const CRITIC_PROMPT = [
|
|
128
|
+
'This command failed twice. Before ANY retry, write a short diagnosis in your reply:',
|
|
129
|
+
'1) LOCATE the failure - re-read the exact error text; name the failing stage (binary missing? wrong flag? path? auth? environment?).',
|
|
130
|
+
'2) HYPOTHESIZE the root cause in one sentence.',
|
|
131
|
+
'3) Only then choose a DIFFERENT command (different flags/tool/approach) - repeating the same line will be refused.',
|
|
132
|
+
'If the failure is environmental (missing tool, permission), stop and tell the user instead of retrying.',
|
|
133
|
+
].join(' ');
|
|
134
|
+
/** P2 plan verification (Self-Refine's verified lesson: 61% of refinement
|
|
135
|
+
* failures are an appropriate-looking but WRONG fix - the verifier must
|
|
136
|
+
* check the fix, not the effort). For expensive commands (full builds,
|
|
137
|
+
* device flashes, package installs) a cheap pre-flight runs BEFORE the
|
|
138
|
+
* real thing: the referenced binary must exist, the target dir must be
|
|
139
|
+
* there, flags must parse. A failing pre-flight returns in milliseconds
|
|
140
|
+
* what would otherwise burn a 3-minute build to discover. */
|
|
141
|
+
const EXPENSIVE_HINTS = /\b(hvigorw|hvigor|hdc|ohpm|npm|pnpm|pip|gradle)\b/i;
|
|
142
|
+
export async function commandPreflight(command, cwd) {
|
|
143
|
+
if (!EXPENSIVE_HINTS.test(command))
|
|
144
|
+
return null; // cheap commands skip
|
|
145
|
+
const first = command.trim().split(/\s+/)[0].replace(/["']/g, '');
|
|
146
|
+
// if it's a relative script (hvigorw.bat, ./gradlew), it must exist here
|
|
147
|
+
if (/^[.\\/]/.test(first) || /\.(bat|cmd|exe|ps1)$/i.test(first)) {
|
|
148
|
+
try {
|
|
149
|
+
await readFile(resolve(cwd, first), 'utf8');
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return `Preflight failed: "${first}" not found under ${cwd} - check the working directory or use an absolute path before running the full command.`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
export const runCommandTool = {
|
|
158
|
+
name: 'run_command',
|
|
159
|
+
description: 'Run a shell command (one line, cmd on Windows / sh elsewhere) with a timeout. Prefer focused commands; read output carefully before deciding next steps.',
|
|
160
|
+
parameters: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
command: { type: 'string', description: 'the command line to run' },
|
|
164
|
+
timeout_ms: { type: 'number', description: 'timeout in ms (default 60000, max 300000)' },
|
|
165
|
+
},
|
|
166
|
+
required: ['command'],
|
|
167
|
+
},
|
|
168
|
+
needsApproval: () => true,
|
|
169
|
+
async execute(args, ctx) {
|
|
170
|
+
const command = String(args.command ?? '');
|
|
171
|
+
for (const d of DENY_PATTERNS) {
|
|
172
|
+
if (d.re.test(command)) {
|
|
173
|
+
return { output: `Refused: ${d.why}. Ask the user to run it manually if truly intended.`, isError: true };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const unix = unixPipeOnWindows(command);
|
|
177
|
+
if (unix)
|
|
178
|
+
return { output: unix, isError: true };
|
|
179
|
+
const pre = await commandPreflight(command, ctx.cwd);
|
|
180
|
+
if (pre)
|
|
181
|
+
return { output: pre, isError: true };
|
|
182
|
+
const fails = failedCommands.get(command) ?? 0;
|
|
183
|
+
if (fails >= 2) {
|
|
184
|
+
return { output: `Refused: this exact command already failed ${fails} times this session. Change strategy (different command, different tool, or ask the user) instead of repeating it.`, isError: true };
|
|
185
|
+
}
|
|
186
|
+
const r = await execCommand(command, args, ctx, fails);
|
|
187
|
+
if (r.isError && fails === 1) {
|
|
188
|
+
// second failure of this line: amplify the signal (Reflexion) - the
|
|
189
|
+
// output now DEMANDS a locate-then-fix diagnosis before any act 3
|
|
190
|
+
return { output: `${r.output}\n\n${CRITIC_PROMPT}`, isError: true };
|
|
191
|
+
}
|
|
192
|
+
return r;
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
async function execCommand(command, args, ctx, fails) {
|
|
196
|
+
const timeout = Math.min(Number(args.timeout_ms ?? 60_000), 300_000);
|
|
197
|
+
try {
|
|
198
|
+
const { stdout, stderr } = await execCb(command, {
|
|
199
|
+
cwd: ctx.cwd,
|
|
200
|
+
timeout,
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
const out = (stdout || '') + (stderr ? `\n[stderr]\n${stderr}` : '');
|
|
205
|
+
failedCommands.delete(command);
|
|
206
|
+
return { output: (out.trim() || '(no output)').slice(0, 60_000) };
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
failedCommands.set(command, fails + 1);
|
|
210
|
+
const e = err;
|
|
211
|
+
const parts = [e.stdout, e.stderr, e.killed ? '(timed out)' : null, e.message].filter(Boolean).join('\n');
|
|
212
|
+
return { output: parts.slice(0, 60_000), isError: true };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Zero-dependency web search: DuckDuckGo HTML endpoint, no API key. */
|
|
216
|
+
export const webSearchTool = {
|
|
217
|
+
name: 'web_search',
|
|
218
|
+
description: 'Search the web (DuckDuckGo, no key). Returns top results as: title | url | snippet. Use for current events, docs, versions - anything not knowable offline.',
|
|
219
|
+
parameters: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
query: { type: 'string', description: 'search query' },
|
|
223
|
+
count: { type: 'number', description: 'max results (default 6, max 10)' },
|
|
224
|
+
},
|
|
225
|
+
required: ['query'],
|
|
226
|
+
},
|
|
227
|
+
async execute(args) {
|
|
228
|
+
const q = String(args.query ?? '').trim();
|
|
229
|
+
if (!q)
|
|
230
|
+
return { output: 'query required', isError: true };
|
|
231
|
+
const n = Math.min(Math.max(Number(args.count ?? 6), 1), 10);
|
|
232
|
+
try {
|
|
233
|
+
const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(q), {
|
|
234
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' },
|
|
235
|
+
signal: AbortSignal.timeout(15_000),
|
|
236
|
+
});
|
|
237
|
+
if (!res.ok)
|
|
238
|
+
return { output: 'search: HTTP ' + res.status, isError: true };
|
|
239
|
+
const html = await res.text();
|
|
240
|
+
const out = [];
|
|
241
|
+
const re = new RegExp('class="result__a"[^>]*href="([^"]+)"[^>]*>([\\s\\S]*?)</a>[\\s\\S]*?class="result__snippet"[^>]*>([\\s\\S]*?)</a>', 'g');
|
|
242
|
+
let m;
|
|
243
|
+
const strip = (t) => t.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').trim();
|
|
244
|
+
while ((m = re.exec(html)) && out.length < n) {
|
|
245
|
+
let url = m[1];
|
|
246
|
+
const uddg = /uddg=([^&]+)/.exec(url);
|
|
247
|
+
if (uddg) {
|
|
248
|
+
try {
|
|
249
|
+
url = decodeURIComponent(uddg[1]);
|
|
250
|
+
}
|
|
251
|
+
catch { /* keep raw */ }
|
|
252
|
+
}
|
|
253
|
+
out.push((out.length + 1) + '. ' + strip(m[2]) + ' | ' + url + ' | ' + strip(m[3]).slice(0, 200));
|
|
254
|
+
}
|
|
255
|
+
return { output: out.length ? out.join('\n') : 'no results (try a different query)' };
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
return { output: 'search failed: ' + String(err).slice(0, 160), isError: true };
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
/** Fetch a URL and return readable text (tags stripped, entities decoded,
|
|
263
|
+
* size-bounded). Pairs with web_search: search finds, fetch reads. */
|
|
264
|
+
export const webFetchTool = {
|
|
265
|
+
name: 'web_fetch',
|
|
266
|
+
description: 'Fetch a web page and return its readable text (HTML stripped, entities decoded, bounded to ~12k chars). Use after web_search to read a result, or for any docs page / raw file URL.',
|
|
267
|
+
parameters: {
|
|
268
|
+
type: 'object',
|
|
269
|
+
properties: {
|
|
270
|
+
url: { type: 'string', description: 'http(s) URL' },
|
|
271
|
+
maxChars: { type: 'number', description: 'bound (default 12000, max 40000)' },
|
|
272
|
+
},
|
|
273
|
+
required: ['url'],
|
|
274
|
+
},
|
|
275
|
+
async execute(args) {
|
|
276
|
+
const url = String(args.url ?? '').trim();
|
|
277
|
+
if (!/^https?:\/\//i.test(url))
|
|
278
|
+
return { output: 'only http(s) URLs are supported', isError: true };
|
|
279
|
+
const max = Math.min(Math.max(Number(args.maxChars ?? 12_000), 500), 40_000);
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch(url, {
|
|
282
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', Accept: 'text/html,application/json,text/plain,*/*' },
|
|
283
|
+
signal: AbortSignal.timeout(20_000),
|
|
284
|
+
redirect: 'follow',
|
|
285
|
+
});
|
|
286
|
+
if (!res.ok)
|
|
287
|
+
return { output: 'fetch: HTTP ' + res.status, isError: true };
|
|
288
|
+
const ct = String(res.headers.get('content-type') ?? '');
|
|
289
|
+
const body = await res.text();
|
|
290
|
+
if (ct.includes('json'))
|
|
291
|
+
return { output: body.slice(0, max) };
|
|
292
|
+
let text = body
|
|
293
|
+
.replace(/[\s\S]*?<body[^>]*>/i, '')
|
|
294
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
295
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
296
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
297
|
+
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, '\n')
|
|
298
|
+
.replace(/<[^>]+>/g, ' ');
|
|
299
|
+
const ents = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", ' ': ' ' };
|
|
300
|
+
text = text.replace(/&(amp|lt|gt|quot|#x27|#39|nbsp);/g, (m) => ents[m] ?? m);
|
|
301
|
+
text = text.replace(/\n{3,}/g, '\n\n').replace(/ {2,}/g, ' ').trim();
|
|
302
|
+
return { output: text.slice(0, max) + (text.length > max ? '\n...[truncated at ' + max + ' chars]' : '') };
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return { output: 'fetch failed: ' + String(err).slice(0, 160), isError: true };
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
/** Desktop automation, step 1 (first-class): screenshot the primary display
|
|
310
|
+
* to a PNG and hand the path back - the agent then reads it with see_image
|
|
311
|
+
* (vision chain). Windows via PowerShell System.Drawing; other platforms
|
|
312
|
+
* report the gap honestly instead of failing silently. */
|
|
313
|
+
export const desktopScreenshotTool = {
|
|
314
|
+
name: 'desktop_screenshot',
|
|
315
|
+
description: 'Screenshot the primary display to a PNG file and return its path. Immediately follow with see_image on that path to actually look at it. Use for: checking a GUI app state, reading dialogs/errors, verifying what a desktop automation step did.',
|
|
316
|
+
parameters: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {},
|
|
319
|
+
required: [],
|
|
320
|
+
},
|
|
321
|
+
needsApproval: () => true,
|
|
322
|
+
async execute(_args, ctx) {
|
|
323
|
+
if (process.platform !== 'win32') {
|
|
324
|
+
return { output: 'desktop_screenshot currently supports Windows only (PowerShell + System.Drawing)', isError: true };
|
|
325
|
+
}
|
|
326
|
+
const out = join(ctx.home, 'tmp', 'shot-' + Date.now() + '.png');
|
|
327
|
+
await import('node:fs/promises').then((f) => f.mkdir(join(ctx.home, 'tmp'), { recursive: true }));
|
|
328
|
+
const ps = [
|
|
329
|
+
'Add-Type -AssemblyName System.Windows.Forms',
|
|
330
|
+
'Add-Type -AssemblyName System.Drawing',
|
|
331
|
+
'$b = New-Object System.Drawing.Bitmap([System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width, [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height)',
|
|
332
|
+
'$g = [System.Drawing.Graphics]::FromImage($b)',
|
|
333
|
+
'$g.CopyFromScreen(0, 0, 0, 0, $b.Size)',
|
|
334
|
+
"$b.Save('OUTPATH', [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
335
|
+
'$g.Dispose(); $b.Dispose()',
|
|
336
|
+
].join('; ').replace(/OUTPATH/g, out.replace(/\\/g, '\\\\').replace(/'/g, "''"));
|
|
337
|
+
try {
|
|
338
|
+
const { execFile } = await import('node:child_process');
|
|
339
|
+
const { promisify: prom } = await import('node:util');
|
|
340
|
+
await prom(execFile)('powershell.exe', ['-NoProfile', '-Command', ps], { timeout: 20_000, windowsHide: true });
|
|
341
|
+
return { output: 'screenshot saved: ' + out + ' - now call see_image with this path' };
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
return { output: 'screenshot failed: ' + String(err).slice(0, 200), isError: true };
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
/** Desktop automation steps 2-3: click and type. PowerShell + Win32 for
|
|
349
|
+
* mouse (SetCursorPos + mouse_event), SendKeys for keyboard. Pair with
|
|
350
|
+
* desktop_screenshot + see_image: look → act → verify. */
|
|
351
|
+
export const desktopClickTool = {
|
|
352
|
+
name: 'desktop_click',
|
|
353
|
+
description: 'Click the mouse at screen coordinates (left click). ALWAYS desktop_screenshot + see_image FIRST to find the right coordinates, then click, then screenshot again to verify. Coordinates are pixels from top-left of the primary display.',
|
|
354
|
+
parameters: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
x: { type: 'number', description: 'X pixel coordinate' },
|
|
358
|
+
y: { type: 'number', description: 'Y pixel coordinate' },
|
|
359
|
+
},
|
|
360
|
+
required: ['x', 'y'],
|
|
361
|
+
},
|
|
362
|
+
needsApproval: () => true,
|
|
363
|
+
async execute(args) {
|
|
364
|
+
if (process.platform !== 'win32')
|
|
365
|
+
return { output: 'desktop_click supports Windows only', isError: true };
|
|
366
|
+
const x = Math.round(Number(args.x));
|
|
367
|
+
const y = Math.round(Number(args.y));
|
|
368
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
369
|
+
return { output: 'x/y must be numbers', isError: true };
|
|
370
|
+
const ps = [
|
|
371
|
+
// no here-strings (they break in -Command inline mode); use a single C# line
|
|
372
|
+
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int x, int y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint f, uint dx, uint dy, uint d, UIntPtr e);' -Name Win32M -Namespace W",
|
|
373
|
+
'[W.Win32M]::SetCursorPos(XVAL, YVAL)',
|
|
374
|
+
'Start-Sleep -Milliseconds 80',
|
|
375
|
+
'[W.Win32M]::mouse_event(2, 0, 0, 0, [UIntPtr]::Zero)',
|
|
376
|
+
'Start-Sleep -Milliseconds 30',
|
|
377
|
+
'[W.Win32M]::mouse_event(4, 0, 0, 0, [UIntPtr]::Zero)',
|
|
378
|
+
].join('; ').replace(/XVAL/g, String(x)).replace(/YVAL/g, String(y));
|
|
379
|
+
try {
|
|
380
|
+
const { execFile } = await import('node:child_process');
|
|
381
|
+
const { promisify: prom } = await import('node:util');
|
|
382
|
+
const r = await prom(execFile)('powershell.exe', ['-NoProfile', '-Command', ps], { timeout: 10_000, windowsHide: true });
|
|
383
|
+
return { output: 'clicked at ' + x + ',' + y + ' - now screenshot to verify' };
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
return { output: 'click failed: ' + String(err).slice(0, 200), isError: true };
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
export const desktopTypeTool = {
|
|
391
|
+
name: 'desktop_type',
|
|
392
|
+
description: 'Type text (or a key combo like ENTER, TAB, ^a, {F5}) into the focused window. Use after desktop_click to focus a field. Standard SendKeys syntax.',
|
|
393
|
+
parameters: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
properties: {
|
|
396
|
+
text: { type: 'string', description: 'text to type, or SendKeys combo (ENTER, TAB, ESC, ^a, +{TAB}, {DOWN})' },
|
|
397
|
+
},
|
|
398
|
+
required: ['text'],
|
|
399
|
+
},
|
|
400
|
+
needsApproval: () => true,
|
|
401
|
+
async execute(args) {
|
|
402
|
+
if (process.platform !== 'win32')
|
|
403
|
+
return { output: 'desktop_type supports Windows only', isError: true };
|
|
404
|
+
const text = String(args.text ?? '');
|
|
405
|
+
if (!text)
|
|
406
|
+
return { output: 'text required', isError: true };
|
|
407
|
+
const safe = text.replace(/'/g, "''").replace(/"/g, String.fromCharCode(34));
|
|
408
|
+
const ps = [
|
|
409
|
+
'$ws = New-Object -ComObject WScript.Shell',
|
|
410
|
+
'Start-Sleep -Milliseconds 50',
|
|
411
|
+
"$ws.SendKeys('TEXTVAL')",
|
|
412
|
+
'"typed"',
|
|
413
|
+
].join('; ').replace(/TEXTVAL/g, safe);
|
|
414
|
+
try {
|
|
415
|
+
const { execFile } = await import('node:child_process');
|
|
416
|
+
const { promisify: prom } = await import('node:util');
|
|
417
|
+
await prom(execFile)('powershell.exe', ['-NoProfile', '-Command', ps], { timeout: 10_000, windowsHide: true });
|
|
418
|
+
return { output: 'typed: ' + text.slice(0, 40) };
|
|
419
|
+
}
|
|
420
|
+
catch (err) {
|
|
421
|
+
return { output: 'type failed: ' + String(err).slice(0, 200), isError: true };
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
/** Browser automation, first-class entry point: open a URL in the user's
|
|
426
|
+
* default browser (visible window), then drive it with the desktop triad:
|
|
427
|
+
* desktop_screenshot + see_image to LOOK, desktop_click/desktop_type to
|
|
428
|
+
* ACT, desktop_screenshot again to VERIFY. This sees-plan-act loop works
|
|
429
|
+
* with any browser and any page (full JS rendering, login states, CAPTCHAs
|
|
430
|
+
* - everything a real user sees). Headless alternatives (--dump-dom etc)
|
|
431
|
+
* are unreliable on Windows; the visible browser is the honest primitive.
|
|
432
|
+
*/
|
|
433
|
+
export const browserOpenTool = {
|
|
434
|
+
name: 'browser_open',
|
|
435
|
+
description: 'Open a URL in the user\'s default browser (visible). Then use desktop_screenshot + see_image to see the page, desktop_click/desktop_type to interact. Full workflow: browser_open → desktop_screenshot → see_image (find elements) → desktop_click (click) → desktop_screenshot (verify).',
|
|
436
|
+
parameters: {
|
|
437
|
+
type: 'object',
|
|
438
|
+
properties: {
|
|
439
|
+
url: { type: 'string', description: 'http(s) URL to open' },
|
|
440
|
+
},
|
|
441
|
+
required: ['url'],
|
|
442
|
+
},
|
|
443
|
+
needsApproval: () => true,
|
|
444
|
+
async execute(args) {
|
|
445
|
+
const url = String(args.url ?? '').trim();
|
|
446
|
+
if (!/^https?:\/\//i.test(url))
|
|
447
|
+
return { output: 'only http(s) URLs', isError: true };
|
|
448
|
+
try {
|
|
449
|
+
if (process.platform === 'win32') {
|
|
450
|
+
const { execFile } = await import('node:child_process');
|
|
451
|
+
const { promisify: prom } = await import('node:util');
|
|
452
|
+
await prom(execFile)('cmd.exe', ['/c', 'start', '', url], { timeout: 8000, windowsHide: true });
|
|
453
|
+
}
|
|
454
|
+
else if (process.platform === 'darwin') {
|
|
455
|
+
const { execFile } = await import('node:child_process');
|
|
456
|
+
const { promisify: prom } = await import('node:util');
|
|
457
|
+
await prom(execFile)('open', [url], { timeout: 8000 });
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
const { execFile } = await import('node:child_process');
|
|
461
|
+
const { promisify: prom } = await import('node:util');
|
|
462
|
+
await prom(execFile)('xdg-open', [url], { timeout: 8000 });
|
|
463
|
+
}
|
|
464
|
+
return { output: 'opened ' + url + ' in the default browser. Wait ~2s for load, then desktop_screenshot + see_image to see the page.' };
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return { output: 'open failed: ' + String(err).slice(0, 160), isError: true };
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
export const sshRunTool = {
|
|
472
|
+
name: 'ssh_run',
|
|
473
|
+
description: 'Run a command on a remote host over SSH. Hosts must be configured under sshHosts in config.json (name/host/user/port/keyPath). Read-only probes (ls/cat/ps/grep/free/df/uptime/systemctl status) run without the approval card; anything else is approval-gated. Use for server ops, remote checks, deployments.',
|
|
474
|
+
parameters: {
|
|
475
|
+
type: 'object',
|
|
476
|
+
properties: {
|
|
477
|
+
host: { type: 'string', description: 'host name from config sshHosts' },
|
|
478
|
+
command: { type: 'string', description: 'shell command to run remotely' },
|
|
479
|
+
timeout_ms: { type: 'number', description: 'timeout in ms (default 20000, max 120000)' },
|
|
480
|
+
},
|
|
481
|
+
required: ['host', 'command'],
|
|
482
|
+
},
|
|
483
|
+
needsApproval(args) {
|
|
484
|
+
const cmd = String(args.command ?? '');
|
|
485
|
+
// read-only probe allowlist - no destructive verbs, no pipes into writes
|
|
486
|
+
const probe = /^(ls|cat|head|tail|df|du|free|uptime|whoami|hostname|uname|systemctl (status|list-units)|ps|grep|find|wc|date|echo|id|ip |ifconfig|nmap --version)/.test(cmd);
|
|
487
|
+
const writes = /rm|mv|dd|mkfs|reboot|shutdown|kill|pkill|systemctl (start|stop|restart|enable|disable)|apt|yum|docker (rm|rmi|prune)|truncate|>||/i;
|
|
488
|
+
return !(probe && !writes);
|
|
489
|
+
},
|
|
490
|
+
async execute(args) {
|
|
491
|
+
const cfg = await loadConfig();
|
|
492
|
+
const hosts = cfg.sshHosts ?? {};
|
|
493
|
+
const h = hosts[String(args.host ?? '')];
|
|
494
|
+
if (!h)
|
|
495
|
+
return { output: 'unknown host. configured: ' + (Object.keys(hosts).join(', ') || '(none)'), isError: true };
|
|
496
|
+
const command = String(args.command ?? '');
|
|
497
|
+
if (!command.trim())
|
|
498
|
+
return { output: 'command required', isError: true };
|
|
499
|
+
const timeout = Math.min(Number(args.timeout_ms ?? 20_000), 120_000);
|
|
500
|
+
const sshArgs = [
|
|
501
|
+
'-o', 'BatchMode=yes',
|
|
502
|
+
'-o', 'ConnectTimeout=8',
|
|
503
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
504
|
+
...(h.keyPath ? ['-i', h.keyPath] : []),
|
|
505
|
+
'-p', String(h.port ?? 22),
|
|
506
|
+
(h.user ? h.user + '@' : '') + h.host,
|
|
507
|
+
command,
|
|
508
|
+
];
|
|
509
|
+
try {
|
|
510
|
+
const { execFile } = await import('node:child_process');
|
|
511
|
+
const { promisify: prom } = await import('node:util');
|
|
512
|
+
const r = await prom(execFile)('ssh', sshArgs, { timeout, windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
|
|
513
|
+
const out = ((r.stdout || '') + (r.stderr ? '\n[stderr]\n' + r.stderr : '')).trim();
|
|
514
|
+
return { output: (out || '(no output)').slice(0, 30_000) };
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
const e = err;
|
|
518
|
+
const parts = [e.stdout, e.stderr, e.killed ? '(timed out)' : e.message];
|
|
519
|
+
return { output: parts.filter(Boolean).join('\n').slice(0, 400), isError: true };
|
|
520
|
+
}
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
export const rememberTool = {
|
|
524
|
+
name: 'remember',
|
|
525
|
+
description: 'Persist a note to long-term memory (survives restarts, loaded into future sessions). Use for user preferences, project facts, and hard-won lessons - not transient details.',
|
|
526
|
+
parameters: {
|
|
527
|
+
type: 'object',
|
|
528
|
+
properties: { note: { type: 'string', description: 'the fact/lesson to remember, one line preferred' } },
|
|
529
|
+
required: ['note'],
|
|
530
|
+
},
|
|
531
|
+
async execute(args) {
|
|
532
|
+
try {
|
|
533
|
+
const { appendMemory } = await import('@hmharness/evolution');
|
|
534
|
+
await appendMemory(homeDir(), String(args.note ?? ''));
|
|
535
|
+
return { output: 'remembered.' };
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
return { output: String(err), isError: true };
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
export const seeImageTool = {
|
|
543
|
+
name: 'see_image',
|
|
544
|
+
description: 'Look at an image file (png/jpg/webp) with the configured vision model and answer a question about it, or describe it. Use for UI screenshots, rendered pages, photos of device screens - anything visual. Requires a vision provider in config.',
|
|
545
|
+
parameters: {
|
|
546
|
+
type: 'object',
|
|
547
|
+
properties: {
|
|
548
|
+
path: { type: 'string', description: 'image file path (absolute or relative to cwd)' },
|
|
549
|
+
question: { type: 'string', description: 'what to answer about the image (default: describe it)' },
|
|
550
|
+
},
|
|
551
|
+
required: ['path'],
|
|
552
|
+
},
|
|
553
|
+
async execute(args, ctx) {
|
|
554
|
+
const cfg = await loadConfig();
|
|
555
|
+
const hasVision = Boolean(cfg.vision || (cfg.routing?.vision && cfg.providers?.[cfg.routing.vision]));
|
|
556
|
+
if (!hasVision) {
|
|
557
|
+
return {
|
|
558
|
+
output: 'No vision provider configured. Add a "vision" block or providers+routing.vision to HMH_HOME/config.json.',
|
|
559
|
+
isError: true,
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
const p = safePath(String(args.path ?? ''), ctx.cwd);
|
|
563
|
+
let data;
|
|
564
|
+
try {
|
|
565
|
+
data = await readFile(p);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
return { output: String(err), isError: true };
|
|
569
|
+
}
|
|
570
|
+
if (data.length > 6 * 1024 * 1024) {
|
|
571
|
+
return { output: `Image too large (${(data.length / 1024 / 1024).toFixed(1)} MB, max 6 MB).`, isError: true };
|
|
572
|
+
}
|
|
573
|
+
const mime = /\.(jpg|jpeg)$/i.test(p) ? 'image/jpeg' : /\.webp$/i.test(p) ? 'image/webp' : 'image/png';
|
|
574
|
+
const url = `data:${mime};base64,${data.toString('base64')}`;
|
|
575
|
+
const question = String(args.question ?? 'Describe this image precisely and concisely.');
|
|
576
|
+
const chain = [resolveProvider(cfg, 'vision'), ...(cfg.visionFallbacks ?? [])].filter((x) => x.baseUrl);
|
|
577
|
+
const errors = [];
|
|
578
|
+
for (const provider of chain) {
|
|
579
|
+
try {
|
|
580
|
+
const answer = await chatVision(provider, question, url);
|
|
581
|
+
return { output: `[${p}]\n${answer}` };
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
errors.push(`${provider.model ?? '?'}: ${String(err).slice(0, 120)}`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return { output: `all vision providers failed:\n${errors.join('\n')}`, isError: true };
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
export const baseTools = [readFileTool, writeFileTool, listDirTool, runCommandTool, rememberTool, seeImageTool];
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hmharness/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "hmharness agent execution layer: base tools, system prompt, sub-agent spawn, and the shared task runner that frontends (cli, web) drive.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.build.json"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@hmharness/kernel": "0.1.0",
|
|
19
|
+
"@hmharness/evolution": "0.1.0",
|
|
20
|
+
"@hmharness/domain-harmony": "0.1.0",
|
|
21
|
+
"@hmharness/domain-ops": "0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist"
|
|
25
|
+
],
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/swsgbl/hmharness.git"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
}
|
|
34
|
+
}
|