@nanhara/hara 0.33.0 → 0.48.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/CHANGELOG.md +152 -1
- package/README.md +12 -4
- package/dist/index.js +303 -76
- package/dist/org/planner.js +19 -0
- package/dist/search/semindex.js +62 -11
- package/dist/session/store.js +14 -0
- package/dist/tools/computer.js +156 -16
- package/dist/tui/App.js +40 -5
- package/dist/tui/InputBox.js +2 -2
- package/dist/vision.js +52 -3
- package/package.json +3 -2
- package/plugins/browser/.hara-plugin/plugin.json +9 -0
- package/plugins/browser/skills/web/SKILL.md +27 -0
- package/plugins/chrome/.hara-plugin/plugin.json +9 -0
- package/plugins/chrome/skills/chrome/SKILL.md +26 -0
package/dist/index.js
CHANGED
|
@@ -4,13 +4,13 @@ import { createInterface } from "node:readline/promises";
|
|
|
4
4
|
import { emitKeypressEvents } from "node:readline";
|
|
5
5
|
import { runTui } from "./tui/run.js";
|
|
6
6
|
import { readClipboardImage } from "./images.js";
|
|
7
|
-
import { describeImages, classifyVision } from "./vision.js";
|
|
7
|
+
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
8
|
import { setTheme } from "./tui/theme.js";
|
|
9
9
|
import { memoryDigest, memoryDir } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
|
-
import { readFileSync, existsSync } from "node:fs";
|
|
13
|
-
import { homedir } from "node:os";
|
|
12
|
+
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
@@ -24,14 +24,14 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
24
24
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
25
25
|
import { searchHybrid } from "./search/hybrid.js";
|
|
26
26
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
27
|
-
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, } from "./session/store.js";
|
|
27
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
28
28
|
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
29
29
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
30
30
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
|
|
31
31
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
32
|
-
import { decompose, topoOrder, savePlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
32
|
+
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
33
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
34
|
-
import { sandboxSupported } from "./sandbox.js";
|
|
34
|
+
import { sandboxSupported, runShell } from "./sandbox.js";
|
|
35
35
|
import { undoLast } from "./undo.js";
|
|
36
36
|
import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
|
|
37
37
|
import { c, out, statusLine } from "./ui.js";
|
|
@@ -133,7 +133,96 @@ function lastAssistantText(history) {
|
|
|
133
133
|
}
|
|
134
134
|
return "";
|
|
135
135
|
}
|
|
136
|
-
/**
|
|
136
|
+
/** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
|
|
137
|
+
async function executeAtom(atom, plan, done, roles, o) {
|
|
138
|
+
atom.status = "running";
|
|
139
|
+
savePlan(o.cwd, plan);
|
|
140
|
+
const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
|
|
141
|
+
const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
|
|
142
|
+
const toolFilter = role?.allowTools
|
|
143
|
+
? (n) => role.allowTools.includes(n)
|
|
144
|
+
: role?.denyTools
|
|
145
|
+
? (n) => !role.denyTools.includes(n)
|
|
146
|
+
: undefined;
|
|
147
|
+
const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
|
|
148
|
+
try {
|
|
149
|
+
await runAgent(history, {
|
|
150
|
+
provider: roleProvider,
|
|
151
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
152
|
+
approval: o.approval,
|
|
153
|
+
confirm: o.confirm,
|
|
154
|
+
projectContext: o.projectContext,
|
|
155
|
+
memory: memoryDigest(o.cwd),
|
|
156
|
+
stats: o.stats,
|
|
157
|
+
systemOverride: role?.system,
|
|
158
|
+
toolFilter,
|
|
159
|
+
quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
atom.status = "failed";
|
|
164
|
+
atom.note = e.message;
|
|
165
|
+
savePlan(o.cwd, plan);
|
|
166
|
+
out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
|
|
170
|
+
atom.status = v.ok ? "done" : "failed";
|
|
171
|
+
atom.note = v.reason;
|
|
172
|
+
savePlan(o.cwd, plan);
|
|
173
|
+
out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
|
|
174
|
+
return v.ok;
|
|
175
|
+
}
|
|
176
|
+
/** Execute a plan's atoms (sequential, or parallel waves with --parallel). Atoms already marked `done`
|
|
177
|
+
* are skipped — so this doubles as the resume engine. Stops on the first failure. */
|
|
178
|
+
async function executePlan(plan, roles, o) {
|
|
179
|
+
const done = plan.atoms.filter((a) => a.status === "done");
|
|
180
|
+
const doneIds = new Set(done.map((a) => a.id));
|
|
181
|
+
if (o.parallel) {
|
|
182
|
+
const waved = topoWaves(plan.atoms);
|
|
183
|
+
if ("error" in waved)
|
|
184
|
+
return void out(c.red(`${waved.error}\n`));
|
|
185
|
+
out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
|
|
186
|
+
for (const wave of waved.ok) {
|
|
187
|
+
const todo = wave.filter((a) => !doneIds.has(a.id));
|
|
188
|
+
if (!todo.length)
|
|
189
|
+
continue; // whole wave already complete (resume)
|
|
190
|
+
out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
|
|
191
|
+
const results = await Promise.all(todo.map((atom) => executeAtom(atom, plan, done, roles, o)));
|
|
192
|
+
todo.forEach((atom, i) => {
|
|
193
|
+
if (results[i]) {
|
|
194
|
+
done.push(atom);
|
|
195
|
+
doneIds.add(atom.id);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (results.some((r) => !r)) {
|
|
199
|
+
out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
const ord = topoOrder(plan.atoms);
|
|
206
|
+
if ("error" in ord)
|
|
207
|
+
return void out(c.red(`${ord.error}\n`));
|
|
208
|
+
for (const atom of ord.ok) {
|
|
209
|
+
if (doneIds.has(atom.id))
|
|
210
|
+
continue; // resume: skip completed atoms
|
|
211
|
+
out(c.cyan(`\n▶ ${atom.id} ${atom.title}\n`));
|
|
212
|
+
if (await executeAtom(atom, plan, done, roles, o)) {
|
|
213
|
+
done.push(atom);
|
|
214
|
+
doneIds.add(atom.id);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
|
|
223
|
+
}
|
|
224
|
+
/** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
|
|
225
|
+
* With `parallel`, independent atoms (the same dependency wave) run concurrently. */
|
|
137
226
|
async function runPlan(task, o) {
|
|
138
227
|
const roles = loadRoles(o.cwd);
|
|
139
228
|
out(c.dim("Planning…\n"));
|
|
@@ -147,68 +236,75 @@ async function runPlan(task, o) {
|
|
|
147
236
|
out(c.red(`${ord.error}\n`));
|
|
148
237
|
return;
|
|
149
238
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (const a of ordered) {
|
|
239
|
+
out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
|
|
240
|
+
for (const a of ord.ok) {
|
|
153
241
|
out(` ${c.cyan(a.id)} ${a.title}${a.deps.length ? c.dim(" ←" + a.deps.join(",")) : ""}${a.role ? c.dim(" @" + a.role) : ""}${a.check ? c.dim(" ✓" + a.check) : ""}\n`);
|
|
154
242
|
}
|
|
155
243
|
if (o.approval !== "full-auto") {
|
|
156
|
-
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${
|
|
244
|
+
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
|
|
157
245
|
if (!ok)
|
|
158
246
|
return void out(c.dim("(cancelled)\n"));
|
|
159
247
|
}
|
|
160
248
|
savePlan(o.cwd, plan);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
249
|
+
await executePlan(plan, roles, o);
|
|
250
|
+
}
|
|
251
|
+
/** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
|
|
252
|
+
async function runResume(o) {
|
|
253
|
+
const roles = loadRoles(o.cwd);
|
|
254
|
+
const plan = loadPlan(o.cwd);
|
|
255
|
+
if (!plan)
|
|
256
|
+
return void out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
|
|
257
|
+
const remaining = plan.atoms.filter((a) => a.status !== "done");
|
|
258
|
+
if (!remaining.length)
|
|
259
|
+
return void out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
|
|
260
|
+
out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
|
|
261
|
+
for (const a of remaining)
|
|
262
|
+
out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
|
|
263
|
+
if (o.approval !== "full-auto") {
|
|
264
|
+
const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
|
|
265
|
+
if (!ok)
|
|
266
|
+
return void out(c.dim("(cancelled)\n"));
|
|
267
|
+
}
|
|
268
|
+
for (const a of plan.atoms)
|
|
269
|
+
if (a.status === "failed" || a.status === "running")
|
|
270
|
+
a.status = "pending"; // retry interrupted
|
|
271
|
+
savePlan(o.cwd, plan);
|
|
272
|
+
await executePlan(plan, roles, o);
|
|
273
|
+
}
|
|
274
|
+
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "codebase_search"]);
|
|
275
|
+
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
|
|
276
|
+
"issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
|
|
277
|
+
"for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
|
|
278
|
+
"**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
|
|
279
|
+
const COMMIT_SYSTEM = "Write a git commit message for the staged diff. A concise imperative subject (≤72 chars; an optional " +
|
|
280
|
+
"conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
|
|
281
|
+
"non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
|
|
282
|
+
"Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
|
|
283
|
+
const SESSION_NAME_SYSTEM = "Name this coding session as a SHORT slug: 2–4 English words, lowercase, hyphen-separated, ASCII only " +
|
|
284
|
+
"(e.g. add-semantic-search, fix-login-redirect). If the conversation is in another language, translate the " +
|
|
285
|
+
"gist to English (use pinyin only if a term is untranslatable). Output ONLY the slug.";
|
|
286
|
+
/** One short model call → a 2–4 word English kebab-case session name summarizing the work.
|
|
287
|
+
* Always ASCII (translates non-English gist). Falls back to the lexical title on any failure. */
|
|
288
|
+
async function nameSession(provider, history) {
|
|
289
|
+
const text = (m) => {
|
|
290
|
+
if (!m)
|
|
291
|
+
return "";
|
|
292
|
+
if (m.role === "assistant")
|
|
293
|
+
return typeof m.text === "string" ? m.text : "";
|
|
294
|
+
if (m.role === "user")
|
|
295
|
+
return typeof m.content === "string" ? m.content : "";
|
|
296
|
+
return "";
|
|
297
|
+
};
|
|
298
|
+
const basis = `User: ${text(history.find((m) => m.role === "user")).slice(0, 800)}\n` +
|
|
299
|
+
`Assistant: ${text(history.find((m) => m.role === "assistant")).slice(0, 800)}`;
|
|
300
|
+
try {
|
|
301
|
+
const r = await provider.turn({ system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } });
|
|
302
|
+
return slugify(r.text) || titleFrom(history);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return titleFrom(history);
|
|
208
306
|
}
|
|
209
|
-
out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
|
|
210
307
|
}
|
|
211
|
-
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch"]);
|
|
212
308
|
const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
|
|
213
309
|
"then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
|
|
214
310
|
"End your message with the plan as a short numbered list.";
|
|
@@ -359,9 +455,10 @@ program
|
|
|
359
455
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
360
456
|
});
|
|
361
457
|
program
|
|
362
|
-
.command("plan
|
|
458
|
+
.command("plan [task...]")
|
|
363
459
|
.description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
|
|
364
|
-
.
|
|
460
|
+
.option("--parallel", "run independent atoms (same dependency wave) concurrently")
|
|
461
|
+
.action(async (taskParts, opts) => {
|
|
365
462
|
const cfg = loadConfig();
|
|
366
463
|
const provider = await buildProvider(cfg);
|
|
367
464
|
if (!provider) {
|
|
@@ -369,7 +466,7 @@ program
|
|
|
369
466
|
process.exit(1);
|
|
370
467
|
}
|
|
371
468
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
372
|
-
|
|
469
|
+
const o = {
|
|
373
470
|
cfg,
|
|
374
471
|
baseProvider: provider,
|
|
375
472
|
cwd: cfg.cwd,
|
|
@@ -378,7 +475,15 @@ program
|
|
|
378
475
|
confirm: async () => true,
|
|
379
476
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
380
477
|
stats,
|
|
381
|
-
|
|
478
|
+
parallel: opts.parallel,
|
|
479
|
+
};
|
|
480
|
+
const task = (taskParts ?? []).join(" ").trim();
|
|
481
|
+
if (task === "resume")
|
|
482
|
+
await runResume(o);
|
|
483
|
+
else if (!task)
|
|
484
|
+
out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
|
|
485
|
+
else
|
|
486
|
+
await runPlan(task, o);
|
|
382
487
|
if (stats.input || stats.output)
|
|
383
488
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
384
489
|
});
|
|
@@ -424,10 +529,11 @@ program
|
|
|
424
529
|
const build = async (name, chunks, blurb) => {
|
|
425
530
|
if (!chunks.length)
|
|
426
531
|
return void out(c.dim(`Nothing to index for ${name}.\n`));
|
|
427
|
-
out(c.dim(`
|
|
532
|
+
out(c.dim(`Indexing ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
|
|
428
533
|
try {
|
|
429
|
-
const
|
|
430
|
-
|
|
534
|
+
const r = await buildIndex(name, chunks, embed, cwd, model);
|
|
535
|
+
const detail = r.reused ? `${r.embedded} embedded, ${r.reused} reused` : `${r.embedded} embedded`;
|
|
536
|
+
out(c.green(`Indexed ${r.total} chunks`) + c.dim(` (${detail}) → ${indexPath(name, cwd)} · ${blurb}`) + "\n");
|
|
431
537
|
}
|
|
432
538
|
catch (e) {
|
|
433
539
|
out(c.red(`Indexing ${name} failed: ${e.message}\n`));
|
|
@@ -445,6 +551,111 @@ program
|
|
|
445
551
|
.command("doctor")
|
|
446
552
|
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
447
553
|
.action(() => out(runDoctor(loadConfig()) + "\n"));
|
|
554
|
+
program
|
|
555
|
+
.command("review")
|
|
556
|
+
.description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
|
|
557
|
+
.option("--staged", "review only staged changes")
|
|
558
|
+
.option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
|
|
559
|
+
.action(async (opts) => {
|
|
560
|
+
const cfg = loadConfig();
|
|
561
|
+
const provider = await buildProvider(cfg);
|
|
562
|
+
if (!provider) {
|
|
563
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
const cmd = opts.base ? `git diff ${opts.base}` : opts.staged ? "git diff --staged" : "git diff HEAD";
|
|
567
|
+
let diff = "";
|
|
568
|
+
try {
|
|
569
|
+
diff = (await runShell(cmd, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
return void out(c.red(`\`${cmd}\` failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
|
|
573
|
+
}
|
|
574
|
+
if (!diff.trim())
|
|
575
|
+
return void out(c.dim(`No changes to review (${cmd}).\n`));
|
|
576
|
+
out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
|
|
577
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
578
|
+
await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
|
|
579
|
+
provider,
|
|
580
|
+
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
581
|
+
approval: "full-auto",
|
|
582
|
+
confirm: async () => true,
|
|
583
|
+
systemOverride: REVIEW_SYSTEM,
|
|
584
|
+
toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
|
|
585
|
+
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
586
|
+
memory: memoryDigest(cfg.cwd),
|
|
587
|
+
stats,
|
|
588
|
+
});
|
|
589
|
+
if (stats.input || stats.output)
|
|
590
|
+
out("\n" + statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
591
|
+
});
|
|
592
|
+
program
|
|
593
|
+
.command("commit")
|
|
594
|
+
.description("generate a commit message from staged changes and commit (-y to skip the confirm)")
|
|
595
|
+
.option("-a, --all", "stage all tracked changes first (git add -u)")
|
|
596
|
+
.action(async (opts) => {
|
|
597
|
+
const skipConfirm = !!program.opts().yes; // reuse the global -y/--yes (auto-approve)
|
|
598
|
+
const cfg = loadConfig();
|
|
599
|
+
const provider = await buildProvider(cfg);
|
|
600
|
+
if (!provider) {
|
|
601
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
602
|
+
process.exit(1);
|
|
603
|
+
}
|
|
604
|
+
if (opts.all) {
|
|
605
|
+
try {
|
|
606
|
+
await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
/* report below if nothing is staged */
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let diff = "";
|
|
613
|
+
try {
|
|
614
|
+
diff = (await runShell("git diff --staged", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
615
|
+
}
|
|
616
|
+
catch (e) {
|
|
617
|
+
return void out(c.red(`git diff failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
|
|
618
|
+
}
|
|
619
|
+
if (!diff.trim())
|
|
620
|
+
return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
|
|
621
|
+
out(c.dim("Writing a commit message…\n"));
|
|
622
|
+
const r = await provider.turn({
|
|
623
|
+
system: COMMIT_SYSTEM,
|
|
624
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
|
|
625
|
+
tools: [],
|
|
626
|
+
onText: () => { },
|
|
627
|
+
});
|
|
628
|
+
if (r.stop === "error")
|
|
629
|
+
return void out(c.red(`message generation failed: ${r.errorMsg ?? "provider error"}\n`));
|
|
630
|
+
const msg = r.text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
631
|
+
if (!msg)
|
|
632
|
+
return void out(c.red("No commit message produced — commit manually or retry.\n"));
|
|
633
|
+
out("\n" + c.bold("Proposed commit message:\n") + c.dim("─".repeat(48) + "\n") + msg + "\n" + c.dim("─".repeat(48)) + "\n\n");
|
|
634
|
+
if (!skipConfirm) {
|
|
635
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
636
|
+
const ans = (await rl.question(`Commit with this message? ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
637
|
+
rl.close();
|
|
638
|
+
if (ans === "n" || ans === "no")
|
|
639
|
+
return void out(c.dim("(cancelled — nothing committed)\n"));
|
|
640
|
+
}
|
|
641
|
+
const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
|
|
642
|
+
writeFileSync(tmp, msg + "\n", "utf8");
|
|
643
|
+
try {
|
|
644
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
645
|
+
out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
|
|
646
|
+
}
|
|
647
|
+
catch (e) {
|
|
648
|
+
out(c.red(`git commit failed: ${e instanceof Error ? e.message : String(e)}\n`));
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
try {
|
|
652
|
+
rmSync(tmp);
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
/* best-effort cleanup */
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
});
|
|
448
659
|
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
449
660
|
rolesCmd
|
|
450
661
|
.command("init")
|
|
@@ -965,19 +1176,35 @@ program.action(async (opts) => {
|
|
|
965
1176
|
visionProvider = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
966
1177
|
return visionProvider;
|
|
967
1178
|
};
|
|
968
|
-
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model)
|
|
969
|
-
|
|
1179
|
+
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
1180
|
+
// Uses the screenshot-tuned prompt (actionable UI elements + positions) + an optional focus hint, so a
|
|
1181
|
+
// text-only main model gets something it can click on rather than a generic transcription.
|
|
1182
|
+
const describeScreenshot = async (path, hint) => {
|
|
970
1183
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
971
1184
|
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
972
1185
|
if (!vp)
|
|
973
1186
|
return "";
|
|
974
1187
|
try {
|
|
975
|
-
return await describeImages(vp, [{ path, mediaType: "image/png" }]);
|
|
1188
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
976
1189
|
}
|
|
977
1190
|
catch {
|
|
978
1191
|
return "";
|
|
979
1192
|
}
|
|
980
1193
|
};
|
|
1194
|
+
// grounding for accurate RPA: ask the vision model WHERE an element is (0..1 fractions) so the computer
|
|
1195
|
+
// tool can click it precisely instead of guessing pixels from a text description.
|
|
1196
|
+
const locateScreenshot = async (path, target) => {
|
|
1197
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1198
|
+
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
1199
|
+
if (!vp)
|
|
1200
|
+
return null;
|
|
1201
|
+
try {
|
|
1202
|
+
return await locateImage(vp, { path, mediaType: "image/png" }, target);
|
|
1203
|
+
}
|
|
1204
|
+
catch {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
};
|
|
981
1208
|
const remindVision = (sink) => {
|
|
982
1209
|
if (remindedVision)
|
|
983
1210
|
return void sink.notice(`⚠ image skipped — ${cfg.model} is text-only. Add a vision model: /vision <model>`);
|
|
@@ -1207,7 +1434,7 @@ program.action(async (opts) => {
|
|
|
1207
1434
|
const pout = stats.output;
|
|
1208
1435
|
await runAgent(history, {
|
|
1209
1436
|
provider,
|
|
1210
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1437
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1211
1438
|
approval: "suggest",
|
|
1212
1439
|
confirm: h.confirm,
|
|
1213
1440
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
@@ -1218,7 +1445,7 @@ program.action(async (opts) => {
|
|
|
1218
1445
|
signal: h.signal,
|
|
1219
1446
|
});
|
|
1220
1447
|
if (!meta.title) {
|
|
1221
|
-
meta.title =
|
|
1448
|
+
meta.title = await nameSession(provider, history);
|
|
1222
1449
|
h.sink.session(meta.title);
|
|
1223
1450
|
}
|
|
1224
1451
|
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
@@ -1235,7 +1462,7 @@ program.action(async (opts) => {
|
|
|
1235
1462
|
const xout = stats.output;
|
|
1236
1463
|
await runAgent(history, {
|
|
1237
1464
|
provider,
|
|
1238
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1465
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1239
1466
|
approval: choice,
|
|
1240
1467
|
memory: buildMemory(),
|
|
1241
1468
|
confirm: h.confirm,
|
|
@@ -1259,7 +1486,7 @@ program.action(async (opts) => {
|
|
|
1259
1486
|
const beforeOut = stats.output;
|
|
1260
1487
|
await runAgent(history, {
|
|
1261
1488
|
provider,
|
|
1262
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1489
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1263
1490
|
approval: appr,
|
|
1264
1491
|
memory: buildMemory(),
|
|
1265
1492
|
confirm: h.confirm,
|
|
@@ -1269,7 +1496,7 @@ program.action(async (opts) => {
|
|
|
1269
1496
|
signal: h.signal,
|
|
1270
1497
|
});
|
|
1271
1498
|
if (!meta.title) {
|
|
1272
|
-
meta.title =
|
|
1499
|
+
meta.title = await nameSession(provider, history);
|
|
1273
1500
|
h.sink.session(meta.title);
|
|
1274
1501
|
}
|
|
1275
1502
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
@@ -1329,7 +1556,7 @@ program.action(async (opts) => {
|
|
|
1329
1556
|
currentTurn = null;
|
|
1330
1557
|
}
|
|
1331
1558
|
if (!meta.title)
|
|
1332
|
-
meta.title =
|
|
1559
|
+
meta.title = await nameSession(provider, history);
|
|
1333
1560
|
if (bar.isActive()) {
|
|
1334
1561
|
bar.update({
|
|
1335
1562
|
sessionName: meta.title,
|
package/dist/org/planner.js
CHANGED
|
@@ -91,6 +91,25 @@ export function topoOrder(atoms) {
|
|
|
91
91
|
return { error: "plan has a dependency cycle — cannot sequence" };
|
|
92
92
|
return { ok: order };
|
|
93
93
|
}
|
|
94
|
+
/** Group atoms into dependency "waves": every atom in a wave depends only on atoms in EARLIER waves, so a
|
|
95
|
+
* wave's atoms are mutually independent and may run concurrently. Preserves atom order; errors on a cycle. */
|
|
96
|
+
export function topoWaves(atoms) {
|
|
97
|
+
const byId = new Map(atoms.map((a) => [a.id, a]));
|
|
98
|
+
const remaining = new Map(atoms.map((a) => [a.id, a]));
|
|
99
|
+
const done = new Set();
|
|
100
|
+
const waves = [];
|
|
101
|
+
while (remaining.size) {
|
|
102
|
+
const wave = [...remaining.values()].filter((a) => a.deps.every((d) => !byId.has(d) || done.has(d)));
|
|
103
|
+
if (!wave.length)
|
|
104
|
+
return { error: "plan has a dependency cycle — cannot sequence" };
|
|
105
|
+
for (const a of wave)
|
|
106
|
+
remaining.delete(a.id);
|
|
107
|
+
for (const a of wave)
|
|
108
|
+
done.add(a.id);
|
|
109
|
+
waves.push(wave);
|
|
110
|
+
}
|
|
111
|
+
return { ok: waves };
|
|
112
|
+
}
|
|
94
113
|
/** Prompt to execute a single atom in the context of the overall plan. */
|
|
95
114
|
export function atomPrompt(atom, plan, done) {
|
|
96
115
|
const priors = done.length ? `Already completed: ${done.map((a) => a.title).join("; ")}.\n` : "";
|