@nanhara/hara 0.33.0 → 0.53.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 +216 -1
- package/README.md +15 -4
- package/dist/agent/loop.js +16 -1
- package/dist/config.js +4 -2
- package/dist/hooks.js +64 -0
- package/dist/index.js +331 -77
- package/dist/notify.js +42 -0
- package/dist/org/planner.js +19 -0
- package/dist/plugins/plugins.js +14 -0
- package/dist/providers/anthropic.js +21 -11
- package/dist/search/semindex.js +62 -11
- package/dist/session/store.js +14 -0
- package/dist/tools/computer.js +156 -16
- package/dist/tools/todo.js +51 -0
- package/dist/tools/web.js +97 -0
- package/dist/tui/App.js +55 -7
- 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,17 +4,18 @@ 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";
|
|
17
17
|
import { runAgent } from "./agent/loop.js";
|
|
18
|
+
import { notifyDone } from "./notify.js";
|
|
18
19
|
import { getTools } from "./tools/registry.js";
|
|
19
20
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
20
21
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -24,14 +25,14 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
24
25
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
25
26
|
import { searchHybrid } from "./search/hybrid.js";
|
|
26
27
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
27
|
-
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, } from "./session/store.js";
|
|
28
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
28
29
|
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
29
30
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
30
|
-
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
|
|
31
|
+
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
31
32
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
32
|
-
import { decompose, topoOrder, savePlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
|
+
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
34
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
34
|
-
import { sandboxSupported } from "./sandbox.js";
|
|
35
|
+
import { sandboxSupported, runShell } from "./sandbox.js";
|
|
35
36
|
import { undoLast } from "./undo.js";
|
|
36
37
|
import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
|
|
37
38
|
import { c, out, statusLine } from "./ui.js";
|
|
@@ -46,6 +47,7 @@ import "./tools/agent.js"; // register agent (subagent spawn)
|
|
|
46
47
|
import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
|
|
47
48
|
import "./tools/skill.js"; // register the skill loader tool
|
|
48
49
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
50
|
+
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
49
51
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
50
52
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
53
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
@@ -133,7 +135,96 @@ function lastAssistantText(history) {
|
|
|
133
135
|
}
|
|
134
136
|
return "";
|
|
135
137
|
}
|
|
136
|
-
/**
|
|
138
|
+
/** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
|
|
139
|
+
async function executeAtom(atom, plan, done, roles, o) {
|
|
140
|
+
atom.status = "running";
|
|
141
|
+
savePlan(o.cwd, plan);
|
|
142
|
+
const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
|
|
143
|
+
const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
|
|
144
|
+
const toolFilter = role?.allowTools
|
|
145
|
+
? (n) => role.allowTools.includes(n)
|
|
146
|
+
: role?.denyTools
|
|
147
|
+
? (n) => !role.denyTools.includes(n)
|
|
148
|
+
: undefined;
|
|
149
|
+
const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
|
|
150
|
+
try {
|
|
151
|
+
await runAgent(history, {
|
|
152
|
+
provider: roleProvider,
|
|
153
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
154
|
+
approval: o.approval,
|
|
155
|
+
confirm: o.confirm,
|
|
156
|
+
projectContext: o.projectContext,
|
|
157
|
+
memory: memoryDigest(o.cwd),
|
|
158
|
+
stats: o.stats,
|
|
159
|
+
systemOverride: role?.system,
|
|
160
|
+
toolFilter,
|
|
161
|
+
quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
atom.status = "failed";
|
|
166
|
+
atom.note = e.message;
|
|
167
|
+
savePlan(o.cwd, plan);
|
|
168
|
+
out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
|
|
172
|
+
atom.status = v.ok ? "done" : "failed";
|
|
173
|
+
atom.note = v.reason;
|
|
174
|
+
savePlan(o.cwd, plan);
|
|
175
|
+
out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
|
|
176
|
+
return v.ok;
|
|
177
|
+
}
|
|
178
|
+
/** Execute a plan's atoms (sequential, or parallel waves with --parallel). Atoms already marked `done`
|
|
179
|
+
* are skipped — so this doubles as the resume engine. Stops on the first failure. */
|
|
180
|
+
async function executePlan(plan, roles, o) {
|
|
181
|
+
const done = plan.atoms.filter((a) => a.status === "done");
|
|
182
|
+
const doneIds = new Set(done.map((a) => a.id));
|
|
183
|
+
if (o.parallel) {
|
|
184
|
+
const waved = topoWaves(plan.atoms);
|
|
185
|
+
if ("error" in waved)
|
|
186
|
+
return void out(c.red(`${waved.error}\n`));
|
|
187
|
+
out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
|
|
188
|
+
for (const wave of waved.ok) {
|
|
189
|
+
const todo = wave.filter((a) => !doneIds.has(a.id));
|
|
190
|
+
if (!todo.length)
|
|
191
|
+
continue; // whole wave already complete (resume)
|
|
192
|
+
out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
|
|
193
|
+
const results = await Promise.all(todo.map((atom) => executeAtom(atom, plan, done, roles, o)));
|
|
194
|
+
todo.forEach((atom, i) => {
|
|
195
|
+
if (results[i]) {
|
|
196
|
+
done.push(atom);
|
|
197
|
+
doneIds.add(atom.id);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
if (results.some((r) => !r)) {
|
|
201
|
+
out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const ord = topoOrder(plan.atoms);
|
|
208
|
+
if ("error" in ord)
|
|
209
|
+
return void out(c.red(`${ord.error}\n`));
|
|
210
|
+
for (const atom of ord.ok) {
|
|
211
|
+
if (doneIds.has(atom.id))
|
|
212
|
+
continue; // resume: skip completed atoms
|
|
213
|
+
out(c.cyan(`\n▶ ${atom.id} ${atom.title}\n`));
|
|
214
|
+
if (await executeAtom(atom, plan, done, roles, o)) {
|
|
215
|
+
done.push(atom);
|
|
216
|
+
doneIds.add(atom.id);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
|
|
225
|
+
}
|
|
226
|
+
/** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
|
|
227
|
+
* With `parallel`, independent atoms (the same dependency wave) run concurrently. */
|
|
137
228
|
async function runPlan(task, o) {
|
|
138
229
|
const roles = loadRoles(o.cwd);
|
|
139
230
|
out(c.dim("Planning…\n"));
|
|
@@ -147,68 +238,75 @@ async function runPlan(task, o) {
|
|
|
147
238
|
out(c.red(`${ord.error}\n`));
|
|
148
239
|
return;
|
|
149
240
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
for (const a of ordered) {
|
|
241
|
+
out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
|
|
242
|
+
for (const a of ord.ok) {
|
|
153
243
|
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
244
|
}
|
|
155
245
|
if (o.approval !== "full-auto") {
|
|
156
|
-
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${
|
|
246
|
+
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
|
|
157
247
|
if (!ok)
|
|
158
248
|
return void out(c.dim("(cancelled)\n"));
|
|
159
249
|
}
|
|
160
250
|
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
|
-
|
|
251
|
+
await executePlan(plan, roles, o);
|
|
252
|
+
}
|
|
253
|
+
/** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
|
|
254
|
+
async function runResume(o) {
|
|
255
|
+
const roles = loadRoles(o.cwd);
|
|
256
|
+
const plan = loadPlan(o.cwd);
|
|
257
|
+
if (!plan)
|
|
258
|
+
return void out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
|
|
259
|
+
const remaining = plan.atoms.filter((a) => a.status !== "done");
|
|
260
|
+
if (!remaining.length)
|
|
261
|
+
return void out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
|
|
262
|
+
out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
|
|
263
|
+
for (const a of remaining)
|
|
264
|
+
out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
|
|
265
|
+
if (o.approval !== "full-auto") {
|
|
266
|
+
const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
|
|
267
|
+
if (!ok)
|
|
268
|
+
return void out(c.dim("(cancelled)\n"));
|
|
269
|
+
}
|
|
270
|
+
for (const a of plan.atoms)
|
|
271
|
+
if (a.status === "failed" || a.status === "running")
|
|
272
|
+
a.status = "pending"; // retry interrupted
|
|
273
|
+
savePlan(o.cwd, plan);
|
|
274
|
+
await executePlan(plan, roles, o);
|
|
275
|
+
}
|
|
276
|
+
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
|
|
277
|
+
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
|
|
278
|
+
"issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
|
|
279
|
+
"for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
|
|
280
|
+
"**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
|
|
281
|
+
const COMMIT_SYSTEM = "Write a git commit message for the staged diff. A concise imperative subject (≤72 chars; an optional " +
|
|
282
|
+
"conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
|
|
283
|
+
"non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
|
|
284
|
+
"Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
|
|
285
|
+
const SESSION_NAME_SYSTEM = "Name this coding session as a SHORT slug: 2–4 English words, lowercase, hyphen-separated, ASCII only " +
|
|
286
|
+
"(e.g. add-semantic-search, fix-login-redirect). If the conversation is in another language, translate the " +
|
|
287
|
+
"gist to English (use pinyin only if a term is untranslatable). Output ONLY the slug.";
|
|
288
|
+
/** One short model call → a 2–4 word English kebab-case session name summarizing the work.
|
|
289
|
+
* Always ASCII (translates non-English gist). Falls back to the lexical title on any failure. */
|
|
290
|
+
async function nameSession(provider, history) {
|
|
291
|
+
const text = (m) => {
|
|
292
|
+
if (!m)
|
|
293
|
+
return "";
|
|
294
|
+
if (m.role === "assistant")
|
|
295
|
+
return typeof m.text === "string" ? m.text : "";
|
|
296
|
+
if (m.role === "user")
|
|
297
|
+
return typeof m.content === "string" ? m.content : "";
|
|
298
|
+
return "";
|
|
299
|
+
};
|
|
300
|
+
const basis = `User: ${text(history.find((m) => m.role === "user")).slice(0, 800)}\n` +
|
|
301
|
+
`Assistant: ${text(history.find((m) => m.role === "assistant")).slice(0, 800)}`;
|
|
302
|
+
try {
|
|
303
|
+
const r = await provider.turn({ system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } });
|
|
304
|
+
return slugify(r.text) || titleFrom(history);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return titleFrom(history);
|
|
208
308
|
}
|
|
209
|
-
out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
|
|
210
309
|
}
|
|
211
|
-
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch"]);
|
|
212
310
|
const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
|
|
213
311
|
"then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
|
|
214
312
|
"End your message with the plan as a short numbered list.";
|
|
@@ -280,6 +378,8 @@ function runDoctor(cfg) {
|
|
|
280
378
|
`${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
|
|
281
379
|
`${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
|
|
282
380
|
`${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
|
|
381
|
+
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
382
|
+
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
283
383
|
];
|
|
284
384
|
return lines.join("\n");
|
|
285
385
|
}
|
|
@@ -359,9 +459,10 @@ program
|
|
|
359
459
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
360
460
|
});
|
|
361
461
|
program
|
|
362
|
-
.command("plan
|
|
462
|
+
.command("plan [task...]")
|
|
363
463
|
.description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
|
|
364
|
-
.
|
|
464
|
+
.option("--parallel", "run independent atoms (same dependency wave) concurrently")
|
|
465
|
+
.action(async (taskParts, opts) => {
|
|
365
466
|
const cfg = loadConfig();
|
|
366
467
|
const provider = await buildProvider(cfg);
|
|
367
468
|
if (!provider) {
|
|
@@ -369,7 +470,7 @@ program
|
|
|
369
470
|
process.exit(1);
|
|
370
471
|
}
|
|
371
472
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
372
|
-
|
|
473
|
+
const o = {
|
|
373
474
|
cfg,
|
|
374
475
|
baseProvider: provider,
|
|
375
476
|
cwd: cfg.cwd,
|
|
@@ -378,7 +479,15 @@ program
|
|
|
378
479
|
confirm: async () => true,
|
|
379
480
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
380
481
|
stats,
|
|
381
|
-
|
|
482
|
+
parallel: opts.parallel,
|
|
483
|
+
};
|
|
484
|
+
const task = (taskParts ?? []).join(" ").trim();
|
|
485
|
+
if (task === "resume")
|
|
486
|
+
await runResume(o);
|
|
487
|
+
else if (!task)
|
|
488
|
+
out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
|
|
489
|
+
else
|
|
490
|
+
await runPlan(task, o);
|
|
382
491
|
if (stats.input || stats.output)
|
|
383
492
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
384
493
|
});
|
|
@@ -424,10 +533,11 @@ program
|
|
|
424
533
|
const build = async (name, chunks, blurb) => {
|
|
425
534
|
if (!chunks.length)
|
|
426
535
|
return void out(c.dim(`Nothing to index for ${name}.\n`));
|
|
427
|
-
out(c.dim(`
|
|
536
|
+
out(c.dim(`Indexing ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
|
|
428
537
|
try {
|
|
429
|
-
const
|
|
430
|
-
|
|
538
|
+
const r = await buildIndex(name, chunks, embed, cwd, model);
|
|
539
|
+
const detail = r.reused ? `${r.embedded} embedded, ${r.reused} reused` : `${r.embedded} embedded`;
|
|
540
|
+
out(c.green(`Indexed ${r.total} chunks`) + c.dim(` (${detail}) → ${indexPath(name, cwd)} · ${blurb}`) + "\n");
|
|
431
541
|
}
|
|
432
542
|
catch (e) {
|
|
433
543
|
out(c.red(`Indexing ${name} failed: ${e.message}\n`));
|
|
@@ -445,6 +555,111 @@ program
|
|
|
445
555
|
.command("doctor")
|
|
446
556
|
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
447
557
|
.action(() => out(runDoctor(loadConfig()) + "\n"));
|
|
558
|
+
program
|
|
559
|
+
.command("review")
|
|
560
|
+
.description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
|
|
561
|
+
.option("--staged", "review only staged changes")
|
|
562
|
+
.option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
|
|
563
|
+
.action(async (opts) => {
|
|
564
|
+
const cfg = loadConfig();
|
|
565
|
+
const provider = await buildProvider(cfg);
|
|
566
|
+
if (!provider) {
|
|
567
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
568
|
+
process.exit(1);
|
|
569
|
+
}
|
|
570
|
+
const cmd = opts.base ? `git diff ${opts.base}` : opts.staged ? "git diff --staged" : "git diff HEAD";
|
|
571
|
+
let diff = "";
|
|
572
|
+
try {
|
|
573
|
+
diff = (await runShell(cmd, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
574
|
+
}
|
|
575
|
+
catch (e) {
|
|
576
|
+
return void out(c.red(`\`${cmd}\` failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
|
|
577
|
+
}
|
|
578
|
+
if (!diff.trim())
|
|
579
|
+
return void out(c.dim(`No changes to review (${cmd}).\n`));
|
|
580
|
+
out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
|
|
581
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
582
|
+
await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
|
|
583
|
+
provider,
|
|
584
|
+
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
585
|
+
approval: "full-auto",
|
|
586
|
+
confirm: async () => true,
|
|
587
|
+
systemOverride: REVIEW_SYSTEM,
|
|
588
|
+
toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
|
|
589
|
+
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
590
|
+
memory: memoryDigest(cfg.cwd),
|
|
591
|
+
stats,
|
|
592
|
+
});
|
|
593
|
+
if (stats.input || stats.output)
|
|
594
|
+
out("\n" + statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
595
|
+
});
|
|
596
|
+
program
|
|
597
|
+
.command("commit")
|
|
598
|
+
.description("generate a commit message from staged changes and commit (-y to skip the confirm)")
|
|
599
|
+
.option("-a, --all", "stage all tracked changes first (git add -u)")
|
|
600
|
+
.action(async (opts) => {
|
|
601
|
+
const skipConfirm = !!program.opts().yes; // reuse the global -y/--yes (auto-approve)
|
|
602
|
+
const cfg = loadConfig();
|
|
603
|
+
const provider = await buildProvider(cfg);
|
|
604
|
+
if (!provider) {
|
|
605
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
if (opts.all) {
|
|
609
|
+
try {
|
|
610
|
+
await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
/* report below if nothing is staged */
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
let diff = "";
|
|
617
|
+
try {
|
|
618
|
+
diff = (await runShell("git diff --staged", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
619
|
+
}
|
|
620
|
+
catch (e) {
|
|
621
|
+
return void out(c.red(`git diff failed: ${e instanceof Error ? e.message : String(e)}\n`) + c.dim("(is this a git repo?)\n"));
|
|
622
|
+
}
|
|
623
|
+
if (!diff.trim())
|
|
624
|
+
return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
|
|
625
|
+
out(c.dim("Writing a commit message…\n"));
|
|
626
|
+
const r = await provider.turn({
|
|
627
|
+
system: COMMIT_SYSTEM,
|
|
628
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
|
|
629
|
+
tools: [],
|
|
630
|
+
onText: () => { },
|
|
631
|
+
});
|
|
632
|
+
if (r.stop === "error")
|
|
633
|
+
return void out(c.red(`message generation failed: ${r.errorMsg ?? "provider error"}\n`));
|
|
634
|
+
const msg = r.text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
|
|
635
|
+
if (!msg)
|
|
636
|
+
return void out(c.red("No commit message produced — commit manually or retry.\n"));
|
|
637
|
+
out("\n" + c.bold("Proposed commit message:\n") + c.dim("─".repeat(48) + "\n") + msg + "\n" + c.dim("─".repeat(48)) + "\n\n");
|
|
638
|
+
if (!skipConfirm) {
|
|
639
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
640
|
+
const ans = (await rl.question(`Commit with this message? ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
641
|
+
rl.close();
|
|
642
|
+
if (ans === "n" || ans === "no")
|
|
643
|
+
return void out(c.dim("(cancelled — nothing committed)\n"));
|
|
644
|
+
}
|
|
645
|
+
const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
|
|
646
|
+
writeFileSync(tmp, msg + "\n", "utf8");
|
|
647
|
+
try {
|
|
648
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
649
|
+
out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
out(c.red(`git commit failed: ${e instanceof Error ? e.message : String(e)}\n`));
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
try {
|
|
656
|
+
rmSync(tmp);
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
/* best-effort cleanup */
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
});
|
|
448
663
|
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
449
664
|
rolesCmd
|
|
450
665
|
.command("init")
|
|
@@ -965,19 +1180,35 @@ program.action(async (opts) => {
|
|
|
965
1180
|
visionProvider = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
966
1181
|
return visionProvider;
|
|
967
1182
|
};
|
|
968
|
-
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model)
|
|
969
|
-
|
|
1183
|
+
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
1184
|
+
// Uses the screenshot-tuned prompt (actionable UI elements + positions) + an optional focus hint, so a
|
|
1185
|
+
// text-only main model gets something it can click on rather than a generic transcription.
|
|
1186
|
+
const describeScreenshot = async (path, hint) => {
|
|
970
1187
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
971
1188
|
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
972
1189
|
if (!vp)
|
|
973
1190
|
return "";
|
|
974
1191
|
try {
|
|
975
|
-
return await describeImages(vp, [{ path, mediaType: "image/png" }]);
|
|
1192
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
976
1193
|
}
|
|
977
1194
|
catch {
|
|
978
1195
|
return "";
|
|
979
1196
|
}
|
|
980
1197
|
};
|
|
1198
|
+
// grounding for accurate RPA: ask the vision model WHERE an element is (0..1 fractions) so the computer
|
|
1199
|
+
// tool can click it precisely instead of guessing pixels from a text description.
|
|
1200
|
+
const locateScreenshot = async (path, target) => {
|
|
1201
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1202
|
+
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
1203
|
+
if (!vp)
|
|
1204
|
+
return null;
|
|
1205
|
+
try {
|
|
1206
|
+
return await locateImage(vp, { path, mediaType: "image/png" }, target);
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
return null;
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
981
1212
|
const remindVision = (sink) => {
|
|
982
1213
|
if (remindedVision)
|
|
983
1214
|
return void sink.notice(`⚠ image skipped — ${cfg.model} is text-only. Add a vision model: /vision <model>`);
|
|
@@ -1196,6 +1427,22 @@ program.action(async (opts) => {
|
|
|
1196
1427
|
}
|
|
1197
1428
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1198
1429
|
const appr = h.approval;
|
|
1430
|
+
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
1431
|
+
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
1432
|
+
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
1433
|
+
const pendingInput = async () => {
|
|
1434
|
+
const out = [];
|
|
1435
|
+
for (const it of h.drainQueue()) {
|
|
1436
|
+
const r2 = await resolveImages(it.images, h);
|
|
1437
|
+
const body = expandMentions(it.line, cwd) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
1438
|
+
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
1439
|
+
if (!body.trim() && !attach)
|
|
1440
|
+
continue; // image-only message whose image was skipped → nothing to add
|
|
1441
|
+
out.push({ role: "user", content: `[I sent this while you were working on the above]\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
1442
|
+
}
|
|
1443
|
+
return out;
|
|
1444
|
+
};
|
|
1445
|
+
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
1199
1446
|
if (appr === "plan") {
|
|
1200
1447
|
// PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
|
|
1201
1448
|
const planImg = await resolveImages(images, h);
|
|
@@ -1207,7 +1454,7 @@ program.action(async (opts) => {
|
|
|
1207
1454
|
const pout = stats.output;
|
|
1208
1455
|
await runAgent(history, {
|
|
1209
1456
|
provider,
|
|
1210
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1457
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1211
1458
|
approval: "suggest",
|
|
1212
1459
|
confirm: h.confirm,
|
|
1213
1460
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
@@ -1216,9 +1463,10 @@ program.action(async (opts) => {
|
|
|
1216
1463
|
projectContext,
|
|
1217
1464
|
stats,
|
|
1218
1465
|
signal: h.signal,
|
|
1466
|
+
pendingInput,
|
|
1219
1467
|
});
|
|
1220
1468
|
if (!meta.title) {
|
|
1221
|
-
meta.title =
|
|
1469
|
+
meta.title = await nameSession(provider, history);
|
|
1222
1470
|
h.sink.session(meta.title);
|
|
1223
1471
|
}
|
|
1224
1472
|
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
@@ -1235,7 +1483,7 @@ program.action(async (opts) => {
|
|
|
1235
1483
|
const xout = stats.output;
|
|
1236
1484
|
await runAgent(history, {
|
|
1237
1485
|
provider,
|
|
1238
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1486
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1239
1487
|
approval: choice,
|
|
1240
1488
|
memory: buildMemory(),
|
|
1241
1489
|
confirm: h.confirm,
|
|
@@ -1243,10 +1491,12 @@ program.action(async (opts) => {
|
|
|
1243
1491
|
projectContext,
|
|
1244
1492
|
stats,
|
|
1245
1493
|
signal: h.signal,
|
|
1494
|
+
pendingInput,
|
|
1246
1495
|
});
|
|
1247
1496
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1248
1497
|
saveSession(meta, history);
|
|
1249
1498
|
}
|
|
1499
|
+
notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
|
|
1250
1500
|
return;
|
|
1251
1501
|
}
|
|
1252
1502
|
const ri = await resolveImages(images, h);
|
|
@@ -1259,7 +1509,7 @@ program.action(async (opts) => {
|
|
|
1259
1509
|
const beforeOut = stats.output;
|
|
1260
1510
|
await runAgent(history, {
|
|
1261
1511
|
provider,
|
|
1262
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
|
|
1512
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1263
1513
|
approval: appr,
|
|
1264
1514
|
memory: buildMemory(),
|
|
1265
1515
|
confirm: h.confirm,
|
|
@@ -1267,12 +1517,14 @@ program.action(async (opts) => {
|
|
|
1267
1517
|
projectContext,
|
|
1268
1518
|
stats,
|
|
1269
1519
|
signal: h.signal,
|
|
1520
|
+
pendingInput,
|
|
1270
1521
|
});
|
|
1271
1522
|
if (!meta.title) {
|
|
1272
|
-
meta.title =
|
|
1523
|
+
meta.title = await nameSession(provider, history);
|
|
1273
1524
|
h.sink.session(meta.title);
|
|
1274
1525
|
}
|
|
1275
1526
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
1527
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
|
|
1276
1528
|
saveSession(meta, history);
|
|
1277
1529
|
},
|
|
1278
1530
|
});
|
|
@@ -1319,6 +1571,7 @@ program.action(async (opts) => {
|
|
|
1319
1571
|
recalledContext = "";
|
|
1320
1572
|
history.push({ role: "user", content: userContent });
|
|
1321
1573
|
currentTurn = new AbortController();
|
|
1574
|
+
const t0 = Date.now();
|
|
1322
1575
|
try {
|
|
1323
1576
|
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
1324
1577
|
}
|
|
@@ -1328,8 +1581,9 @@ program.action(async (opts) => {
|
|
|
1328
1581
|
finally {
|
|
1329
1582
|
currentTurn = null;
|
|
1330
1583
|
}
|
|
1584
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
|
|
1331
1585
|
if (!meta.title)
|
|
1332
|
-
meta.title =
|
|
1586
|
+
meta.title = await nameSession(provider, history);
|
|
1333
1587
|
if (bar.isActive()) {
|
|
1334
1588
|
bar.update({
|
|
1335
1589
|
sessionName: meta.title,
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Task-done notifications — ping the user when a turn finishes (or needs them) so they can walk away
|
|
2
|
+
// during a long run (codex/Claude-Code parity). off = nothing; bell = terminal BEL; system = an OS
|
|
3
|
+
// notification (best-effort, fire-and-forget) + bell. Gated on elapsed so quick turns you watched stay quiet.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
|
+
export const NOTIFY_MODES = ["off", "bell", "system"];
|
|
7
|
+
/** AppleScript double-quoted string (escape " and \). */
|
|
8
|
+
const osaStr = (s) => '"' + s.replace(/[\\"]/g, "\\$&") + '"';
|
|
9
|
+
/** Fire a notification for a finished/awaiting turn. No-op under `off` or when the turn was quicker than
|
|
10
|
+
* `minMs` (default 8s) — you were watching those. `system` shells out without blocking and also rings the bell. */
|
|
11
|
+
export function notifyDone(mode, opts) {
|
|
12
|
+
if (mode === "off")
|
|
13
|
+
return;
|
|
14
|
+
if (opts.elapsedMs < (opts.minMs ?? 8000))
|
|
15
|
+
return;
|
|
16
|
+
const bell = () => {
|
|
17
|
+
try {
|
|
18
|
+
process.stderr.write("\x07");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* no tty */
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
if (mode === "bell")
|
|
25
|
+
return bell();
|
|
26
|
+
const title = (opts.title ?? "hara").slice(0, 80);
|
|
27
|
+
const msg = opts.message.slice(0, 200).replace(/\s*\n+\s*/g, " ").trim() || "done";
|
|
28
|
+
try {
|
|
29
|
+
const os = platform();
|
|
30
|
+
if (os === "darwin") {
|
|
31
|
+
spawn("osascript", ["-e", `display notification ${osaStr(msg)} with title ${osaStr(title)}`], { stdio: "ignore", detached: true }).unref();
|
|
32
|
+
}
|
|
33
|
+
else if (os === "linux") {
|
|
34
|
+
spawn("notify-send", ["-a", "hara", title, msg], { stdio: "ignore", detached: true }).unref();
|
|
35
|
+
}
|
|
36
|
+
// Windows (and any platform): the bell is the reliable cross-terminal signal; toast needs extra modules.
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* best-effort — a notification must never break the turn */
|
|
40
|
+
}
|
|
41
|
+
bell();
|
|
42
|
+
}
|