@nanhara/hara 0.0.2 → 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 +582 -0
- package/CLA.md +1 -1
- package/README.md +207 -10
- package/dist/activity.js +30 -0
- package/dist/agent/loop.js +184 -0
- package/dist/config.js +114 -0
- package/dist/context/agents-md.js +64 -0
- package/dist/context/mentions.js +90 -0
- package/dist/diff.js +103 -0
- package/dist/fs-walk.js +103 -0
- package/dist/fuzzy.js +62 -0
- package/dist/images.js +146 -0
- package/dist/index.js +1589 -0
- package/dist/mcp/client.js +54 -0
- package/dist/md.js +52 -0
- package/dist/memory/guard.js +51 -0
- package/dist/memory/store.js +93 -0
- package/dist/org/planner.js +174 -0
- package/dist/org/roles.js +140 -0
- package/dist/org/router.js +39 -0
- package/dist/plugins/plugins.js +124 -0
- package/dist/providers/anthropic.js +83 -0
- package/dist/providers/openai.js +125 -0
- package/dist/providers/qwen-oauth.js +139 -0
- package/dist/providers/types.js +2 -0
- package/dist/recall.js +76 -0
- package/dist/sandbox.js +78 -0
- package/dist/search/embed.js +42 -0
- package/dist/search/hybrid.js +38 -0
- package/dist/search/semindex.js +192 -0
- package/dist/session/store.js +109 -0
- package/dist/skills/skills.js +141 -0
- package/dist/statusbar.js +69 -0
- package/dist/tools/agent.js +26 -0
- package/dist/tools/apply-core.js +63 -0
- package/dist/tools/builtin.js +106 -0
- package/dist/tools/codebase.js +102 -0
- package/dist/tools/computer.js +376 -0
- package/dist/tools/edit.js +62 -0
- package/dist/tools/memory.js +147 -0
- package/dist/tools/patch.js +123 -0
- package/dist/tools/registry.js +18 -0
- package/dist/tools/search.js +176 -0
- package/dist/tools/skill.js +30 -0
- package/dist/tools/web.js +73 -0
- package/dist/tui/App.js +200 -0
- package/dist/tui/InputBox.js +208 -0
- package/dist/tui/run.js +10 -0
- package/dist/tui/theme.js +11 -0
- package/dist/ui.js +17 -0
- package/dist/undo.js +40 -0
- package/dist/vision.js +130 -0
- package/package.json +34 -9
- 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/LICENSE-MIT +0 -21
- package/bin/hara.mjs +0 -25
- /package/{LICENSE-APACHE → LICENSE} +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { emitKeypressEvents } from "node:readline";
|
|
5
|
+
import { runTui } from "./tui/run.js";
|
|
6
|
+
import { readClipboardImage } from "./images.js";
|
|
7
|
+
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
|
+
import { setTheme } from "./tui/theme.js";
|
|
9
|
+
import { memoryDigest, memoryDir } from "./memory/store.js";
|
|
10
|
+
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
|
+
import { stdin, stdout } from "node:process";
|
|
12
|
+
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { homedir, tmpdir } from "node:os";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
17
|
+
import { runAgent } from "./agent/loop.js";
|
|
18
|
+
import { getTools } from "./tools/registry.js";
|
|
19
|
+
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
20
|
+
import { createOpenAIProvider } from "./providers/openai.js";
|
|
21
|
+
import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
|
|
22
|
+
import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
23
|
+
import { getEmbedder } from "./search/embed.js";
|
|
24
|
+
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
25
|
+
import { searchHybrid } from "./search/hybrid.js";
|
|
26
|
+
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
27
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
28
|
+
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
29
|
+
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
30
|
+
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
|
|
31
|
+
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
32
|
+
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
|
+
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
34
|
+
import { sandboxSupported, runShell } from "./sandbox.js";
|
|
35
|
+
import { undoLast } from "./undo.js";
|
|
36
|
+
import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
|
|
37
|
+
import { c, out, statusLine } from "./ui.js";
|
|
38
|
+
import * as bar from "./statusbar.js";
|
|
39
|
+
import { nearest } from "./fuzzy.js";
|
|
40
|
+
import "./tools/builtin.js"; // register read_file/write_file/bash
|
|
41
|
+
import "./tools/edit.js"; // register edit_file
|
|
42
|
+
import "./tools/search.js"; // register grep/glob/ls
|
|
43
|
+
import "./tools/patch.js"; // register apply_patch
|
|
44
|
+
import "./tools/web.js"; // register web_fetch
|
|
45
|
+
import "./tools/agent.js"; // register agent (subagent spawn)
|
|
46
|
+
import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
|
|
47
|
+
import "./tools/skill.js"; // register the skill loader tool
|
|
48
|
+
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
49
|
+
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
50
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
52
|
+
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
53
|
+
async function buildProvider(cfg) {
|
|
54
|
+
if (cfg.provider === "qwen-oauth") {
|
|
55
|
+
const auth = await getValidQwenAuth();
|
|
56
|
+
if (!auth)
|
|
57
|
+
return null;
|
|
58
|
+
return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
|
|
59
|
+
}
|
|
60
|
+
if (!cfg.apiKey)
|
|
61
|
+
return null;
|
|
62
|
+
if (cfg.provider === "anthropic") {
|
|
63
|
+
return createAnthropicProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL });
|
|
64
|
+
}
|
|
65
|
+
return createOpenAIProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL, label: cfg.provider });
|
|
66
|
+
}
|
|
67
|
+
function authHint(cfg) {
|
|
68
|
+
if (cfg.provider === "qwen-oauth")
|
|
69
|
+
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
70
|
+
return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara config set apiKey <key>")}.`;
|
|
71
|
+
}
|
|
72
|
+
async function runInit(provider, cwd, sandbox = "off") {
|
|
73
|
+
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
74
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
|
|
75
|
+
}
|
|
76
|
+
/** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
|
|
77
|
+
async function runOrg(task, o) {
|
|
78
|
+
const roles = loadRoles(o.cwd);
|
|
79
|
+
if (!roles.length) {
|
|
80
|
+
out(c.yellow("No roles defined — run ") + c.bold("hara roles init") + c.yellow(" to scaffold some.\n"));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let role;
|
|
84
|
+
if (o.forceRole) {
|
|
85
|
+
role = roles.find((r) => r.id === o.forceRole);
|
|
86
|
+
if (!role) {
|
|
87
|
+
out(c.red(`No role '${o.forceRole}'. Available: ${roles.map((r) => r.id).join(", ")}\n`));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const kw = routeByKeywords(task, roles);
|
|
93
|
+
if (kw) {
|
|
94
|
+
role = kw.role;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const r = await o.baseProvider.turn({
|
|
98
|
+
system: "You are a task dispatcher. Reply with only a role id.",
|
|
99
|
+
history: [{ role: "user", content: buildDispatchPrompt(task, roles) }],
|
|
100
|
+
tools: [],
|
|
101
|
+
onText: () => { },
|
|
102
|
+
});
|
|
103
|
+
role = parseRoleId(r.text, roles) ?? roles[0];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
out(c.dim(`→ ${role.id} owns this task\n`));
|
|
107
|
+
const roleProvider = role.model && role.model !== o.cfg.model
|
|
108
|
+
? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider)
|
|
109
|
+
: o.baseProvider;
|
|
110
|
+
const toolFilter = role.allowTools
|
|
111
|
+
? (n) => role.allowTools.includes(n)
|
|
112
|
+
: role.denyTools
|
|
113
|
+
? (n) => !role.denyTools.includes(n)
|
|
114
|
+
: undefined;
|
|
115
|
+
const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
|
|
116
|
+
await runAgent(history, {
|
|
117
|
+
provider: roleProvider,
|
|
118
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
119
|
+
approval: o.approval,
|
|
120
|
+
confirm: o.confirm,
|
|
121
|
+
projectContext: o.projectContext,
|
|
122
|
+
memory: memoryDigest(o.cwd),
|
|
123
|
+
stats: o.stats,
|
|
124
|
+
systemOverride: role.system,
|
|
125
|
+
toolFilter,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function lastAssistantText(history) {
|
|
129
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
130
|
+
const m = history[i];
|
|
131
|
+
if (m.role === "assistant" && typeof m.text === "string")
|
|
132
|
+
return m.text;
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
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. */
|
|
226
|
+
async function runPlan(task, o) {
|
|
227
|
+
const roles = loadRoles(o.cwd);
|
|
228
|
+
out(c.dim("Planning…\n"));
|
|
229
|
+
const plan = await decompose(o.baseProvider, task, roles);
|
|
230
|
+
if (!plan.atoms.length) {
|
|
231
|
+
out(c.red("Planner returned no atoms — try rephrasing the task.\n"));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const ord = topoOrder(plan.atoms);
|
|
235
|
+
if ("error" in ord) {
|
|
236
|
+
out(c.red(`${ord.error}\n`));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
|
|
240
|
+
for (const a of ord.ok) {
|
|
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`);
|
|
242
|
+
}
|
|
243
|
+
if (o.approval !== "full-auto") {
|
|
244
|
+
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
|
|
245
|
+
if (!ok)
|
|
246
|
+
return void out(c.dim("(cancelled)\n"));
|
|
247
|
+
}
|
|
248
|
+
savePlan(o.cwd, plan);
|
|
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);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
|
|
309
|
+
"then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
|
|
310
|
+
"End your message with the plan as a short numbered list.";
|
|
311
|
+
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
312
|
+
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
313
|
+
const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
|
|
314
|
+
"continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
|
|
315
|
+
"Be specific. Output only the summary.";
|
|
316
|
+
const workingSetFromSummary = (s) => s
|
|
317
|
+
.split("\n")
|
|
318
|
+
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
319
|
+
.filter((l) => l.length > 3)
|
|
320
|
+
.slice(0, 12)
|
|
321
|
+
.map((l) => l.slice(0, 140));
|
|
322
|
+
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
323
|
+
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
324
|
+
const roles = loadRoles(cwd);
|
|
325
|
+
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
326
|
+
const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
|
|
327
|
+
const toolFilter = role?.allowTools
|
|
328
|
+
? (n) => role.allowTools.includes(n)
|
|
329
|
+
: role?.denyTools
|
|
330
|
+
? (n) => !role.denyTools.includes(n)
|
|
331
|
+
: (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
|
|
332
|
+
const subHistory = [{ role: "user", content: task }];
|
|
333
|
+
await runAgent(subHistory, {
|
|
334
|
+
provider,
|
|
335
|
+
ctx: { cwd, sandbox }, // no `spawn` here → sub-agents can't recurse
|
|
336
|
+
approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
|
|
337
|
+
confirm: async () => true,
|
|
338
|
+
projectContext,
|
|
339
|
+
memory: memoryDigest(cwd),
|
|
340
|
+
stats,
|
|
341
|
+
systemOverride: role?.system,
|
|
342
|
+
toolFilter,
|
|
343
|
+
quiet: true,
|
|
344
|
+
});
|
|
345
|
+
for (let i = subHistory.length - 1; i >= 0; i--) {
|
|
346
|
+
const m = subHistory[i];
|
|
347
|
+
if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
|
|
348
|
+
return m.text.trim();
|
|
349
|
+
}
|
|
350
|
+
return "(sub-agent produced no output)";
|
|
351
|
+
}
|
|
352
|
+
/** Check the hara setup and print a health summary (provider/auth/model/node/assets/roles). */
|
|
353
|
+
function runDoctor(cfg) {
|
|
354
|
+
const ok = (b) => (b ? c.green("✓") : c.red("✗"));
|
|
355
|
+
const dot = c.dim("·");
|
|
356
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
357
|
+
const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
|
|
358
|
+
const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
|
|
359
|
+
const authed = hasKey || oauthOk;
|
|
360
|
+
const ad = assetsDir();
|
|
361
|
+
const roles = loadRoles(cfg.cwd);
|
|
362
|
+
const vcap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
363
|
+
const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
|
|
364
|
+
const lines = [
|
|
365
|
+
c.bold("hara doctor"),
|
|
366
|
+
`${ok(nodeMajor >= 20)} node ${process.versions.node} ${c.dim("(need ≥20)")}`,
|
|
367
|
+
`${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
|
|
368
|
+
`${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
|
|
369
|
+
`${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
|
|
370
|
+
`${dot} code-assets ${existsSync(ad) ? c.dim(ad) : c.dim("none — run: hara recall --init")}`,
|
|
371
|
+
`${dot} roles ${roles.length ? c.dim(roles.map((r) => r.id).join(", ")) : c.dim("none — run: hara roles init")}`,
|
|
372
|
+
`${dot} skills ${(() => { const n = loadSkillIndex(cfg.cwd).length; return n ? c.dim(`${n} (${loadSkillIndex(cfg.cwd).map((s) => s.id).slice(0, 6).join(", ")})`) : c.dim("none — run: hara skills init"); })()}`,
|
|
373
|
+
`${dot} memory ${existsSync(join(homedir(), ".hara", "memory")) ? c.dim("~/.hara/memory + project") : c.dim("none yet (created on first write)")} ${c.dim("· evolve")} ${c.bold(cfg.evolve)} ${c.dim("· capture")} ${c.bold(cfg.assetCapture)}`,
|
|
374
|
+
`${dot} search ${c.dim("lexical (always on)")}${cfg.embedProvider === "off" ? c.dim(" · semantic off (hara config set embedProvider ollama|qwen)") : c.dim(" · semantic ") + c.bold(cfg.embedProvider) + (() => { const idx = ["repo", "assets", "memory"].filter((n) => indexExists(n, cfg.cwd)); return c.dim(" · indexed: ") + (idx.length ? c.green(idx.join(", ")) : c.yellow("none — run: hara index --all")); })()}`,
|
|
375
|
+
`${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
|
|
376
|
+
`${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"}`)}`,
|
|
377
|
+
`${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>"); })()}`,
|
|
378
|
+
`${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
|
|
379
|
+
];
|
|
380
|
+
return lines.join("\n");
|
|
381
|
+
}
|
|
382
|
+
function mentionCompleter(line, cwd) {
|
|
383
|
+
const m = /@([^\s@]*)$/.exec(line);
|
|
384
|
+
if (!m)
|
|
385
|
+
return [[], line];
|
|
386
|
+
return [fileCandidates(cwd, m[1]).map((f) => "@" + f), "@" + m[1]];
|
|
387
|
+
}
|
|
388
|
+
function helpText(commands) {
|
|
389
|
+
const lines = commands.map((cmd) => ` /${cmd.name.padEnd(13)} ${c.dim(cmd.desc)}`);
|
|
390
|
+
return c.bold("Commands:\n") + lines.join("\n") + "\n" + c.dim(" @path attach a file's contents (Tab to complete)\n");
|
|
391
|
+
}
|
|
392
|
+
const program = new Command();
|
|
393
|
+
program
|
|
394
|
+
.name("hara")
|
|
395
|
+
.description("A coding agent CLI that runs like an engineering org.")
|
|
396
|
+
.version(pkg.version)
|
|
397
|
+
.option("-p, --print <prompt>", "run a single prompt non-interactively, then exit")
|
|
398
|
+
.option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
|
|
399
|
+
.option("-m, --model <model>", "model id (overrides config)")
|
|
400
|
+
.option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
|
|
401
|
+
.option("--profile <name>", "use a named profile from ~/.hara/config.json")
|
|
402
|
+
.option("-c, --continue", "resume the most recent session in this directory")
|
|
403
|
+
.option("--resume <id>", "resume a specific session by id")
|
|
404
|
+
.option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
|
|
405
|
+
program
|
|
406
|
+
.command("init")
|
|
407
|
+
.description("analyze the project and (re)generate AGENTS.md")
|
|
408
|
+
.action(async () => {
|
|
409
|
+
const cfg = loadConfig();
|
|
410
|
+
const provider = await buildProvider(cfg);
|
|
411
|
+
if (!provider) {
|
|
412
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
out(c.dim("Analyzing project to generate AGENTS.md…\n"));
|
|
416
|
+
await runInit(provider, cfg.cwd, cfg.sandbox);
|
|
417
|
+
});
|
|
418
|
+
program
|
|
419
|
+
.command("sessions")
|
|
420
|
+
.description("list saved sessions")
|
|
421
|
+
.action(() => {
|
|
422
|
+
const metas = listSessions();
|
|
423
|
+
if (!metas.length) {
|
|
424
|
+
out(c.dim("No sessions yet.\n"));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
for (const m of metas) {
|
|
428
|
+
out(`${c.bold(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.provider}:${m.model} ${m.title}\n`);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
program
|
|
432
|
+
.command("org <task...>")
|
|
433
|
+
.description("dispatch a task to the owning role and run it")
|
|
434
|
+
.option("--role <id>", "force a specific role")
|
|
435
|
+
.action(async (taskParts, opts2) => {
|
|
436
|
+
const cfg = loadConfig();
|
|
437
|
+
const provider = await buildProvider(cfg);
|
|
438
|
+
if (!provider) {
|
|
439
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
443
|
+
await runOrg(taskParts.join(" "), {
|
|
444
|
+
cfg,
|
|
445
|
+
baseProvider: provider,
|
|
446
|
+
cwd: cfg.cwd,
|
|
447
|
+
sandbox: cfg.sandbox,
|
|
448
|
+
approval: "full-auto",
|
|
449
|
+
confirm: async () => true,
|
|
450
|
+
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
451
|
+
stats,
|
|
452
|
+
forceRole: opts2.role,
|
|
453
|
+
});
|
|
454
|
+
if (stats.input || stats.output)
|
|
455
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
456
|
+
});
|
|
457
|
+
program
|
|
458
|
+
.command("plan [task...]")
|
|
459
|
+
.description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
|
|
460
|
+
.option("--parallel", "run independent atoms (same dependency wave) concurrently")
|
|
461
|
+
.action(async (taskParts, opts) => {
|
|
462
|
+
const cfg = loadConfig();
|
|
463
|
+
const provider = await buildProvider(cfg);
|
|
464
|
+
if (!provider) {
|
|
465
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
466
|
+
process.exit(1);
|
|
467
|
+
}
|
|
468
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
469
|
+
const o = {
|
|
470
|
+
cfg,
|
|
471
|
+
baseProvider: provider,
|
|
472
|
+
cwd: cfg.cwd,
|
|
473
|
+
sandbox: cfg.sandbox,
|
|
474
|
+
approval: "full-auto",
|
|
475
|
+
confirm: async () => true,
|
|
476
|
+
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
477
|
+
stats,
|
|
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);
|
|
487
|
+
if (stats.input || stats.output)
|
|
488
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
489
|
+
});
|
|
490
|
+
program
|
|
491
|
+
.command("recall [query...]")
|
|
492
|
+
.description("search your code-asset library (~/.hara/code-assets) for snippets/playbooks")
|
|
493
|
+
.option("--init", "scaffold the code-assets directory with an example")
|
|
494
|
+
.action(async (parts, opts2) => {
|
|
495
|
+
if (opts2.init) {
|
|
496
|
+
const w = scaffoldAssets();
|
|
497
|
+
out(w.length ? c.green(`Scaffolded ${assetsDir()}: ${w.join(", ")}\n`) : c.dim(`Assets already exist at ${assetsDir()}\n`));
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const q = (parts ?? []).join(" ");
|
|
501
|
+
if (!q)
|
|
502
|
+
return void out(c.dim("usage: hara recall <query> (or: hara recall --init)\n"));
|
|
503
|
+
const hits = await searchHybrid(q, process.cwd(), { indexName: "assets", roots: assetSearchRoots(process.cwd()) });
|
|
504
|
+
if (!hits.length)
|
|
505
|
+
return void out(c.dim(`No matches in ${assetsDir()} (add .md files, or run: hara recall --init)\n`));
|
|
506
|
+
for (const h of hits)
|
|
507
|
+
out(`${c.cyan(h.path)} ${c.dim(h.title)}\n`);
|
|
508
|
+
});
|
|
509
|
+
program
|
|
510
|
+
.command("index")
|
|
511
|
+
.description("build the semantic index (opt-in; needs an embedding provider)")
|
|
512
|
+
.option("--repo", "index the current project — for codebase_search (default)")
|
|
513
|
+
.option("--assets", "index your global code-assets, skills & memory — for recall / memory_search")
|
|
514
|
+
.option("--all", "index everything")
|
|
515
|
+
.action(async (opts) => {
|
|
516
|
+
const cfg = loadConfig();
|
|
517
|
+
const embed = getEmbedder(cfg);
|
|
518
|
+
if (!embed) {
|
|
519
|
+
out(c.yellow("Semantic search is off — search stays lexical (which still works).\n"));
|
|
520
|
+
out(c.dim("Turn it on with an embedding provider, then re-run `hara index`:\n"));
|
|
521
|
+
out(c.dim(" hara config set embedProvider ollama # local & offline (needs Ollama + an embed model)\n"));
|
|
522
|
+
out(c.dim(" hara config set embedProvider qwen # DashScope text-embedding-v3 (uses your key)\n"));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const cwd = process.cwd();
|
|
526
|
+
const model = `${cfg.embedProvider}:${cfg.embedModel ?? "default"}`;
|
|
527
|
+
const doRepo = opts.all || opts.repo || (!opts.assets && !opts.all);
|
|
528
|
+
const doAssets = opts.all || opts.assets;
|
|
529
|
+
const build = async (name, chunks, blurb) => {
|
|
530
|
+
if (!chunks.length)
|
|
531
|
+
return void out(c.dim(`Nothing to index for ${name}.\n`));
|
|
532
|
+
out(c.dim(`Indexing ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
|
|
533
|
+
try {
|
|
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");
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
out(c.red(`Indexing ${name} failed: ${e.message}\n`));
|
|
540
|
+
out(c.dim("Check the embedding endpoint/key; search still works lexically.\n"));
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
if (doRepo)
|
|
544
|
+
await build("repo", collectRepoChunks(findProjectRoot(cwd)), "codebase_search");
|
|
545
|
+
if (doAssets) {
|
|
546
|
+
await build("assets", [...collectDirChunks(assetsDir(), "code-assets"), ...collectDirChunks(globalSkillsDir(), "skills")], "recall");
|
|
547
|
+
await build("memory", collectDirChunks(memoryDir("global", cwd), "memory"), "memory_search");
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
program
|
|
551
|
+
.command("doctor")
|
|
552
|
+
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
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
|
+
});
|
|
659
|
+
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
660
|
+
rolesCmd
|
|
661
|
+
.command("init")
|
|
662
|
+
.description("scaffold example roles")
|
|
663
|
+
.action(() => {
|
|
664
|
+
const written = scaffoldRoles(process.cwd());
|
|
665
|
+
out(written.length
|
|
666
|
+
? c.green(`Created ${written.length} file(s) in .hara/roles/: ${written.join(", ")}\n`)
|
|
667
|
+
: c.dim("Roles already exist in .hara/roles/.\n"));
|
|
668
|
+
});
|
|
669
|
+
rolesCmd.action(() => {
|
|
670
|
+
const roles = loadRoles(process.cwd());
|
|
671
|
+
if (!roles.length) {
|
|
672
|
+
out(c.dim("No roles. Run `hara roles init`.\n"));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
for (const r of roles) {
|
|
676
|
+
out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
|
|
680
|
+
skillsCmd
|
|
681
|
+
.command("init")
|
|
682
|
+
.description("scaffold an example skill")
|
|
683
|
+
.action(() => {
|
|
684
|
+
const written = scaffoldSkills(process.cwd());
|
|
685
|
+
out(written.length
|
|
686
|
+
? c.green(`Created an example skill: ${written.join(", ")}\n`)
|
|
687
|
+
: c.dim("Skills already exist in .hara/skills/.\n"));
|
|
688
|
+
});
|
|
689
|
+
skillsCmd.action(() => {
|
|
690
|
+
const skills = loadSkillIndex(process.cwd());
|
|
691
|
+
if (!skills.length) {
|
|
692
|
+
out(c.dim("No skills. Run `hara skills init`, or the agent saves them with skill_create.\n"));
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
for (const s of skills) {
|
|
696
|
+
out(`${c.bold(s.id)}${s.context === "fork" ? c.dim(" (fork)") : ""} ${c.dim(s.source)}\n ${s.description}\n`);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
const pluginCmd = program.command("plugin").description("manage plugins (bundle skills/roles/MCP servers)");
|
|
700
|
+
pluginCmd
|
|
701
|
+
.command("add <source>")
|
|
702
|
+
.description("install a plugin from file:<path> | github:<owner/repo> | git:<url>")
|
|
703
|
+
.action((source) => {
|
|
704
|
+
try {
|
|
705
|
+
const p = installPlugin(source);
|
|
706
|
+
setPluginEnabled(p.name, true);
|
|
707
|
+
const m = p.manifest;
|
|
708
|
+
const parts = [
|
|
709
|
+
m.skills?.length ? `${m.skills.length} skill dir(s)` : "",
|
|
710
|
+
m.agents?.length ? `${m.agents.length} role dir(s)` : "",
|
|
711
|
+
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
712
|
+
].filter(Boolean);
|
|
713
|
+
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
714
|
+
}
|
|
715
|
+
catch (e) {
|
|
716
|
+
out(c.red(`Install failed: ${e.message}\n`));
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
pluginCmd
|
|
720
|
+
.command("remove <name>")
|
|
721
|
+
.alias("uninstall")
|
|
722
|
+
.description("uninstall a plugin")
|
|
723
|
+
.action((name) => out(uninstallPlugin(name) ? c.green(`Removed ${name}\n`) : c.dim(`(no plugin '${name}')\n`)));
|
|
724
|
+
pluginCmd
|
|
725
|
+
.command("enable <name>")
|
|
726
|
+
.description("enable an installed plugin")
|
|
727
|
+
.action((name) => (setPluginEnabled(name, true), out(c.green(`Enabled ${name}\n`))));
|
|
728
|
+
pluginCmd
|
|
729
|
+
.command("disable <name>")
|
|
730
|
+
.description("disable an installed plugin (keeps it installed)")
|
|
731
|
+
.action((name) => (setPluginEnabled(name, false), out(c.green(`Disabled ${name}\n`))));
|
|
732
|
+
pluginCmd.action(() => {
|
|
733
|
+
const installed = listInstalled();
|
|
734
|
+
if (!installed.length)
|
|
735
|
+
return void out(c.dim("No plugins. Install with `hara plugin add <source>`.\n"));
|
|
736
|
+
const on = new Set(enabledPlugins().map((p) => p.name));
|
|
737
|
+
for (const p of installed) {
|
|
738
|
+
out(`${on.has(p.name) ? c.green("●") : c.dim("○")} ${c.bold(p.name)}@${p.version}${p.manifest.description ? c.dim(" " + p.manifest.description) : ""}\n`);
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
const login = program.command("login").description("authenticate a provider");
|
|
742
|
+
login
|
|
743
|
+
.command("qwen")
|
|
744
|
+
.description("Qwen OAuth device login (free 'Qwen Code' tier — same as OpenClaw)")
|
|
745
|
+
.action(async () => {
|
|
746
|
+
try {
|
|
747
|
+
await qwenDeviceLogin((m) => out(m + "\n"));
|
|
748
|
+
writeConfigValue("provider", "qwen-oauth");
|
|
749
|
+
writeConfigValue("model", "coder-model");
|
|
750
|
+
out(c.green("\n✓ Qwen OAuth complete — provider set to qwen-oauth (model coder-model).\n"));
|
|
751
|
+
}
|
|
752
|
+
catch (e) {
|
|
753
|
+
out(c.red(`\nQwen OAuth failed: ${e.message}\n`));
|
|
754
|
+
process.exit(1);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
const config = program.command("config").description("manage ~/.hara/config.json");
|
|
758
|
+
config
|
|
759
|
+
.command("set <key> <value>")
|
|
760
|
+
.description(`set a config value (keys: ${CONFIG_KEYS.join(" | ")})`)
|
|
761
|
+
.action((key, value) => {
|
|
762
|
+
if (!CONFIG_KEYS.includes(key)) {
|
|
763
|
+
out(c.red(`Unknown key '${key}'. Valid keys: ${CONFIG_KEYS.join(", ")}.\n`));
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
|
766
|
+
if (key === "approval" && !APPROVAL_MODES.includes(value)) {
|
|
767
|
+
out(c.red(`Invalid approval mode. One of: ${APPROVAL_MODES.join(", ")}.\n`));
|
|
768
|
+
process.exit(1);
|
|
769
|
+
}
|
|
770
|
+
if (key === "sandbox" && !SANDBOX_MODES.includes(value)) {
|
|
771
|
+
out(c.red(`Invalid sandbox mode. One of: ${SANDBOX_MODES.join(", ")}.\n`));
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
writeConfigValue(key, value);
|
|
775
|
+
out(c.green(`Set ${key} → ${configPath()}\n`));
|
|
776
|
+
});
|
|
777
|
+
config
|
|
778
|
+
.command("get [key]")
|
|
779
|
+
.description("show config (apiKey masked)")
|
|
780
|
+
.action((key) => {
|
|
781
|
+
const raw = readRawConfig();
|
|
782
|
+
if (key) {
|
|
783
|
+
out((key === "apiKey" ? maskKey(raw.apiKey) : raw[key] ?? "(unset)") + "\n");
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
out(`path: ${configPath()}\n` +
|
|
787
|
+
`provider: ${raw.provider ?? "(default anthropic)"}\n` +
|
|
788
|
+
`model: ${raw.model ?? "(provider default)"}\n` +
|
|
789
|
+
`baseURL: ${raw.baseURL ?? "(provider default)"}\n` +
|
|
790
|
+
`approval: ${raw.approval ?? "(default suggest)"}\n` +
|
|
791
|
+
`sandbox: ${raw.sandbox ?? "(default off)"}\n` +
|
|
792
|
+
`apiKey: ${maskKey(raw.apiKey)}\n`);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
config
|
|
796
|
+
.command("path")
|
|
797
|
+
.description("print the config file path")
|
|
798
|
+
.action(() => out(configPath() + "\n"));
|
|
799
|
+
// default action (interactive REPL / one-shot)
|
|
800
|
+
program.action(async (opts) => {
|
|
801
|
+
const cfg = loadConfig({ profile: opts.profile });
|
|
802
|
+
if (opts.model)
|
|
803
|
+
cfg.model = opts.model;
|
|
804
|
+
const provider0 = await buildProvider(cfg);
|
|
805
|
+
if (!provider0) {
|
|
806
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
807
|
+
process.exit(1);
|
|
808
|
+
}
|
|
809
|
+
let provider = provider0;
|
|
810
|
+
const cwd = cfg.cwd;
|
|
811
|
+
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
812
|
+
let currentTurn = null; // set during a running turn so Esc can abort it
|
|
813
|
+
const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
|
|
814
|
+
let recalledContext = ""; // snippets queued by /recall, prepended to the next message
|
|
815
|
+
const sandbox = opts.sandbox || cfg.sandbox;
|
|
816
|
+
if (sandbox !== "off" && !sandboxSupported()) {
|
|
817
|
+
out(c.yellow(`(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`));
|
|
818
|
+
}
|
|
819
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
820
|
+
const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
|
|
821
|
+
if (Object.keys(mcpAll).length) {
|
|
822
|
+
await connectMcpServers(mcpAll, (m) => out(c.dim(m + "\n")));
|
|
823
|
+
}
|
|
824
|
+
// one-shot
|
|
825
|
+
if (opts.print) {
|
|
826
|
+
const projectContext = loadAgentsMd(cwd) || undefined;
|
|
827
|
+
const history = [{ role: "user", content: expandMentions(String(opts.print), cwd) }];
|
|
828
|
+
await runAgent(history, {
|
|
829
|
+
provider,
|
|
830
|
+
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role) },
|
|
831
|
+
approval: "full-auto",
|
|
832
|
+
confirm: async () => true,
|
|
833
|
+
projectContext,
|
|
834
|
+
memory: memoryDigest(cwd),
|
|
835
|
+
stats,
|
|
836
|
+
});
|
|
837
|
+
if (stats.input || stats.output)
|
|
838
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
839
|
+
await closeMcp();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
|
|
843
|
+
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
844
|
+
out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
|
|
845
|
+
const rl = createInterface({
|
|
846
|
+
input: stdin,
|
|
847
|
+
output: stdout,
|
|
848
|
+
completer: (line) => {
|
|
849
|
+
const sm = /^\/(\w*)$/.exec(line); // `/<partial>` → complete command names
|
|
850
|
+
if (sm) {
|
|
851
|
+
const q = sm[1].toLowerCase();
|
|
852
|
+
return [[...byName.keys()].filter((n) => n.startsWith(q)).sort().map((n) => "/" + n), line];
|
|
853
|
+
}
|
|
854
|
+
return mentionCompleter(line, cwd);
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
const confirm = async (q) => (await rl.question(`${q} ${c.dim("[y/N]")} `)).trim().toLowerCase().startsWith("y");
|
|
858
|
+
// shift+tab cycles the approval mode (classic REPL only; the TUI handles its own keys).
|
|
859
|
+
// Bare /approval is the reliable fallback everywhere.
|
|
860
|
+
if (stdin.isTTY && !useTui) {
|
|
861
|
+
try {
|
|
862
|
+
emitKeypressEvents(stdin);
|
|
863
|
+
stdin.on("keypress", (_s, key) => {
|
|
864
|
+
if (key && key.shift && key.name === "tab") {
|
|
865
|
+
approval = bar.nextMode(approval);
|
|
866
|
+
if (bar.isActive())
|
|
867
|
+
bar.update({ approval });
|
|
868
|
+
}
|
|
869
|
+
else if (key?.name === "escape" && currentTurn) {
|
|
870
|
+
currentTurn.abort(); // interrupt the running turn
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
catch {
|
|
875
|
+
/* keypress unavailable; /approval still works */
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
if (!hasAgentsMd(cwd)) {
|
|
879
|
+
const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
880
|
+
if (ans === "" || ans.startsWith("y")) {
|
|
881
|
+
out(c.dim("Analyzing project…\n"));
|
|
882
|
+
try {
|
|
883
|
+
await runInit(provider, cwd, sandbox);
|
|
884
|
+
}
|
|
885
|
+
catch (e) {
|
|
886
|
+
out(c.red(`[init error] ${e.message}\n`));
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
let projectContext = loadAgentsMd(cwd) || undefined;
|
|
891
|
+
const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
|
|
892
|
+
// session: --resume <id> / --continue (latest in this cwd) / new
|
|
893
|
+
let resumed = null;
|
|
894
|
+
if (opts.resume) {
|
|
895
|
+
const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
|
|
896
|
+
resumed = rid ? loadSession(rid) : null;
|
|
897
|
+
if (!resumed)
|
|
898
|
+
out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
|
|
899
|
+
}
|
|
900
|
+
else if (opts.continue) {
|
|
901
|
+
resumed = latestForCwd(cwd);
|
|
902
|
+
if (!resumed)
|
|
903
|
+
out(c.dim("(no prior session in this directory; starting fresh)\n"));
|
|
904
|
+
}
|
|
905
|
+
const meta = resumed?.meta ?? {
|
|
906
|
+
id: newSessionId(),
|
|
907
|
+
cwd,
|
|
908
|
+
provider: cfg.provider,
|
|
909
|
+
model: cfg.model,
|
|
910
|
+
title: "",
|
|
911
|
+
createdAt: new Date().toISOString(),
|
|
912
|
+
updatedAt: "",
|
|
913
|
+
};
|
|
914
|
+
const history = resumed?.history ? [...resumed.history] : [];
|
|
915
|
+
const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
|
|
916
|
+
const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
|
|
917
|
+
if (resumed)
|
|
918
|
+
out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs)\n`));
|
|
919
|
+
// Vision describer state — shared by the `/vision` command (both REPLs) and the TUI image pipeline.
|
|
920
|
+
let visionProvider;
|
|
921
|
+
let remindedVision = false;
|
|
922
|
+
/** `/vision <model>` sets the describer; `/vision main yes|no|auto` sets the current model's capability. */
|
|
923
|
+
const applyVision = (arg) => {
|
|
924
|
+
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
|
925
|
+
if (parts.length === 0) {
|
|
926
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
927
|
+
return `vision — main ${cfg.model}: ${cap}${cap === "unknown" ? " (asks on first image)" : ""} · describer: ${cfg.visionModel || "(none — /vision <model>)"}`;
|
|
928
|
+
}
|
|
929
|
+
if (parts[0] === "main") {
|
|
930
|
+
const v = parts[1];
|
|
931
|
+
if (!v || !["yes", "no", "auto"].includes(v))
|
|
932
|
+
return "usage: /vision main yes|no|auto";
|
|
933
|
+
if (v === "auto") {
|
|
934
|
+
const m = { ...cfg.modelVision };
|
|
935
|
+
delete m[cfg.model];
|
|
936
|
+
cfg.modelVision = m;
|
|
937
|
+
setModelVisionOverride(cfg.model, null);
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
cfg.modelVision = { ...cfg.modelVision, [cfg.model]: v };
|
|
941
|
+
setModelVisionOverride(cfg.model, v);
|
|
942
|
+
}
|
|
943
|
+
return `(${cfg.model} vision = ${v})`;
|
|
944
|
+
}
|
|
945
|
+
const model = parts.join(" ");
|
|
946
|
+
cfg.visionModel = model;
|
|
947
|
+
visionProvider = undefined; // rebuild the describer with the new model
|
|
948
|
+
writeConfigValue("visionModel", model);
|
|
949
|
+
const warn = classifyVision(cfg.provider, model, cfg.modelVision) !== "vision" ? ` ⚠ ${model} isn't a known vision model — if it can't read images, pick a *-vl / vision model.` : "";
|
|
950
|
+
return `(visionModel → ${model}; text-only main models describe pasted images with it)${warn}`;
|
|
951
|
+
};
|
|
952
|
+
const commands = [
|
|
953
|
+
{ name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
|
|
954
|
+
{
|
|
955
|
+
name: "init",
|
|
956
|
+
desc: "analyze project & regenerate AGENTS.md",
|
|
957
|
+
run: async () => {
|
|
958
|
+
out(c.dim("Analyzing project…\n"));
|
|
959
|
+
try {
|
|
960
|
+
await runInit(provider, cwd, sandbox);
|
|
961
|
+
projectContext = loadAgentsMd(cwd) || undefined;
|
|
962
|
+
out(c.green("AGENTS.md updated.\n"));
|
|
963
|
+
}
|
|
964
|
+
catch (e) {
|
|
965
|
+
out(c.red(`[init error] ${e.message}\n`));
|
|
966
|
+
}
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
name: "tools",
|
|
971
|
+
desc: "list available tools",
|
|
972
|
+
run: () => {
|
|
973
|
+
out(c.bold("Tools:\n"));
|
|
974
|
+
for (const t of getTools())
|
|
975
|
+
out(` ${t.name}${t.kind !== "read" ? c.yellow(" *") : ""} ${c.dim(t.description)}\n`);
|
|
976
|
+
out(c.dim(" * may prompt for confirmation (depends on approval mode)\n"));
|
|
977
|
+
},
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
name: "model",
|
|
981
|
+
desc: "show or switch model: /model [id]",
|
|
982
|
+
run: async (a) => {
|
|
983
|
+
if (a) {
|
|
984
|
+
cfg.model = a;
|
|
985
|
+
visionProvider = undefined;
|
|
986
|
+
remindedVision = false;
|
|
987
|
+
const p = await buildProvider(cfg);
|
|
988
|
+
if (p) {
|
|
989
|
+
provider = p;
|
|
990
|
+
if (bar.isActive())
|
|
991
|
+
bar.update({ model: a });
|
|
992
|
+
out(c.dim(`(model → ${cfg.provider}:${a})\n`));
|
|
993
|
+
}
|
|
994
|
+
else
|
|
995
|
+
out(c.red("(could not rebuild provider)\n"));
|
|
996
|
+
}
|
|
997
|
+
else
|
|
998
|
+
out(`${cfg.provider}:${cfg.model}\n`);
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
name: "vision",
|
|
1003
|
+
desc: "vision describer: /vision <model> · /vision main yes|no|auto",
|
|
1004
|
+
run: (a) => void out(applyVision(a || "") + "\n"),
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
name: "approval",
|
|
1008
|
+
desc: `cycle/set approval: /approval [${APPROVAL_MODES.join("|")}]`,
|
|
1009
|
+
run: (a) => {
|
|
1010
|
+
if (a) {
|
|
1011
|
+
if (APPROVAL_MODES.includes(a))
|
|
1012
|
+
approval = a;
|
|
1013
|
+
else
|
|
1014
|
+
return void out(c.red(`Invalid mode. One of: ${APPROVAL_MODES.join(", ")}\n`));
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
approval = bar.nextMode(approval); // bare /approval cycles
|
|
1018
|
+
}
|
|
1019
|
+
bar.update({ approval });
|
|
1020
|
+
out(c.dim(`(approval → ${approval})\n`));
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
{ name: "usage", desc: "show token usage this session", run: () => void out(statusLine(cfg.model, stats.input, stats.output) + "\n") },
|
|
1024
|
+
{ name: "doctor", desc: "check your hara setup", run: () => void out(runDoctor(cfg) + "\n") },
|
|
1025
|
+
{
|
|
1026
|
+
name: "roles",
|
|
1027
|
+
desc: "list org roles",
|
|
1028
|
+
run: () => {
|
|
1029
|
+
const rs = loadRoles(cwd);
|
|
1030
|
+
if (!rs.length)
|
|
1031
|
+
return void out(c.dim("No roles. Run `hara roles init`.\n"));
|
|
1032
|
+
for (const r of rs)
|
|
1033
|
+
out(` ${r.id} ${c.dim("owns: " + r.owns.join(", "))}\n`);
|
|
1034
|
+
},
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
name: "skills",
|
|
1038
|
+
desc: "list available skills",
|
|
1039
|
+
run: () => {
|
|
1040
|
+
const ss = loadSkillIndex(cwd);
|
|
1041
|
+
if (!ss.length)
|
|
1042
|
+
return void out(c.dim("No skills. Run `hara skills init`.\n"));
|
|
1043
|
+
for (const s of ss)
|
|
1044
|
+
out(` ${s.id} ${c.dim(s.description)}\n`);
|
|
1045
|
+
},
|
|
1046
|
+
},
|
|
1047
|
+
{
|
|
1048
|
+
name: "skill",
|
|
1049
|
+
desc: "load a skill's instructions into your next message: /skill <id>",
|
|
1050
|
+
run: (a) => {
|
|
1051
|
+
if (!a)
|
|
1052
|
+
return void out(c.dim("usage: /skill <id>\n"));
|
|
1053
|
+
const sk = loadSkillIndex(cwd).find((s) => s.id === a.trim());
|
|
1054
|
+
if (!sk)
|
|
1055
|
+
return void out(c.dim(`(no skill '${a.trim()}')\n`));
|
|
1056
|
+
recalledContext += (recalledContext ? "\n\n" : "") + `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}`;
|
|
1057
|
+
out(c.green(`↗ loaded skill ${sk.id} (added to your next message)\n`));
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
name: "org",
|
|
1062
|
+
desc: "dispatch a task to the owning role: /org <task>",
|
|
1063
|
+
run: async (a) => {
|
|
1064
|
+
if (!a)
|
|
1065
|
+
return void out(c.dim("usage: /org <task>\n"));
|
|
1066
|
+
await runOrg(a, { cfg, baseProvider: provider, cwd, sandbox, approval, confirm, projectContext, stats });
|
|
1067
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
name: "plan",
|
|
1072
|
+
desc: "decompose + execute a task as atoms (DAG + verify): /plan <task>",
|
|
1073
|
+
run: async (a) => {
|
|
1074
|
+
if (!a)
|
|
1075
|
+
return void out(c.dim("usage: /plan <task>\n"));
|
|
1076
|
+
await runPlan(a, { cfg, baseProvider: provider, cwd, sandbox, approval, confirm, projectContext, stats });
|
|
1077
|
+
if (bar.isActive())
|
|
1078
|
+
bar.update({ input: stats.input, output: stats.output, ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0) });
|
|
1079
|
+
else
|
|
1080
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1081
|
+
},
|
|
1082
|
+
},
|
|
1083
|
+
{
|
|
1084
|
+
name: "sessions",
|
|
1085
|
+
desc: "list saved sessions",
|
|
1086
|
+
run: () => {
|
|
1087
|
+
const ms = listSessions();
|
|
1088
|
+
if (!ms.length)
|
|
1089
|
+
return void out(c.dim("No sessions yet.\n"));
|
|
1090
|
+
for (const m of ms)
|
|
1091
|
+
out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.title || "(untitled)"}\n`);
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
name: "undo",
|
|
1096
|
+
desc: "revert the last file change(s) made this session",
|
|
1097
|
+
run: async () => {
|
|
1098
|
+
const r = await undoLast();
|
|
1099
|
+
if ("error" in r)
|
|
1100
|
+
return void out(c.dim(`(${r.error})\n`));
|
|
1101
|
+
out(c.green(`↩ reverted: ${r.files.join(", ")}\n`));
|
|
1102
|
+
},
|
|
1103
|
+
},
|
|
1104
|
+
{
|
|
1105
|
+
name: "compact",
|
|
1106
|
+
desc: "summarize the conversation so far to free up context",
|
|
1107
|
+
run: async () => {
|
|
1108
|
+
if (history.length < 2)
|
|
1109
|
+
return void out(c.dim("(nothing to compact)\n"));
|
|
1110
|
+
out(c.dim("Compacting…\n"));
|
|
1111
|
+
const r = await provider.turn({
|
|
1112
|
+
system: COMPACT_SYSTEM,
|
|
1113
|
+
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1114
|
+
tools: [],
|
|
1115
|
+
onText: () => { },
|
|
1116
|
+
});
|
|
1117
|
+
if (r.stop === "error")
|
|
1118
|
+
return void out(c.red(`(compact failed: ${r.errorMsg})\n`));
|
|
1119
|
+
const summary = r.text.trim();
|
|
1120
|
+
if (!summary)
|
|
1121
|
+
return void out(c.dim("(compact produced nothing)\n"));
|
|
1122
|
+
meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects next turns
|
|
1123
|
+
history.length = 0;
|
|
1124
|
+
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1125
|
+
stats.input += r.usage?.input ?? 0;
|
|
1126
|
+
stats.output += r.usage?.output ?? 0;
|
|
1127
|
+
saveSession(meta, history);
|
|
1128
|
+
out(c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`));
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
{
|
|
1132
|
+
name: "recall",
|
|
1133
|
+
desc: "pull snippets from your code-asset library into context: /recall <query>",
|
|
1134
|
+
run: async (a) => {
|
|
1135
|
+
if (!a)
|
|
1136
|
+
return void out(c.dim("usage: /recall <query>\n"));
|
|
1137
|
+
const hits = await searchHybrid(a, cwd, { indexName: "assets", roots: assetSearchRoots(cwd), limit: 3 });
|
|
1138
|
+
if (!hits.length)
|
|
1139
|
+
return void out(c.dim(`(no matches in ${assetsDir()})\n`));
|
|
1140
|
+
const block = hits.map((h) => `Recalled \`${h.path}\` (${h.title}):\n${h.snippet}`).join("\n\n");
|
|
1141
|
+
recalledContext += (recalledContext ? "\n\n" : "") + block;
|
|
1142
|
+
out(c.green(`↗ recalled ${hits.length}: ${hits.map((h) => h.path).join(", ")} (added to your next message)\n`));
|
|
1143
|
+
},
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
name: "name",
|
|
1147
|
+
desc: "rename this session: /name <name>",
|
|
1148
|
+
run: (a) => {
|
|
1149
|
+
if (!a)
|
|
1150
|
+
return void out(c.dim(`session: ${meta.title || "(untitled)"} · ${meta.id}\n`));
|
|
1151
|
+
meta.title = a.slice(0, 32);
|
|
1152
|
+
if (bar.isActive())
|
|
1153
|
+
bar.update({ sessionName: meta.title });
|
|
1154
|
+
saveSession(meta, history);
|
|
1155
|
+
out(c.green(`(renamed → ${meta.title})\n`));
|
|
1156
|
+
},
|
|
1157
|
+
},
|
|
1158
|
+
{ name: "reset", aliases: ["clear"], desc: "clear conversation context", run: () => void ((history.length = 0), (recalledContext = ""), out(c.dim("(context cleared)\n"))) },
|
|
1159
|
+
{ name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
|
|
1160
|
+
];
|
|
1161
|
+
const byName = new Map();
|
|
1162
|
+
for (const cmd of commands) {
|
|
1163
|
+
byName.set(cmd.name, cmd);
|
|
1164
|
+
for (const a of cmd.aliases ?? [])
|
|
1165
|
+
byName.set(a, cmd);
|
|
1166
|
+
}
|
|
1167
|
+
if (useTui) {
|
|
1168
|
+
rl.close(); // hand stdin over to ink
|
|
1169
|
+
setTheme(cfg.theme);
|
|
1170
|
+
// Vision: a text-only main model routes pasted images through a describer (`visionModel`); a
|
|
1171
|
+
// vision-capable main model gets them inline (describer auto-suspended). Unknown models are asked
|
|
1172
|
+
// once and remembered per-model in cfg.modelVision. See classifyVision for the capability map.
|
|
1173
|
+
const getVisionProvider = async () => {
|
|
1174
|
+
if (visionProvider !== undefined)
|
|
1175
|
+
return visionProvider;
|
|
1176
|
+
visionProvider = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
1177
|
+
return visionProvider;
|
|
1178
|
+
};
|
|
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) => {
|
|
1183
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1184
|
+
const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
|
|
1185
|
+
if (!vp)
|
|
1186
|
+
return "";
|
|
1187
|
+
try {
|
|
1188
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
1191
|
+
return "";
|
|
1192
|
+
}
|
|
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
|
+
};
|
|
1208
|
+
const remindVision = (sink) => {
|
|
1209
|
+
if (remindedVision)
|
|
1210
|
+
return void sink.notice(`⚠ image skipped — ${cfg.model} is text-only. Add a vision model: /vision <model>`);
|
|
1211
|
+
remindedVision = true;
|
|
1212
|
+
sink.notice(`⚠ ${cfg.model} is text-only and can't see images, so your image was skipped.\n` +
|
|
1213
|
+
` Add a vision model to read images for it:\n` +
|
|
1214
|
+
` /vision qwen-vl-max ← sets it now (uses your current plan/key) and remembers it\n` +
|
|
1215
|
+
` It OCRs/describes each pasted image into text the model can act on.`);
|
|
1216
|
+
};
|
|
1217
|
+
const resolveImages = async (imgs, h) => {
|
|
1218
|
+
if (!imgs?.length)
|
|
1219
|
+
return {};
|
|
1220
|
+
let cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1221
|
+
if (cap === "unknown") {
|
|
1222
|
+
const ans = await h.select(`Can your model "${cfg.model}" understand images (vision)?`, [
|
|
1223
|
+
{ label: "Yes — send images to it directly", value: "yes" },
|
|
1224
|
+
{ label: "No — describe them with a vision model first", value: "no" },
|
|
1225
|
+
{ label: "Skip the image this time", value: "skip" },
|
|
1226
|
+
]);
|
|
1227
|
+
if (ans === "skip")
|
|
1228
|
+
return { skip: true };
|
|
1229
|
+
cap = ans === "yes" ? "vision" : "text";
|
|
1230
|
+
cfg.modelVision = { ...cfg.modelVision, [cfg.model]: ans };
|
|
1231
|
+
setModelVisionOverride(cfg.model, ans);
|
|
1232
|
+
h.sink.notice(`(remembered: ${cfg.model} ${ans === "yes" ? "supports images" : "is text-only"})`);
|
|
1233
|
+
}
|
|
1234
|
+
if (cap === "vision")
|
|
1235
|
+
return { attach: imgs }; // native vision — describer suspended
|
|
1236
|
+
if (!cfg.visionModel) {
|
|
1237
|
+
remindVision(h.sink);
|
|
1238
|
+
return { skip: true };
|
|
1239
|
+
}
|
|
1240
|
+
const vp = await getVisionProvider();
|
|
1241
|
+
if (!vp) {
|
|
1242
|
+
h.sink.notice(`(visionModel ${cfg.visionModel} unavailable — check visionApiKey/visionBaseURL)`);
|
|
1243
|
+
return { skip: true };
|
|
1244
|
+
}
|
|
1245
|
+
h.sink.notice(`✻ reading ${imgs.length} image${imgs.length === 1 ? "" : "s"} with ${cfg.visionModel}…`);
|
|
1246
|
+
try {
|
|
1247
|
+
const desc = await describeImages(vp, imgs, { signal: h.signal });
|
|
1248
|
+
return { extraText: `\n\n[Image description — via ${cfg.visionModel}]\n${desc}` };
|
|
1249
|
+
}
|
|
1250
|
+
catch (e) {
|
|
1251
|
+
const msg = h.signal?.aborted ? "image describe cancelled" : `image describe failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
1252
|
+
h.sink.notice(`(${msg})`);
|
|
1253
|
+
return { skip: true };
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
const mainCap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1257
|
+
const visionLine = mainCap === "vision"
|
|
1258
|
+
? `${cfg.model} reads images directly`
|
|
1259
|
+
: cfg.visionModel
|
|
1260
|
+
? `${cfg.model} is text-only → images read by ${cfg.visionModel}`
|
|
1261
|
+
: mainCap === "text"
|
|
1262
|
+
? `${cfg.model} is text-only — /vision <model> to read pasted images`
|
|
1263
|
+
: `${cfg.model} image support unknown — asked on first paste`;
|
|
1264
|
+
await runTui({
|
|
1265
|
+
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
1266
|
+
model: cfg.model,
|
|
1267
|
+
cwd,
|
|
1268
|
+
header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
|
|
1269
|
+
cycleApproval: (m) => cycleMode(m),
|
|
1270
|
+
onClipboardImage: readClipboardImage,
|
|
1271
|
+
onSubmit: async (line, h, images) => {
|
|
1272
|
+
if (line.startsWith("/")) {
|
|
1273
|
+
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
1274
|
+
const arg = rest.join(" ").trim();
|
|
1275
|
+
if (nm === "exit" || nm === "quit") {
|
|
1276
|
+
if (cfg.evolve === "proactive" && history.length >= 4) {
|
|
1277
|
+
h.sink.notice("✻ distilling session learnings…");
|
|
1278
|
+
try {
|
|
1279
|
+
await runAgent(history, {
|
|
1280
|
+
provider,
|
|
1281
|
+
ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice } },
|
|
1282
|
+
approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
|
|
1283
|
+
confirm: h.confirm,
|
|
1284
|
+
toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
|
|
1285
|
+
systemOverride: DISTILL_SYSTEM,
|
|
1286
|
+
memory: buildMemory(),
|
|
1287
|
+
stats,
|
|
1288
|
+
signal: h.signal,
|
|
1289
|
+
});
|
|
1290
|
+
saveSession(meta, history);
|
|
1291
|
+
}
|
|
1292
|
+
catch {
|
|
1293
|
+
/* exit anyway */
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return void h.exit();
|
|
1297
|
+
}
|
|
1298
|
+
if (nm === "help")
|
|
1299
|
+
return void h.sink.notice(commands.map((x) => `/${x.name} — ${x.desc}`).join("\n"));
|
|
1300
|
+
if (nm === "tools")
|
|
1301
|
+
return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
|
|
1302
|
+
if (nm === "reset" || nm === "clear") {
|
|
1303
|
+
history.length = 0;
|
|
1304
|
+
recalledContext = "";
|
|
1305
|
+
return void h.sink.notice("(context cleared)");
|
|
1306
|
+
}
|
|
1307
|
+
if (nm === "undo") {
|
|
1308
|
+
const r = await undoLast();
|
|
1309
|
+
return void h.sink.notice("error" in r ? `(${r.error})` : `↩ reverted: ${r.files.join(", ")}`);
|
|
1310
|
+
}
|
|
1311
|
+
if (nm === "model") {
|
|
1312
|
+
if (!arg)
|
|
1313
|
+
return void h.sink.notice(`model: ${cfg.provider}:${cfg.model}`);
|
|
1314
|
+
cfg.model = arg;
|
|
1315
|
+
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
1316
|
+
remindedVision = false;
|
|
1317
|
+
const p = await buildProvider(cfg);
|
|
1318
|
+
if (p) {
|
|
1319
|
+
provider = p;
|
|
1320
|
+
return void h.sink.notice(`(model → ${cfg.provider}:${arg})`);
|
|
1321
|
+
}
|
|
1322
|
+
return void h.sink.notice("(could not rebuild provider)");
|
|
1323
|
+
}
|
|
1324
|
+
if (nm === "recall") {
|
|
1325
|
+
if (!arg)
|
|
1326
|
+
return void h.sink.notice("usage: /recall <query>");
|
|
1327
|
+
const hits = await searchHybrid(arg, cwd, { indexName: "assets", roots: assetSearchRoots(cwd), limit: 3 });
|
|
1328
|
+
if (!hits.length)
|
|
1329
|
+
return void h.sink.notice(`(no matches in ${assetsDir()})`);
|
|
1330
|
+
recalledContext += (recalledContext ? "\n\n" : "") + hits.map((x) => `Recalled \`${x.path}\` (${x.title}):\n${x.snippet}`).join("\n\n");
|
|
1331
|
+
return void h.sink.notice(`↗ recalled ${hits.length}: ${hits.map((x) => x.path).join(", ")} (added to your next message)`);
|
|
1332
|
+
}
|
|
1333
|
+
if (nm === "name") {
|
|
1334
|
+
if (!arg)
|
|
1335
|
+
return void h.sink.notice(`session: ${meta.title || "(untitled)"} · ${meta.id}`);
|
|
1336
|
+
meta.title = arg.slice(0, 32);
|
|
1337
|
+
h.sink.session(meta.title);
|
|
1338
|
+
saveSession(meta, history);
|
|
1339
|
+
return void h.sink.notice(`(renamed → ${meta.title})`);
|
|
1340
|
+
}
|
|
1341
|
+
if (nm === "compact") {
|
|
1342
|
+
if (history.length < 2)
|
|
1343
|
+
return void h.sink.notice("(nothing to compact)");
|
|
1344
|
+
h.sink.notice("✻ compacting…");
|
|
1345
|
+
const cui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1346
|
+
if (cfg.evolve !== "off") {
|
|
1347
|
+
try {
|
|
1348
|
+
await runAgent(history, {
|
|
1349
|
+
provider,
|
|
1350
|
+
ctx: { cwd, sandbox, spawn, ui: cui },
|
|
1351
|
+
approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
|
|
1352
|
+
confirm: h.confirm,
|
|
1353
|
+
toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
|
|
1354
|
+
systemOverride: DISTILL_SYSTEM,
|
|
1355
|
+
memory: buildMemory(),
|
|
1356
|
+
stats,
|
|
1357
|
+
signal: h.signal,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
/* flush is best-effort */
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const cr = await provider.turn({
|
|
1365
|
+
system: COMPACT_SYSTEM,
|
|
1366
|
+
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1367
|
+
tools: [],
|
|
1368
|
+
onText: () => { },
|
|
1369
|
+
});
|
|
1370
|
+
if (cr.stop === "error")
|
|
1371
|
+
return void h.sink.notice(`(compact failed: ${cr.errorMsg})`);
|
|
1372
|
+
const summary = cr.text.trim();
|
|
1373
|
+
if (!summary)
|
|
1374
|
+
return void h.sink.notice("(compact produced nothing)");
|
|
1375
|
+
meta.workingSet = workingSetFromSummary(summary);
|
|
1376
|
+
history.length = 0;
|
|
1377
|
+
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1378
|
+
stats.input += cr.usage?.input ?? 0;
|
|
1379
|
+
stats.output += cr.usage?.output ?? 0;
|
|
1380
|
+
h.sink.usage(cr.usage?.input ?? 0, cr.usage?.output ?? 0);
|
|
1381
|
+
saveSession(meta, history);
|
|
1382
|
+
return void h.sink.notice(`(compacted — kept ${meta.workingSet.length} working-memory notes)`);
|
|
1383
|
+
}
|
|
1384
|
+
if (nm === "sessions") {
|
|
1385
|
+
const ms = listSessions();
|
|
1386
|
+
return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
|
|
1387
|
+
}
|
|
1388
|
+
if (nm === "usage")
|
|
1389
|
+
return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
|
|
1390
|
+
if (nm === "doctor")
|
|
1391
|
+
return void h.sink.notice(runDoctor(cfg).replace(/\[[0-9;]*m/g, ""));
|
|
1392
|
+
if (nm === "vision")
|
|
1393
|
+
return void h.sink.notice(applyVision(arg));
|
|
1394
|
+
if (nm === "roles") {
|
|
1395
|
+
const rs = loadRoles(cwd);
|
|
1396
|
+
return void h.sink.notice(rs.length ? rs.map((r) => ` ${r.id} — owns: ${r.owns.join(", ")}`).join("\n") : "No roles. Run `hara roles init`.");
|
|
1397
|
+
}
|
|
1398
|
+
if (nm === "skills") {
|
|
1399
|
+
const ss = loadSkillIndex(cwd);
|
|
1400
|
+
return void h.sink.notice(ss.length ? ss.map((s) => ` ${s.id} — ${s.description}`).join("\n") : "No skills. Run `hara skills init`.");
|
|
1401
|
+
}
|
|
1402
|
+
if (nm === "skill") {
|
|
1403
|
+
if (!arg)
|
|
1404
|
+
return void h.sink.notice("usage: /skill <id>");
|
|
1405
|
+
const sk = loadSkillIndex(cwd).find((s) => s.id === arg.trim());
|
|
1406
|
+
if (!sk)
|
|
1407
|
+
return void h.sink.notice(`(no skill '${arg.trim()}')`);
|
|
1408
|
+
recalledContext += (recalledContext ? "\n\n" : "") + `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}`;
|
|
1409
|
+
return void h.sink.notice(`↗ loaded skill ${sk.id} (added to your next message)`);
|
|
1410
|
+
}
|
|
1411
|
+
if (nm === "approval") {
|
|
1412
|
+
const all = ["suggest", "auto-edit", "full-auto", "plan"];
|
|
1413
|
+
if (arg && !all.includes(arg))
|
|
1414
|
+
return void h.sink.notice(`Invalid mode. One of: ${all.join(", ")}`);
|
|
1415
|
+
const m = (arg || cycleMode(h.approval));
|
|
1416
|
+
h.setApproval(m);
|
|
1417
|
+
return void h.sink.notice(`(approval → ${m})`);
|
|
1418
|
+
}
|
|
1419
|
+
if (byName.has(nm))
|
|
1420
|
+
return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
|
|
1421
|
+
const near = nearest(nm, [...byName.keys()]);
|
|
1422
|
+
return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
|
|
1423
|
+
}
|
|
1424
|
+
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1425
|
+
const appr = h.approval;
|
|
1426
|
+
if (appr === "plan") {
|
|
1427
|
+
// PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
|
|
1428
|
+
const planImg = await resolveImages(images, h);
|
|
1429
|
+
if (planImg.skip)
|
|
1430
|
+
return;
|
|
1431
|
+
history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
|
|
1432
|
+
recalledContext = "";
|
|
1433
|
+
const pin = stats.input;
|
|
1434
|
+
const pout = stats.output;
|
|
1435
|
+
await runAgent(history, {
|
|
1436
|
+
provider,
|
|
1437
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1438
|
+
approval: "suggest",
|
|
1439
|
+
confirm: h.confirm,
|
|
1440
|
+
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
1441
|
+
systemOverride: PLAN_SYSTEM,
|
|
1442
|
+
memory: buildMemory(),
|
|
1443
|
+
projectContext,
|
|
1444
|
+
stats,
|
|
1445
|
+
signal: h.signal,
|
|
1446
|
+
});
|
|
1447
|
+
if (!meta.title) {
|
|
1448
|
+
meta.title = await nameSession(provider, history);
|
|
1449
|
+
h.sink.session(meta.title);
|
|
1450
|
+
}
|
|
1451
|
+
h.sink.usage(stats.input - pin, stats.output - pout);
|
|
1452
|
+
saveSession(meta, history);
|
|
1453
|
+
const choice = await h.select("hara has a plan — proceed?", [
|
|
1454
|
+
{ label: "Yes, and auto-apply edits", value: "auto-edit" },
|
|
1455
|
+
{ label: "Yes, approve each edit", value: "suggest" },
|
|
1456
|
+
{ label: "No, keep planning (esc)", value: "no" },
|
|
1457
|
+
]);
|
|
1458
|
+
if (choice !== "no") {
|
|
1459
|
+
h.setApproval(choice);
|
|
1460
|
+
history.push({ role: "user", content: "Proceed: execute the plan above." });
|
|
1461
|
+
const xin = stats.input;
|
|
1462
|
+
const xout = stats.output;
|
|
1463
|
+
await runAgent(history, {
|
|
1464
|
+
provider,
|
|
1465
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1466
|
+
approval: choice,
|
|
1467
|
+
memory: buildMemory(),
|
|
1468
|
+
confirm: h.confirm,
|
|
1469
|
+
autoApprove,
|
|
1470
|
+
projectContext,
|
|
1471
|
+
stats,
|
|
1472
|
+
signal: h.signal,
|
|
1473
|
+
});
|
|
1474
|
+
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1475
|
+
saveSession(meta, history);
|
|
1476
|
+
}
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const ri = await resolveImages(images, h);
|
|
1480
|
+
if (ri.skip)
|
|
1481
|
+
return;
|
|
1482
|
+
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
|
|
1483
|
+
recalledContext = "";
|
|
1484
|
+
history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
|
|
1485
|
+
const beforeIn = stats.input;
|
|
1486
|
+
const beforeOut = stats.output;
|
|
1487
|
+
await runAgent(history, {
|
|
1488
|
+
provider,
|
|
1489
|
+
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
1490
|
+
approval: appr,
|
|
1491
|
+
memory: buildMemory(),
|
|
1492
|
+
confirm: h.confirm,
|
|
1493
|
+
autoApprove,
|
|
1494
|
+
projectContext,
|
|
1495
|
+
stats,
|
|
1496
|
+
signal: h.signal,
|
|
1497
|
+
});
|
|
1498
|
+
if (!meta.title) {
|
|
1499
|
+
meta.title = await nameSession(provider, history);
|
|
1500
|
+
h.sink.session(meta.title);
|
|
1501
|
+
}
|
|
1502
|
+
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
1503
|
+
saveSession(meta, history);
|
|
1504
|
+
},
|
|
1505
|
+
});
|
|
1506
|
+
await closeMcp();
|
|
1507
|
+
process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
1508
|
+
}
|
|
1509
|
+
out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${projectContext ? " (AGENTS.md loaded)" : ""}\n\n`));
|
|
1510
|
+
bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output });
|
|
1511
|
+
process.on("exit", () => {
|
|
1512
|
+
try {
|
|
1513
|
+
bar.uninstall();
|
|
1514
|
+
}
|
|
1515
|
+
catch {
|
|
1516
|
+
/* best-effort terminal reset */
|
|
1517
|
+
}
|
|
1518
|
+
});
|
|
1519
|
+
for (;;) {
|
|
1520
|
+
bar.renderTop(); // top border + session name
|
|
1521
|
+
let line;
|
|
1522
|
+
try {
|
|
1523
|
+
line = (await rl.question(c.cyan("› "))).trim();
|
|
1524
|
+
}
|
|
1525
|
+
catch {
|
|
1526
|
+
break;
|
|
1527
|
+
}
|
|
1528
|
+
bar.renderBottom(); // bottom border + modes/usage
|
|
1529
|
+
if (!line)
|
|
1530
|
+
continue;
|
|
1531
|
+
if (line.startsWith("/")) {
|
|
1532
|
+
const [name, ...rest] = line.slice(1).split(/\s+/);
|
|
1533
|
+
const cmd = byName.get(name);
|
|
1534
|
+
if (!cmd) {
|
|
1535
|
+
const near = nearest(name, [...byName.keys()]);
|
|
1536
|
+
const hint = near.length ? c.dim(` Did you mean ${near.map((n) => "/" + n).join(", ")}?`) : "";
|
|
1537
|
+
out(c.red(`Unknown command /${name}.`) + hint + c.dim(" — /help for the list.\n"));
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
const res = await cmd.run(rest.join(" "));
|
|
1541
|
+
if (res === "exit")
|
|
1542
|
+
break;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
|
|
1546
|
+
recalledContext = "";
|
|
1547
|
+
history.push({ role: "user", content: userContent });
|
|
1548
|
+
currentTurn = new AbortController();
|
|
1549
|
+
try {
|
|
1550
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
1551
|
+
}
|
|
1552
|
+
catch (e) {
|
|
1553
|
+
out(c.red(`\n[error] ${e.message}\n`));
|
|
1554
|
+
}
|
|
1555
|
+
finally {
|
|
1556
|
+
currentTurn = null;
|
|
1557
|
+
}
|
|
1558
|
+
if (!meta.title)
|
|
1559
|
+
meta.title = await nameSession(provider, history);
|
|
1560
|
+
if (bar.isActive()) {
|
|
1561
|
+
bar.update({
|
|
1562
|
+
sessionName: meta.title,
|
|
1563
|
+
input: stats.input,
|
|
1564
|
+
output: stats.output,
|
|
1565
|
+
ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0),
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
|
|
1570
|
+
}
|
|
1571
|
+
saveSession(meta, history);
|
|
1572
|
+
const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
1573
|
+
if (ctxPct >= 80)
|
|
1574
|
+
out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /reset to clear\n`));
|
|
1575
|
+
}
|
|
1576
|
+
bar.uninstall();
|
|
1577
|
+
rl.close();
|
|
1578
|
+
await closeMcp();
|
|
1579
|
+
});
|
|
1580
|
+
program.parseAsync().catch((e) => {
|
|
1581
|
+
try {
|
|
1582
|
+
bar.uninstall();
|
|
1583
|
+
}
|
|
1584
|
+
catch {
|
|
1585
|
+
/* ignore */
|
|
1586
|
+
}
|
|
1587
|
+
out(c.red(`\n[fatal] ${e?.message ?? e}\n`));
|
|
1588
|
+
process.exit(1);
|
|
1589
|
+
});
|