@hmharness/cli 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/main.d.ts +2 -0
- package/dist/main.js +641 -0
- package/dist/prompt.d.ts +7 -0
- package/dist/prompt.js +14 -0
- package/dist/runner.d.ts +68 -0
- package/dist/runner.js +179 -0
- package/dist/spawn.d.ts +29 -0
- package/dist/spawn.js +64 -0
- package/dist/tools.d.ts +7 -0
- package/dist/tools.js +137 -0
- package/dist/tui.d.ts +110 -0
- package/dist/tui.js +1085 -0
- package/dist/web-daemon.d.ts +15 -0
- package/dist/web-daemon.js +106 -0
- package/package.json +53 -0
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @hmharness/cli - main (terminal frontend)
|
|
4
|
+
* Usage:
|
|
5
|
+
* hmh init create HMH_HOME skeleton + config
|
|
6
|
+
* hmh "do something" one-shot task (full agent loop, streaming)
|
|
7
|
+
* hmh interactive REPL (conversation memory kept)
|
|
8
|
+
* hmh resume [id-prefix] continue a past session by id prefix (or latest)
|
|
9
|
+
* hmh web [--port=7788] local web frontend (SSE streaming + approvals)
|
|
10
|
+
* hmh tui lite terminal UI (status header + slash commands)
|
|
11
|
+
* hmh ops [scan|brief|status] ops keeper: ecosystem radar
|
|
12
|
+
* hmh devices|check direct tool run, no model
|
|
13
|
+
* hmh tools list all registered tools (native + MCP)
|
|
14
|
+
* hmh mcp show configured MCP servers and their tools
|
|
15
|
+
* hmh evolve [--every=N] self-evolution cycle (or resident loop)
|
|
16
|
+
* hmh bench run the evolution bench
|
|
17
|
+
* hmh skills [--promote|--rollback|--unpromote <name>]
|
|
18
|
+
hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
|
|
19
|
+
* Flags: --yes / -y / --yolo auto-approve gated tools (Claude-Code-style alias;
|
|
20
|
+
* --locale=zh|en override the UI locale for this run.
|
|
21
|
+
*/
|
|
22
|
+
import readline from 'node:readline/promises';
|
|
23
|
+
import { stdin, stdout } from 'node:process';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { stopWebDaemon, startWebDaemon, hmhWebUp, readWebPid } from "./web-daemon.js";
|
|
26
|
+
import { chat, homeDir, resolveProvider, initHome, latestSession, listProviders, loadConfig, loadTranscript, mcpServerTools, Registry, runLoop, setChatRoute, setLocale, } from '@hmharness/kernel';
|
|
27
|
+
import { listSkills, listDrafts, promoteSkill, runBench, runEvolution, rollbackSkill, unpromoteSkill, } from '@hmharness/evolution';
|
|
28
|
+
import { harmonyTools } from '@hmharness/domain-harmony';
|
|
29
|
+
import { baseTools, buildRegistry, buildSystemPrompt, runAgentTask, strings } from '@hmharness/agent';
|
|
30
|
+
const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
31
|
+
const CYAN = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
32
|
+
const YELLOW = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
33
|
+
const GREEN = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
34
|
+
async function uiStrings() {
|
|
35
|
+
const cfg = await loadConfig();
|
|
36
|
+
return strings((cfg.locale ?? 'zh'));
|
|
37
|
+
}
|
|
38
|
+
async function runTask(task, taskOpts = {}) {
|
|
39
|
+
const cfg = await loadConfig();
|
|
40
|
+
const { reg, clients } = taskOpts.registry
|
|
41
|
+
? { reg: taskOpts.registry, clients: taskOpts.clients ?? [] }
|
|
42
|
+
: await buildRegistry({ announce: false });
|
|
43
|
+
void clients;
|
|
44
|
+
// Live output state: reasoning arrives dimmed and prefixed, final text plain.
|
|
45
|
+
const zt = strings((cfg.locale ?? 'zh'));
|
|
46
|
+
let displayMode = 'none';
|
|
47
|
+
let streamedText = false;
|
|
48
|
+
const openMode = (m) => {
|
|
49
|
+
if (displayMode !== m) {
|
|
50
|
+
if (displayMode === 'reasoning')
|
|
51
|
+
stdout.write('\n');
|
|
52
|
+
if (m === 'reasoning')
|
|
53
|
+
stdout.write(DIM('\n' + zt.thinkingLabel));
|
|
54
|
+
displayMode = m;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const result = await runAgentTask({
|
|
58
|
+
task,
|
|
59
|
+
registry: reg,
|
|
60
|
+
cfg,
|
|
61
|
+
yes: taskOpts.yes,
|
|
62
|
+
resumeMessages: taskOpts.resumeMessages,
|
|
63
|
+
events: {
|
|
64
|
+
onLine: (l) => stdout.write(DIM(` ${l}\n`)),
|
|
65
|
+
onDelta: (kind, chunk) => {
|
|
66
|
+
if (kind === 'reasoning') {
|
|
67
|
+
openMode('reasoning');
|
|
68
|
+
stdout.write(DIM(chunk));
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
openMode('text');
|
|
72
|
+
streamedText = true;
|
|
73
|
+
stdout.write(chunk);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
onToolCall: (name, args) => {
|
|
77
|
+
if (displayMode !== 'none') {
|
|
78
|
+
stdout.write('\n');
|
|
79
|
+
displayMode = 'none';
|
|
80
|
+
}
|
|
81
|
+
stdout.write(DIM(` [tool] ${name} ${JSON.stringify(args).slice(0, 100)}\n`));
|
|
82
|
+
},
|
|
83
|
+
onToolResult: (name, output, isError) => {
|
|
84
|
+
if (isError)
|
|
85
|
+
stdout.write(DIM(` [${name} ERROR] ${output.slice(0, 160)}\n`));
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
stdout.write(streamedText ? '\n\n' : '\n' + result.text + '\n\n');
|
|
90
|
+
stdout.write(DIM(`(session ${result.sessionId} · ${result.turns} turns · ${result.toolUses} tool uses)\n`));
|
|
91
|
+
// working transcript minus the system prompt and the task line we appended
|
|
92
|
+
return { messages: result.messages, sessionId: result.sessionId };
|
|
93
|
+
}
|
|
94
|
+
async function repl(yes, initialHistory) {
|
|
95
|
+
const home = homeDir();
|
|
96
|
+
let cfg = await loadConfig();
|
|
97
|
+
let autoApprove = yes;
|
|
98
|
+
let t = strings((cfg.locale ?? 'zh'));
|
|
99
|
+
const header = () => stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`));
|
|
100
|
+
stdout.write(CYAN('hmh') + DIM(` · ${cfg.provider.model} · ${home}\n`) + DIM(`${t.replHint} · /help ${String(t.cmdHelp)}\n\n`));
|
|
101
|
+
const { reg, clients } = await buildRegistry();
|
|
102
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
103
|
+
// stdin EOF (piped input, closed terminal) must exit the loop - a bare
|
|
104
|
+
// rl.question() promise never settles after close, which would hang
|
|
105
|
+
const closed = new Promise((_, reject) => rl.on('close', () => reject(new Error('stdin closed'))));
|
|
106
|
+
// The REPL keeps conversation memory across its own lines (and any
|
|
107
|
+
// resumed history); each line re-injects fresh memory/skills.
|
|
108
|
+
let history = initialHistory ? [...initialHistory] : [];
|
|
109
|
+
try {
|
|
110
|
+
while (true) {
|
|
111
|
+
let line;
|
|
112
|
+
try {
|
|
113
|
+
line = await Promise.race([rl.question(CYAN('hmh> ')), closed]);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
line = line.trim();
|
|
119
|
+
if (!line)
|
|
120
|
+
continue;
|
|
121
|
+
if (line === '/exit' || line === '/quit')
|
|
122
|
+
break;
|
|
123
|
+
if (line.startsWith('/')) {
|
|
124
|
+
// same command set as the TUI palette, line-mode
|
|
125
|
+
if (line === '/yolo' || line === '/yolo on' || line === '/yolo off') {
|
|
126
|
+
const turnOn = line === '/yolo' ? !autoApprove : line === '/yolo on';
|
|
127
|
+
autoApprove = turnOn;
|
|
128
|
+
stdout.write((turnOn ? YELLOW(t.yoloOn) : DIM(t.yoloOff)) + '\n');
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (line === '/lang' || line.startsWith('/lang ')) {
|
|
132
|
+
const { nextLocale } = await import("./tui.js");
|
|
133
|
+
const target = nextLocale(cfg.locale ?? 'zh', line.slice(5));
|
|
134
|
+
cfg = await setLocale(target);
|
|
135
|
+
t = strings(target);
|
|
136
|
+
stdout.write(GREEN('✓') + ' ' + t.langSwitched(target) + '\n');
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (line === '/help' || line === '?') {
|
|
140
|
+
const { COMMANDS } = await import("./tui.js");
|
|
141
|
+
stdout.write(COMMANDS.map((c) => ' ' + c.name.padEnd(11) + ' ' + String(t[c.key])).join('\n') + '\n');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (line === '/model' || line.startsWith('/model ')) {
|
|
145
|
+
const mArg = line.slice(7).trim();
|
|
146
|
+
if (!mArg) {
|
|
147
|
+
// line-mode REPL has no live palette: the list IS the menu,
|
|
148
|
+
// the hint tells how to act on it (i18n, was hardcoded zh)
|
|
149
|
+
const rows = listProviders(cfg).map((v) => ` ${v.purposes.includes('chat') ? GREEN('●') : DIM('○')} ${v.name} — ${v.model}${v.purposes.length ? DIM(` (${v.purposes.join('/')})`) : ''}`);
|
|
150
|
+
stdout.write(rows.join('\n') + '\n' + DIM(t.cmdModelHint) + '\n');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
cfg = await setChatRoute(mArg);
|
|
155
|
+
stdout.write(GREEN('✓') + ` chat → ${mArg} · ${resolveProvider(cfg, 'chat').model}\n`);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
stdout.write(YELLOW(`${String(err)}\n`));
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (line === '/tools') {
|
|
163
|
+
for (const tool of reg.list())
|
|
164
|
+
printTool(tool);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (line === '/skills') {
|
|
168
|
+
const active = await listSkills(home);
|
|
169
|
+
const drafts = await listDrafts(home);
|
|
170
|
+
stdout.write(CYAN(`${t.active} (${active.length})\n`));
|
|
171
|
+
stdout.write(active.length ? active.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : DIM(` ${t.none}\n`));
|
|
172
|
+
stdout.write(CYAN(`${t.drafts} (${drafts.length})\n`));
|
|
173
|
+
stdout.write(drafts.length ? drafts.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : DIM(` ${t.none}\n`));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (line === '/mcp') {
|
|
177
|
+
for (const [name, c] of Object.entries(cfg.mcpServers ?? {})) {
|
|
178
|
+
stdout.write(` ${name} — ${c.type}${c.trusted ? ' · trusted' : ' · gated'}\n`);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (line === '/providers' || line === '/providers scan') {
|
|
183
|
+
// same capability as the TUI: probe local keys, optionally write
|
|
184
|
+
// them in (was REPL-missing -> 'unknown command' while /help
|
|
185
|
+
// advertised it, a scan-D gap)
|
|
186
|
+
const { readFile } = await import('node:fs/promises');
|
|
187
|
+
const { detectLocalProviders, addProviders } = await import('@hmharness/kernel');
|
|
188
|
+
const found = await detectLocalProviders(cfg, readFile);
|
|
189
|
+
if (line === '/providers') {
|
|
190
|
+
stdout.write(found.length
|
|
191
|
+
? found.map((p) => ` ${YELLOW('+')} ${p.name} — ${p.model} (${p.envVar})`).join('\n') + '\n' + DIM(t.cmdProvidersScanHint) + '\n'
|
|
192
|
+
: DIM(t.cmdProvidersListed) + '\n');
|
|
193
|
+
}
|
|
194
|
+
else if (!found.length) {
|
|
195
|
+
stdout.write(DIM(t.cmdProvidersNone) + Object.keys(cfg.providers ?? {}).join(', ') + '\n');
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
const r = await addProviders(found.map((p) => ({ name: p.name, baseUrl: p.baseUrl, model: p.model })));
|
|
199
|
+
cfg = r.cfg;
|
|
200
|
+
stdout.write(GREEN('✓') + ' ' + t.cmdProvidersAdded(r.added.length, r.added.join(', ')) + '\n');
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (line === '/clear') {
|
|
205
|
+
// line-mode twin of the TUI /clear: clear the conversation so the
|
|
206
|
+
// next task starts fresh (REPL counterpart was missing)
|
|
207
|
+
history = [];
|
|
208
|
+
stdout.write(DIM(t.cmdClearDone) + '\n');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (line === '/status') {
|
|
212
|
+
header();
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (line === '/web') {
|
|
216
|
+
stdout.write(DIM(t.tuiWebHint + '\n'));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (line === '/ops' || line === '/ops scan') {
|
|
220
|
+
const { harmonyOpsStatus, harmonyOpsRadarScan } = await import('@hmharness/domain-ops');
|
|
221
|
+
const r = line === '/ops'
|
|
222
|
+
? await harmonyOpsStatus.execute({}, { cwd: process.cwd(), home })
|
|
223
|
+
: await harmonyOpsRadarScan.execute({}, { cwd: process.cwd(), home });
|
|
224
|
+
stdout.write(r.output + '\n');
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (line === '/bench') {
|
|
228
|
+
const { results, passRate } = await runBench(home, (c) => makeCaseRunner()(c, ''));
|
|
229
|
+
for (const r of results)
|
|
230
|
+
stdout.write(`${r.pass ? GREEN(t.pass) : YELLOW(t.fail)} ${r.name} — ${r.detail}\n`);
|
|
231
|
+
stdout.write(`pass rate: ${(passRate * 100).toFixed(0)}%\n`);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (line === '/evolve') {
|
|
235
|
+
const report = await runEvolution({
|
|
236
|
+
home,
|
|
237
|
+
provider: resolveProvider(cfg, 'evolve'),
|
|
238
|
+
runCase: makeCaseRunner(),
|
|
239
|
+
log: (l) => stdout.write(DIM(` ${l}\n`)),
|
|
240
|
+
});
|
|
241
|
+
stdout.write(t.tuiEvolveDone(report.proposals.length, report.insightCount, report.noteCount) + '\n');
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
stdout.write(YELLOW(t.unknownCommand(line) + '\n'));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const r = await runTask(line, { yes: autoApprove, sharedRl: rl, registry: reg, clients, resumeMessages: history });
|
|
249
|
+
// working transcript = [system, ...resumeMessages, user, ...new turns];
|
|
250
|
+
// only the NEW turns (past the replayed prefix) extend history.
|
|
251
|
+
history = [...history, { role: 'user', content: line }, ...r.messages.slice(history.length + 2)];
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
stdout.write(`error: ${String(err)}\n`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
rl.close();
|
|
260
|
+
for (const c of clients)
|
|
261
|
+
c.close();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Bench case runner shared by `hmh bench` and `hmh evolve`. Tool cases run
|
|
266
|
+
* through the real loop (native tools only - no MCP, no approvals: gated
|
|
267
|
+
* tools deny safely, keeping runs deterministic and side-effect free).
|
|
268
|
+
*/
|
|
269
|
+
function makeCaseRunner() {
|
|
270
|
+
return async (c, skillsPrompt) => {
|
|
271
|
+
const cfg = await loadConfig();
|
|
272
|
+
if (!c.tools) {
|
|
273
|
+
const r = await chat(resolveProvider(cfg, 'bench'), [{ role: 'user', content: c.prompt }]);
|
|
274
|
+
return r.message.content ?? '';
|
|
275
|
+
}
|
|
276
|
+
const reg = new Registry();
|
|
277
|
+
reg.registerAll(baseTools).registerAll(harmonyTools);
|
|
278
|
+
const system = buildSystemPrompt({
|
|
279
|
+
cwd: process.cwd(),
|
|
280
|
+
home: homeDir(),
|
|
281
|
+
memory: '',
|
|
282
|
+
skills: skillsPrompt,
|
|
283
|
+
insights: '',
|
|
284
|
+
model: cfg.provider.model,
|
|
285
|
+
});
|
|
286
|
+
const res = await runLoop({
|
|
287
|
+
provider: resolveProvider(cfg, 'bench'),
|
|
288
|
+
registry: reg,
|
|
289
|
+
messages: [
|
|
290
|
+
{ role: 'system', content: system },
|
|
291
|
+
{ role: 'user', content: c.prompt },
|
|
292
|
+
],
|
|
293
|
+
ctx: { cwd: process.cwd(), home: homeDir() },
|
|
294
|
+
maxTurns: 6,
|
|
295
|
+
});
|
|
296
|
+
return res.text;
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function printTool(t) {
|
|
300
|
+
stdout.write(` ${t.name}${t.needsApproval ? YELLOW(' [gated]') : ''} — ${t.description.split('\n')[0].slice(0, 100)}\n`);
|
|
301
|
+
}
|
|
302
|
+
async function main() {
|
|
303
|
+
const rawArgs = process.argv.slice(2);
|
|
304
|
+
// --yolo is the Claude-Code-style alias of --yes (auto-approve gates;
|
|
305
|
+
// the kernel's destructive-command hard-deny always stays on)
|
|
306
|
+
const yes = rawArgs.some((a) => a === '--yes' || a === '-y' || a === '--yolo');
|
|
307
|
+
// --locale=zh|en overrides the configured locale for this run (kernel's
|
|
308
|
+
// loadConfig honours HMH_LOCALE), so every command - task, REPL, TUI, web -
|
|
309
|
+
// picks it up without touching config.json
|
|
310
|
+
const localeArg = rawArgs.find((a) => a.startsWith('--locale=') && a.length > 9);
|
|
311
|
+
if (localeArg === '--locale=zh' || localeArg === '--locale=en')
|
|
312
|
+
process.env.HMH_LOCALE = localeArg.slice(9);
|
|
313
|
+
const args = rawArgs.filter((a) => a !== '--yes' && a !== '-y' && a !== '--yolo' && !a.startsWith('--locale='));
|
|
314
|
+
const [cmd, ...rest] = args;
|
|
315
|
+
const arg = rest.join(' ');
|
|
316
|
+
if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
317
|
+
stdout.write(`hmh - self-evolving agent harness for HarmonyOS development
|
|
318
|
+
|
|
319
|
+
usage:
|
|
320
|
+
hmh "do something" one-shot task (full agent loop, streaming)
|
|
321
|
+
hmh interactive REPL (conversation memory kept, /help for commands)
|
|
322
|
+
hmh resume [id-prefix] continue a past session by id prefix (or latest)
|
|
323
|
+
hmh web start|stop|status web UI as a silent background daemon (no window,
|
|
324
|
+
survives closing everything; log ~/.hmharness/web.log)
|
|
325
|
+
hmh web [--port=7788] web UI in the foreground (debugging)
|
|
326
|
+
hmh tui [--no-web] fullscreen terminal UI (slash palette, mouse wheel);
|
|
327
|
+
also starts the web UI in the background (--no-web skips)
|
|
328
|
+
hmh ops [scan|brief|status] ops keeper: ecosystem radar
|
|
329
|
+
hmh devices|check direct tool run, no model
|
|
330
|
+
hmh tools list all registered tools (native + MCP)
|
|
331
|
+
hmh mcp show configured MCP servers and their tools
|
|
332
|
+
hmh evolve [--every=N] self-evolution cycle (or resident loop)
|
|
333
|
+
hmh bench run the evolution bench
|
|
334
|
+
hmh skills [--promote|--rollback|--unpromote <name>]
|
|
335
|
+
hmh skills add <git-url-or-local-dir> install skills (multi-skill packs supported)
|
|
336
|
+
|
|
337
|
+
flags:
|
|
338
|
+
--yes / -y auto-approve gated tools (else they prompt; non-TTY denies)
|
|
339
|
+
--locale=zh|en override the UI locale for this run
|
|
340
|
+
--help | -h this help
|
|
341
|
+
`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (cmd === 'init') {
|
|
345
|
+
const { home, created } = await initHome();
|
|
346
|
+
stdout.write(`home: ${home}\n${created.length ? 'created: ' + created.join(', ') : 'already initialized.'}\n`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (cmd === 'devices' || cmd === 'check') {
|
|
350
|
+
await initHome();
|
|
351
|
+
const { reg } = await buildRegistry({ mcp: false, announce: false });
|
|
352
|
+
const tool = reg.get(cmd === 'devices' ? 'harmony_devices' : 'harmony_toolchain_check');
|
|
353
|
+
const r = await tool.execute({}, { cwd: process.cwd(), home: homeDir() });
|
|
354
|
+
stdout.write(r.output + '\n');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (cmd === 'tools') {
|
|
358
|
+
await initHome();
|
|
359
|
+
const { reg, clients } = await buildRegistry({ announce: false });
|
|
360
|
+
stdout.write(CYAN('native tools\n'));
|
|
361
|
+
for (const t of reg.list())
|
|
362
|
+
if (!t.name.startsWith('mcp_'))
|
|
363
|
+
printTool(t);
|
|
364
|
+
const mcp = reg.list().filter((t) => t.name.startsWith('mcp_'));
|
|
365
|
+
if (mcp.length > 0) {
|
|
366
|
+
stdout.write(CYAN('mcp tools\n'));
|
|
367
|
+
for (const t of mcp)
|
|
368
|
+
printTool(t);
|
|
369
|
+
}
|
|
370
|
+
for (const c of clients)
|
|
371
|
+
c.close();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (cmd === 'mcp') {
|
|
375
|
+
await initHome();
|
|
376
|
+
const cfg = await loadConfig();
|
|
377
|
+
const servers = Object.entries(cfg.mcpServers ?? {});
|
|
378
|
+
if (servers.length === 0) {
|
|
379
|
+
stdout.write('No MCP servers configured. Add them to HMH_HOME/config.json, e.g.\n'
|
|
380
|
+
+ '{ "mcpServers": { "fetch": { "type": "stdio", "command": "npx", "args": ["-y", "mcp-server-fetch"] } } }\n');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const [name, raw] of servers) {
|
|
384
|
+
try {
|
|
385
|
+
const sc = raw.type === 'http'
|
|
386
|
+
? { type: 'http', url: raw.url ?? '', headers: raw.headers, trusted: raw.trusted }
|
|
387
|
+
: { type: 'stdio', command: raw.command ?? '', args: raw.args, env: raw.env, trusted: raw.trusted };
|
|
388
|
+
const { client, tools } = await mcpServerTools(name, sc);
|
|
389
|
+
stdout.write(CYAN(`${name}`) + DIM(` (${raw.type}${raw.trusted ? ', trusted' : ''}) — ${tools.length} tools\n`));
|
|
390
|
+
for (const t of tools)
|
|
391
|
+
printTool(t);
|
|
392
|
+
client.close();
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
stdout.write(YELLOW(`${name}: unavailable (${String(err).slice(0, 160)})\n`));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (cmd === 'skills') {
|
|
401
|
+
const home = homeDir();
|
|
402
|
+
const flag = rest.find((a) => a.startsWith('--'));
|
|
403
|
+
const skillName = rest.find((a) => !a.startsWith('-') && !a.startsWith('--') && a !== 'add');
|
|
404
|
+
if (rest[0] === 'add' || flag === '--add') {
|
|
405
|
+
// install from a git URL or local dir (multi-skill packs supported)
|
|
406
|
+
if (!skillName) {
|
|
407
|
+
stdout.write(`usage: hmh skills add <git-url-or-local-dir>\n`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const { installSkills } = await import('@hmharness/evolution');
|
|
411
|
+
try {
|
|
412
|
+
const r = await installSkills(skillName, home);
|
|
413
|
+
stdout.write(r.installed.length
|
|
414
|
+
? GREEN('✓') + ` installed ${r.installed.length} skill(s): ${r.installed.join(', ')}\n` + DIM(`verify: hmh skills\n`)
|
|
415
|
+
: DIM('no installable skills found (looked for SKILL.md at root, skills/*/, or */SKILL.md)\n'));
|
|
416
|
+
if (r.skipped.length)
|
|
417
|
+
stdout.write(DIM(`skipped (already present): ${r.skipped.join(', ')}\n`));
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
stdout.write(YELLOW(`install failed: ${String(err).slice(0, 300)}\n`));
|
|
421
|
+
}
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (flag === '--promote' || flag === '--rollback' || flag === '--unpromote') {
|
|
425
|
+
if (!skillName) {
|
|
426
|
+
stdout.write(`usage: hmh skills --promote|--rollback|--unpromote <name>\n`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (flag === '--promote') {
|
|
430
|
+
const r = await promoteSkill(home, skillName);
|
|
431
|
+
stdout.write(`promoted ${skillName} -> active (${r.file}${r.archivedPrevious ? '; previous archived' : ''})\n`);
|
|
432
|
+
}
|
|
433
|
+
else if (flag === '--rollback') {
|
|
434
|
+
stdout.write((await rollbackSkill(home, skillName)) ? `rolled back ${skillName} to the previous archived version\n` : `no archived snapshot of ${skillName}\n`);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
stdout.write((await unpromoteSkill(home, skillName)) ? `moved ${skillName} back to drafts\n` : `${skillName} is not active\n`);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const active = await listSkills(home);
|
|
442
|
+
const drafts = await listDrafts(home);
|
|
443
|
+
stdout.write(CYAN(`active (${active.length})\n`));
|
|
444
|
+
stdout.write(active.length ? active.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : ' (none)\n');
|
|
445
|
+
stdout.write(CYAN(`drafts (${drafts.length})\n`));
|
|
446
|
+
stdout.write(drafts.length ? drafts.map((s) => ` ${s.name} — ${s.description}`).join('\n') + '\n' : ' (none)\n');
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (cmd === 'bench') {
|
|
450
|
+
await initHome();
|
|
451
|
+
// `hmh bench --impact`: the canary A/B report - which experimental
|
|
452
|
+
// skills earned full activation on evidence, which retired, which
|
|
453
|
+
// need more data. The observability half of self-evolution (P0).
|
|
454
|
+
if (rest.includes('--impact')) {
|
|
455
|
+
const { impactReport } = await import('@hmharness/evolution');
|
|
456
|
+
const { rows, applied } = await impactReport(homeDir());
|
|
457
|
+
if (rows.length === 0) {
|
|
458
|
+
stdout.write(DIM('no canary skills under evaluation\n'));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
for (const r of rows) {
|
|
462
|
+
const badge = r.verdict === 'promote' ? GREEN('PROMOTE') : r.verdict === 'retire' ? YELLOW('RETIRE') : r.verdict === 'keep' ? GREEN('KEEP') : DIM('needs data');
|
|
463
|
+
stdout.write(`${badge.padEnd(10)} ${r.skill} — exposed ${r.exposed.sessions}s/${(r.exposed.okRate * 100).toFixed(0)}% vs control ${r.control.sessions}s/${(r.control.okRate * 100).toFixed(0)}%\n`);
|
|
464
|
+
}
|
|
465
|
+
if (applied.length)
|
|
466
|
+
stdout.write('\n' + applied.map((a) => CYAN('✓ ') + a).join('\n') + '\n');
|
|
467
|
+
else
|
|
468
|
+
stdout.write(DIM('\n(no verdicts strong enough to act on - honesty over noise)\n'));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const runner = makeCaseRunner();
|
|
472
|
+
const { results, passRate } = await runBench(homeDir(), (c) => runner(c, ''));
|
|
473
|
+
for (const r of results)
|
|
474
|
+
stdout.write(`${r.pass ? GREEN('PASS') : YELLOW('FAIL')} ${r.name} — ${r.detail}\n`);
|
|
475
|
+
stdout.write(`pass rate: ${(passRate * 100).toFixed(0)}%\n`);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (cmd === 'evolve') {
|
|
479
|
+
await initHome();
|
|
480
|
+
const cfg = await loadConfig();
|
|
481
|
+
const maxN = Number(rest.find((a) => a.startsWith('--max='))?.slice(6) ?? 2);
|
|
482
|
+
const everyMin = Number(rest.find((a) => a.startsWith('--every='))?.slice(8) ?? 0);
|
|
483
|
+
const maxCycles = Number(rest.find((a) => a.startsWith('--cycles='))?.slice(9) ?? 0);
|
|
484
|
+
const t = await uiStrings();
|
|
485
|
+
const runCycle = async (n) => {
|
|
486
|
+
stdout.write(CYAN('evolve') + DIM(` · ${t.evolveCycle(cfg.provider.model, n)}${everyMin ? '' : ' (one-shot)'}\n`));
|
|
487
|
+
const report = await runEvolution({
|
|
488
|
+
home: homeDir(),
|
|
489
|
+
provider: resolveProvider(cfg, 'evolve'),
|
|
490
|
+
runCase: makeCaseRunner(),
|
|
491
|
+
maxProposals: Number.isFinite(maxN) ? Math.min(Math.max(maxN, 0), 4) : 2,
|
|
492
|
+
log: (l) => stdout.write(DIM(` ${l}\n`)),
|
|
493
|
+
});
|
|
494
|
+
for (const o of report.outcomes) {
|
|
495
|
+
const tag = o.action === 'promoted' ? GREEN(t.promoted) : o.action === 'rejected' ? YELLOW(t.rejected) : YELLOW(t.errorLabel);
|
|
496
|
+
stdout.write(`${tag} ${o.name} — ${o.reason}\n`);
|
|
497
|
+
}
|
|
498
|
+
if (report.memoryDistilled)
|
|
499
|
+
stdout.write(DIM(`memory distilled: ${report.memoryDistilled}\n`));
|
|
500
|
+
};
|
|
501
|
+
if (everyMin >= 1) {
|
|
502
|
+
// Scheduled mode: run a cycle, sleep, repeat. Errors don't kill the
|
|
503
|
+
// loop (transient gateway failures are expected); Ctrl-C exits.
|
|
504
|
+
const waitMs = Math.max(everyMin, 5) * 60_000;
|
|
505
|
+
const cap = maxCycles > 0 ? maxCycles : Infinity;
|
|
506
|
+
for (let n = 1; n <= cap; n++) {
|
|
507
|
+
try {
|
|
508
|
+
await runCycle(n);
|
|
509
|
+
}
|
|
510
|
+
catch (err) {
|
|
511
|
+
stdout.write(YELLOW(`cycle ${n} failed: ${String(err).slice(0, 160)} (continuing)\n`));
|
|
512
|
+
}
|
|
513
|
+
if (n >= cap)
|
|
514
|
+
break;
|
|
515
|
+
stdout.write(DIM(`next cycle in ${Math.max(everyMin, 5)} min (Ctrl-C to stop)\n`));
|
|
516
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
517
|
+
}
|
|
518
|
+
stdout.write(DIM(`log: ${homeDir()}/evolution/log.jsonl\n`));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
await runCycle(1);
|
|
522
|
+
stdout.write(DIM(`log: ${homeDir()}/evolution/log.jsonl\n`));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (cmd === 'providers') {
|
|
526
|
+
await initHome();
|
|
527
|
+
const cfg = await loadConfig();
|
|
528
|
+
const { readFile } = await import('node:fs/promises');
|
|
529
|
+
const { detectLocalProviders, listProviders, PROVIDER_PRESETS, addProviders } = await import('@hmharness/kernel');
|
|
530
|
+
stdout.write(CYAN(`configured (${listProviders(cfg).length})\n`));
|
|
531
|
+
for (const v of listProviders(cfg)) {
|
|
532
|
+
stdout.write(` ${v.purposes.includes('chat') ? GREEN('●') : DIM('○')} ${v.name} — ${v.model}${v.purposes.length ? DIM(` (${v.purposes.join('/')})`) : ''}\n`);
|
|
533
|
+
}
|
|
534
|
+
const found = await detectLocalProviders(cfg, readFile);
|
|
535
|
+
if (!found.length) {
|
|
536
|
+
stdout.write(DIM('no new local providers detected (env vars / opencode config)\n'));
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
stdout.write(CYAN(`detected locally (${found.length})\n`));
|
|
540
|
+
for (const p of found)
|
|
541
|
+
stdout.write(` ${YELLOW('+')} ${p.name} — ${p.model} (${p.envVar})\n`);
|
|
542
|
+
if (rest.includes('--scan')) {
|
|
543
|
+
const r = await addProviders(found.map((p) => ({ name: p.name, baseUrl: p.baseUrl, model: p.model })));
|
|
544
|
+
stdout.write(GREEN('✓') + ` added ${r.added.length}: ${r.added.join(', ')} — hmh /model or hmh tui "/model <name>" to use\n`);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
stdout.write(DIM('run "hmh providers --scan" to add them to config.json\n'));
|
|
548
|
+
}
|
|
549
|
+
void PROVIDER_PRESETS;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (cmd === 'ops') {
|
|
553
|
+
await initHome();
|
|
554
|
+
const { harmonyOpsRadarScan, harmonyOpsRadarBrief, harmonyOpsStatus } = await import('@hmharness/domain-ops');
|
|
555
|
+
const sub = rest[0] ?? 'status';
|
|
556
|
+
const ctx = { cwd: process.cwd(), home: homeDir() };
|
|
557
|
+
if (sub === 'scan') {
|
|
558
|
+
const r = await harmonyOpsRadarScan.execute({}, ctx);
|
|
559
|
+
stdout.write(r.output + '\n');
|
|
560
|
+
}
|
|
561
|
+
else if (sub === 'brief') {
|
|
562
|
+
const r = await harmonyOpsRadarBrief.execute({}, ctx);
|
|
563
|
+
stdout.write(r.output + '\n');
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const r = await harmonyOpsStatus.execute({}, ctx);
|
|
567
|
+
stdout.write(r.output + '\n');
|
|
568
|
+
}
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (cmd === 'tui') {
|
|
572
|
+
await initHome();
|
|
573
|
+
// tui(yes, noWeb): inside the TTY check the TUI auto-links the web UI
|
|
574
|
+
const { tui } = await import("./tui.js");
|
|
575
|
+
await tui(yes, rest.includes('--no-web'));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (cmd === 'web') {
|
|
579
|
+
await initHome();
|
|
580
|
+
const port = Number(rest.find((a) => a.startsWith('--port='))?.slice(7) ?? 7788);
|
|
581
|
+
const t = await uiStrings();
|
|
582
|
+
const sub = rest.find((a) => !a.startsWith('-'));
|
|
583
|
+
if (sub === 'stop') {
|
|
584
|
+
stdout.write(stopWebDaemon() ? t.webStopped + '\n' : t.webNotRunning + '\n');
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (sub === 'status') {
|
|
588
|
+
const up = await hmhWebUp(Number.isFinite(port) ? port : 7788);
|
|
589
|
+
stdout.write(up ? t.webRunning(readWebPid() || 0, port) + '\n' : t.webNotRunning + '\n');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (sub === 'start') {
|
|
593
|
+
const r = startWebDaemon(Number.isFinite(port) ? port : 7788);
|
|
594
|
+
if (r.already) {
|
|
595
|
+
stdout.write(t.webRunning(r.pid, port) + '\n');
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
stdout.write(t.webStarted(Number.isFinite(port) ? port : 7788, join(homeDir(), 'web.log')) + '\n');
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
// default: foreground server (handy for debugging)
|
|
603
|
+
const { startServer } = await import('@hmharness/web');
|
|
604
|
+
await startServer({ port: Number.isFinite(port) ? port : 7788, host: '127.0.0.1' });
|
|
605
|
+
return; // startServer keeps the process alive
|
|
606
|
+
}
|
|
607
|
+
if (cmd === 'resume') {
|
|
608
|
+
await initHome();
|
|
609
|
+
const file = await latestSession(homeDir(), arg);
|
|
610
|
+
if (!file) {
|
|
611
|
+
stdout.write(arg ? `No session matches prefix "${arg}".\n` : 'No sessions yet.\n');
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const tr = await loadTranscript(file);
|
|
615
|
+
if (!tr) {
|
|
616
|
+
stdout.write(`Could not parse ${file}\n`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
stdout.write(DIM(`resuming ${tr.id} · ${tr.messages.length} messages · model ${tr.model}\n`));
|
|
620
|
+
await repl(yes, tr.messages);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (cmd && !cmd.startsWith('-')) {
|
|
624
|
+
await initHome();
|
|
625
|
+
const { reg, clients } = await buildRegistry({ announce: false });
|
|
626
|
+
try {
|
|
627
|
+
await runTask([cmd, ...rest].join(' '), { yes, registry: reg, clients });
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
for (const c of clients)
|
|
631
|
+
c.close();
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await initHome();
|
|
636
|
+
await repl(yes);
|
|
637
|
+
}
|
|
638
|
+
main().catch((err) => {
|
|
639
|
+
console.error(String(err));
|
|
640
|
+
process.exit(1);
|
|
641
|
+
});
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function buildSystemPrompt(opts) {
|
|
2
|
+
const parts = [];
|
|
3
|
+
parts.push(`You are hmh, a coding agent powered by ${opts.model}, running on hmharness - a self-evolving agent framework designed for the full HarmonyOS development lifecycle. Working directory: ${opts.cwd}.`, '', 'Reply in the language the user writes in (Chinese in, Chinese out).', '', 'HarmonyOS development is your home domain: DevEco Studio toolchain, hvigor builds, ohpm packages, hdc devices, ArkTS/ArkUI, OpenHarmony and Cangjie. When a task touches it, prefer the harmony_* tools and precise toolchain knowledge.', '', 'Working style: read before writing; prefer small focused commands; verify results; state tradeoffs briefly. For risky operations (deleting, overwriting, publishing) say what will happen first.');
|
|
4
|
+
if (opts.memory.trim()) {
|
|
5
|
+
parts.push('', '## Long-term memory', opts.memory.trim());
|
|
6
|
+
}
|
|
7
|
+
if (opts.skills.trim()) {
|
|
8
|
+
parts.push('', '## Skill library', 'Read a skill file with read_file before applying it the first time.', opts.skills.trim());
|
|
9
|
+
}
|
|
10
|
+
if (opts.insights.trim()) {
|
|
11
|
+
parts.push('', '## Recent session outcomes (what worked / what failed)', opts.insights.trim());
|
|
12
|
+
}
|
|
13
|
+
return parts.join('\n');
|
|
14
|
+
}
|