@nanhara/hara 0.53.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { runTui } from "./tui/run.js";
6
6
  import { readClipboardImage } from "./images.js";
7
7
  import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
8
8
  import { setTheme } from "./tui/theme.js";
9
- import { memoryDigest, memoryDir } from "./memory/store.js";
9
+ import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
10
10
  import { nextMode as cycleMode } from "./tui/InputBox.js";
11
11
  import { stdin, stdout } from "node:process";
12
12
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
@@ -16,6 +16,13 @@ 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
18
  import { notifyDone } from "./notify.js";
19
+ import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
20
+ import { completionScript } from "./completions.js";
21
+ import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
22
+ import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
23
+ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
24
+ import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
25
+ import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
19
26
  import { getTools } from "./tools/registry.js";
20
27
  import { createAnthropicProvider } from "./providers/anthropic.js";
21
28
  import { createOpenAIProvider } from "./providers/openai.js";
@@ -26,7 +33,7 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
26
33
  import { searchHybrid } from "./search/hybrid.js";
27
34
  import { expandMentions, fileCandidates } from "./context/mentions.js";
28
35
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
29
- import { loadRoles, scaffoldRoles } from "./org/roles.js";
36
+ import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
30
37
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
31
38
  import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
32
39
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
@@ -50,7 +57,19 @@ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge b
50
57
  import "./tools/todo.js"; // register todo_write (inline task checklist)
51
58
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
52
59
  const here = dirname(fileURLToPath(import.meta.url));
53
- const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
60
+ // Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
61
+ // else read package.json (npm install / `node dist`). The read is wrapped so the binary never hits it.
62
+ const pkg = {
63
+ version: process.env.HARA_BUILD_VERSION ??
64
+ ((() => {
65
+ try {
66
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
67
+ }
68
+ catch {
69
+ return "0.0.0";
70
+ }
71
+ })()),
72
+ };
54
73
  const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
55
74
  async function buildProvider(cfg) {
56
75
  if (cfg.provider === "qwen-oauth") {
@@ -75,6 +94,61 @@ async function runInit(provider, cwd, sandbox = "off") {
75
94
  const history = [{ role: "user", content: INIT_PROMPT }];
76
95
  await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
77
96
  }
97
+ /** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
98
+ * Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
99
+ async function autoCommit(provider, cwd) {
100
+ try {
101
+ await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
102
+ }
103
+ catch {
104
+ /* fall through — empty diff is reported below */
105
+ }
106
+ let diff = "";
107
+ try {
108
+ diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
109
+ }
110
+ catch (e) {
111
+ return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
112
+ }
113
+ if (!diff.trim())
114
+ return "nothing to commit";
115
+ const r = await provider.turn({
116
+ system: COMMIT_SYSTEM,
117
+ history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
118
+ tools: [],
119
+ onText: () => { },
120
+ });
121
+ const msg = stripCommitFence(r.text);
122
+ if (!msg)
123
+ return "error: no commit message produced";
124
+ const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
125
+ writeFileSync(tmp, msg + "\n", "utf8");
126
+ try {
127
+ const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
128
+ return (res.stdout || "").trim().split("\n")[0] || "committed";
129
+ }
130
+ catch (e) {
131
+ return `error: git commit failed (${e instanceof Error ? e.message : String(e)})`;
132
+ }
133
+ finally {
134
+ try {
135
+ rmSync(tmp);
136
+ }
137
+ catch {
138
+ /* best-effort cleanup */
139
+ }
140
+ }
141
+ }
142
+ /** Format an autoCommit result + emit it. */
143
+ async function commitStep(provider, cwd) {
144
+ const r = await autoCommit(provider, cwd);
145
+ if (r.startsWith("error:"))
146
+ out(c.red(`✗ ${r}\n`));
147
+ else if (r === "nothing to commit")
148
+ out(c.dim("(nothing to commit)\n"));
149
+ else
150
+ out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
151
+ }
78
152
  /** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
79
153
  async function runOrg(task, o) {
80
154
  const roles = loadRoles(o.cwd);
@@ -115,7 +189,7 @@ async function runOrg(task, o) {
115
189
  ? (n) => !role.denyTools.includes(n)
116
190
  : undefined;
117
191
  const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
118
- await runAgent(history, {
192
+ const runImplementer = () => runAgent(history, {
119
193
  provider: roleProvider,
120
194
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
121
195
  approval: o.approval,
@@ -126,6 +200,61 @@ async function runOrg(task, o) {
126
200
  systemOverride: role.system,
127
201
  toolFilter,
128
202
  });
203
+ const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
204
+ const doCommit = async (ok) => {
205
+ if (!o.commit)
206
+ return;
207
+ if (!ok)
208
+ return void out(c.yellow("(not committing — review didn't approve; changes left in your working tree)\n"));
209
+ if (!wasClean)
210
+ return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
211
+ await commitStep(o.baseProvider, o.cwd);
212
+ };
213
+ await runImplementer();
214
+ if (!o.review) {
215
+ await doCommit(true);
216
+ return;
217
+ }
218
+ // Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
219
+ const reviewer = roles.find((r) => r.id === "reviewer");
220
+ const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
221
+ const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
222
+ const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
223
+ const maxRounds = Math.max(1, o.rounds ?? 3);
224
+ for (let round = 1; round <= maxRounds; round++) {
225
+ const changes = captureChanges(o.cwd);
226
+ if (!changes.diff && !changes.newFiles.length) {
227
+ out(c.dim("(no changes to review)\n"));
228
+ return;
229
+ }
230
+ out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
231
+ const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
232
+ await runAgent(rHist, {
233
+ provider: revProvider,
234
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
235
+ approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
236
+ confirm: o.confirm,
237
+ projectContext: o.projectContext,
238
+ memory: memoryDigest(o.cwd),
239
+ stats: o.stats,
240
+ systemOverride: revSystem,
241
+ toolFilter: revTools,
242
+ });
243
+ const verdict = parseVerdict(lastAssistantText(rHist));
244
+ if (verdict.approved) {
245
+ out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
246
+ await doCommit(true);
247
+ return;
248
+ }
249
+ if (round === maxRounds) {
250
+ out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
251
+ await doCommit(false);
252
+ return;
253
+ }
254
+ out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
255
+ history.push({ role: "user", content: fixPrompt(verdict.issues) });
256
+ await runImplementer();
257
+ }
129
258
  }
130
259
  function lastAssistantText(history) {
131
260
  for (let i = history.length - 1; i >= 0; i--) {
@@ -312,6 +441,11 @@ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / gr
312
441
  "End your message with the plan as a short numbered list.";
313
442
  const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
314
443
  "conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
444
+ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
445
+ "the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
446
+ "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
447
+ "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
448
+ "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
315
449
  const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
316
450
  "continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
317
451
  "Be specific. Output only the summary.";
@@ -326,11 +460,10 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
326
460
  const roles = loadRoles(cwd);
327
461
  const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
328
462
  const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
329
- const toolFilter = role?.allowTools
330
- ? (n) => role.allowTools.includes(n)
331
- : role?.denyTools
332
- ? (n) => !role.denyTools.includes(n)
333
- : (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
463
+ // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
464
+ // further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
465
+ // Write-capable roles run in the main loop via `hara org`, behind the user's gate.
466
+ const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
334
467
  const subHistory = [{ role: "user", content: task }];
335
468
  await runAgent(subHistory, {
336
469
  provider,
@@ -377,9 +510,11 @@ function runDoctor(cfg) {
377
510
  `${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
378
511
  `${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"}`)}`,
379
512
  `${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>"); })()}`,
380
- `${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
513
+ `${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
381
514
  `${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
515
  `${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
516
+ `${dot} cron ${(() => { const n = loadJobs().length; return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add"); })()}`,
517
+ `${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
383
518
  ];
384
519
  return lines.join("\n");
385
520
  }
@@ -434,8 +569,11 @@ program
434
569
  });
435
570
  program
436
571
  .command("org <task...>")
437
- .description("dispatch a task to the owning role and run it")
572
+ .description("dispatch a task to the owning role and run it (--review loops a reviewer until it approves)")
438
573
  .option("--role <id>", "force a specific role")
574
+ .option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
575
+ .option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
576
+ .option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
439
577
  .action(async (taskParts, opts2) => {
440
578
  const cfg = loadConfig();
441
579
  const provider = await buildProvider(cfg);
@@ -454,6 +592,9 @@ program
454
592
  projectContext: loadAgentsMd(cfg.cwd) || undefined,
455
593
  stats,
456
594
  forceRole: opts2.role,
595
+ review: opts2.review,
596
+ rounds: opts2.rounds,
597
+ commit: opts2.commit,
457
598
  });
458
599
  if (stats.input || stats.output)
459
600
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
@@ -555,6 +696,31 @@ program
555
696
  .command("doctor")
556
697
  .description("check your hara setup (provider / auth / model / node / assets / roles)")
557
698
  .action(() => out(runDoctor(loadConfig()) + "\n"));
699
+ program
700
+ .command("completions <shell>")
701
+ .description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
702
+ .action((shell) => {
703
+ const top = program.commands.map((cmd) => cmd.name()).filter((n) => n && n !== "completions").sort();
704
+ const subs = {};
705
+ for (const cmd of program.commands) {
706
+ const sub = cmd.commands.map((s) => s.name()).filter(Boolean);
707
+ if (sub.length)
708
+ subs[cmd.name()] = sub;
709
+ }
710
+ const script = completionScript(shell, { top, subs });
711
+ if (!script)
712
+ return void out(c.red(`Unsupported shell '${shell}'. Use: bash | zsh | fish\n`));
713
+ out(script);
714
+ });
715
+ program
716
+ .command("mcp")
717
+ .description("run hara as an MCP server (stdio) — expose its read/search tools (incl. codebase_search) to other MCP clients")
718
+ .action(async () => {
719
+ const cfg = loadConfig();
720
+ // stdout is the JSON-RPC transport — diagnostics MUST go to stderr only.
721
+ process.stderr.write(c.dim(`hara mcp · serving over stdio · cwd ${cfg.cwd}\n tools: ${mcpServeToolNames().join(", ") || "(none)"}\n (read-only by default; set HARA_MCP_TOOLS to override)\n`));
722
+ await startMcpServer(pkg.version, { cwd: cfg.cwd, sandbox: "read-only" });
723
+ });
558
724
  program
559
725
  .command("review")
560
726
  .description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
@@ -660,6 +826,154 @@ program
660
826
  }
661
827
  }
662
828
  });
829
+ function renderCronJobs() {
830
+ const jobs = loadJobs();
831
+ const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
832
+ if (!jobs.length)
833
+ return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
834
+ const now = Date.now();
835
+ const lines = jobs.map((j) => {
836
+ const nxt = nextRun(j, now);
837
+ const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
838
+ return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nxt ? new Date(nxt).toLocaleString() : "—"} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
839
+ });
840
+ return head + "\n" + lines.join("\n") + "\n";
841
+ }
842
+ const cronCmd = program.command("cron").description("scheduled tasks — run a prompt/org task on a schedule (fired by your OS via `hara cron install`)");
843
+ cronCmd
844
+ .command("add <schedule> <task...>")
845
+ .description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
846
+ .option("--name <name>", "a label for the job")
847
+ .option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
848
+ .action((schedule, taskParts, opts) => {
849
+ const task = taskParts.join(" ");
850
+ const sched = parseSchedule(schedule, Date.now());
851
+ if ("error" in sched)
852
+ return void out(c.red(sched.error + "\n"));
853
+ const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
854
+ out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
855
+ if (!isInstalled())
856
+ out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
857
+ });
858
+ // Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
859
+ const cronResolve = (id) => {
860
+ const r = resolveJob(id);
861
+ if (r === "ambiguous")
862
+ return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
863
+ if (!r)
864
+ return void out(c.red(`no such job: ${id}\n`)), null;
865
+ return r;
866
+ };
867
+ cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
868
+ cronCmd
869
+ .command("remove <id>")
870
+ .alias("rm")
871
+ .description("delete a job (by id or unique prefix)")
872
+ .action((id) => {
873
+ const j = cronResolve(id);
874
+ if (j)
875
+ out(removeJob(j.id) ? c.green(`✓ removed ${j.id}\n`) : c.red("no such job\n"));
876
+ });
877
+ cronCmd.command("enable <id>").description("enable a job").action((id) => {
878
+ const j = cronResolve(id);
879
+ if (j) {
880
+ setEnabled(j.id, true);
881
+ out(c.green(`✓ enabled ${j.id}\n`));
882
+ }
883
+ });
884
+ cronCmd.command("disable <id>").description("disable a job (keeps it, stops firing)").action((id) => {
885
+ const j = cronResolve(id);
886
+ if (j) {
887
+ setEnabled(j.id, false);
888
+ out(c.green(`✓ disabled ${j.id}\n`));
889
+ }
890
+ });
891
+ cronCmd
892
+ .command("run <id>")
893
+ .description("run a job right now, ignoring its schedule")
894
+ .action(async (id) => {
895
+ const job = cronResolve(id);
896
+ if (!job)
897
+ return;
898
+ out(c.dim(`running ${job.id} (${job.name})…\n`));
899
+ const r = await runJobOnce(job);
900
+ recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
901
+ out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
902
+ });
903
+ cronCmd
904
+ .command("tick")
905
+ .description("run all due jobs now (your OS scheduler calls this every minute)")
906
+ .action(async () => {
907
+ const r = await runTick(Date.now());
908
+ if (r.skipped)
909
+ return void out(c.dim(`(skipped — ${r.skipped})\n`));
910
+ out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
911
+ });
912
+ cronCmd
913
+ .command("install")
914
+ .description("register the per-minute tick with your OS scheduler (launchd on macOS, crontab on Linux)")
915
+ .action(() => {
916
+ const r = installScheduler(selfArgv());
917
+ out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
918
+ });
919
+ cronCmd.command("uninstall").description("remove the OS scheduler entry").action(() => {
920
+ const r = uninstallScheduler();
921
+ out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
922
+ });
923
+ cronCmd
924
+ .command("logs <id>")
925
+ .description("show a job's recent run output")
926
+ .action((id) => {
927
+ const job = cronResolve(id);
928
+ if (!job)
929
+ return;
930
+ const p = logPath(job.id);
931
+ out(existsSync(p) ? readFileSync(p, "utf8").slice(-4000) + "\n" : c.dim("(no runs yet)\n"));
932
+ });
933
+ const memoryCmd = program.command("memory").description("inspect + consolidate hara's durable memory (~/.hara/memory + project .hara/memory)");
934
+ memoryCmd.command("show").description("print the memory digest injected at session start").action(() => {
935
+ const d = memoryDigest(process.cwd());
936
+ out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
937
+ });
938
+ memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
939
+ const w = scaffoldMemory(process.cwd());
940
+ out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
941
+ });
942
+ memoryCmd
943
+ .command("distill")
944
+ .description("consolidate recent daily logs into durable MEMORY (promote short-term → long-term)")
945
+ .option("--days <n>", "days of logs to consider (default 14)", (v) => parseInt(v, 10))
946
+ .option("--scope <s>", "global | project | all (default all)")
947
+ .action(async (opts) => {
948
+ const cfg = loadConfig();
949
+ const provider = await buildProvider(cfg);
950
+ if (!provider) {
951
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
952
+ process.exit(1);
953
+ }
954
+ const days = opts.days && opts.days > 0 ? opts.days : 14;
955
+ const scopes = opts.scope === "global" ? ["global"] : opts.scope === "project" ? ["project"] : ["project", "global"];
956
+ const logs = scopes
957
+ .map((s) => readRecentLogs(s, cfg.cwd, days))
958
+ .filter(Boolean)
959
+ .join("\n\n");
960
+ if (!logs.trim())
961
+ return void out(c.dim(`No daily logs in the last ${days} day(s) to distill. (The agent jots them via memory_write target=log.)\n`));
962
+ out(c.dim(`Distilling ${days}-day logs → durable memory…\n`));
963
+ const stats = { input: 0, output: 0, lastInput: 0 };
964
+ const history = [{ role: "user", content: `Current durable memory:\n\n${memoryDigest(cfg.cwd) || "(empty)"}\n\n---\n\nRecent daily logs (last ${days} days):\n\n${logs.slice(0, 80_000)}` }];
965
+ await runAgent(history, {
966
+ provider,
967
+ ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
968
+ approval: "full-auto",
969
+ confirm: async () => true,
970
+ toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
971
+ systemOverride: MEMORY_DISTILL_SYSTEM,
972
+ stats,
973
+ });
974
+ if (stats.input || stats.output)
975
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
976
+ });
663
977
  const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
664
978
  rolesCmd
665
979
  .command("init")
@@ -715,6 +1029,18 @@ pluginCmd
715
1029
  m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
716
1030
  ].filter(Boolean);
717
1031
  out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
1032
+ // Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
1033
+ // hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
1034
+ const execs = [];
1035
+ for (const [name, s] of Object.entries(m.mcpServers ?? {}))
1036
+ execs.push(`mcp ${name}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
1037
+ for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
1038
+ execs.push(`hook: ${h.command}`);
1039
+ if (execs.length) {
1040
+ out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
1041
+ execs.map((e) => c.dim(` ${e}`)).join("\n") +
1042
+ c.dim(`\n disable: hara plugin disable ${p.name}\n`));
1043
+ }
718
1044
  }
719
1045
  catch (e) {
720
1046
  out(c.red(`Install failed: ${e.message}\n`));
@@ -1272,6 +1598,7 @@ program.action(async (opts) => {
1272
1598
  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" : ""}` },
1273
1599
  cycleApproval: (m) => cycleMode(m),
1274
1600
  onClipboardImage: readClipboardImage,
1601
+ vim: cfg.vimMode,
1275
1602
  onSubmit: async (line, h, images) => {
1276
1603
  if (line.startsWith("/")) {
1277
1604
  const [nm, ...rest] = line.slice(1).split(/\s+/);
@@ -1420,6 +1747,49 @@ program.action(async (opts) => {
1420
1747
  h.setApproval(m);
1421
1748
  return void h.sink.notice(`(approval → ${m})`);
1422
1749
  }
1750
+ if (nm === "diff") {
1751
+ try {
1752
+ const d = (await runShell(arg === "staged" ? "git diff --staged" : "git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout.trim();
1753
+ if (!d)
1754
+ return void h.sink.notice(arg === "staged" ? "(nothing staged)" : "(no changes vs HEAD — /diff staged for the index)");
1755
+ return void h.sink.diff(d.length > 12_000 ? d.slice(0, 12_000) + "\n…[truncated]" : d);
1756
+ }
1757
+ catch {
1758
+ return void h.sink.notice("(git diff failed — is this a git repo?)");
1759
+ }
1760
+ }
1761
+ if (nm === "commit") {
1762
+ h.sink.notice("✻ writing a commit message…");
1763
+ const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
1764
+ return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
1765
+ }
1766
+ if (nm === "review") {
1767
+ let diff = "";
1768
+ try {
1769
+ diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
1770
+ }
1771
+ catch {
1772
+ /* not a git repo → empty */
1773
+ }
1774
+ if (!diff.trim())
1775
+ return void h.sink.notice("(nothing to review — no changes vs HEAD)");
1776
+ const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1777
+ const xin = stats.input;
1778
+ const xout = stats.output;
1779
+ await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
1780
+ provider,
1781
+ ctx: { cwd, sandbox, ui: rui },
1782
+ approval: "full-auto", // read-only via the tool filter, so nothing prompts
1783
+ confirm: h.confirm,
1784
+ toolFilter: (n) => READONLY_TOOLS.has(n),
1785
+ systemOverride: REVIEW_SYSTEM,
1786
+ memory: buildMemory(),
1787
+ stats,
1788
+ signal: h.signal,
1789
+ });
1790
+ h.sink.usage(stats.input - xin, stats.output - xout);
1791
+ return;
1792
+ }
1423
1793
  if (byName.has(nm))
1424
1794
  return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
1425
1795
  const near = nearest(nm, [...byName.keys()]);
@@ -0,0 +1,56 @@
1
+ // MCP server — expose hara's safe read/search tools over stdio so other MCP clients (Claude Desktop,
2
+ // Cursor, another hara, …) can use them. The high-value one is `codebase_search` over the current repo
3
+ // (semantic if you've built an index, lexical otherwise). `hara mcp` runs this.
4
+ //
5
+ // Read-only by DEFAULT: no edit/write/bash/computer — an external client must not be able to mutate your
6
+ // machine through hara. Override the exposed set with HARA_MCP_TOOLS (comma list) at your own risk.
7
+ //
8
+ // IMPORTANT: stdio is the JSON-RPC transport — this module must never write to stdout. Diagnostics go to
9
+ // stderr (the `hara mcp` command handles that); tool output flows back through the protocol.
10
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
13
+ import { getTool, getTools } from "../tools/registry.js";
14
+ /** Safe default: navigate + search the repo + the web. No state, no mutation, no privacy-sensitive memory. */
15
+ const SAFE_TOOLS = ["read_file", "grep", "glob", "ls", "codebase_search", "web_fetch", "web_search"];
16
+ /** Names to expose: HARA_MCP_TOOLS (comma list) if set, else the safe default — intersected with what's
17
+ * actually registered. An unknown name in the override is silently dropped. */
18
+ export function mcpServeToolNames() {
19
+ const env = process.env.HARA_MCP_TOOLS;
20
+ const want = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : SAFE_TOOLS;
21
+ const have = new Set(getTools().map((t) => t.name));
22
+ return want.filter((n) => have.has(n));
23
+ }
24
+ /** The exposed tools in MCP `tools/list` shape. */
25
+ export function mcpToolList() {
26
+ return mcpServeToolNames().map((n) => {
27
+ const t = getTool(n);
28
+ return { name: t.name, description: t.description, inputSchema: t.input_schema };
29
+ });
30
+ }
31
+ /** Run one exposed tool. Refuses anything outside the exposed set (defense in depth, even if a client
32
+ * asks for a name it shouldn't know). Never throws — errors come back as `isError` results. */
33
+ export async function mcpCallTool(name, args, ctx) {
34
+ if (!mcpServeToolNames().includes(name)) {
35
+ return { content: [{ type: "text", text: `Tool not exposed by \`hara mcp\`: ${name}` }], isError: true };
36
+ }
37
+ const tool = getTool(name);
38
+ if (!tool)
39
+ return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
40
+ try {
41
+ const out = await tool.run(args ?? {}, ctx);
42
+ return { content: [{ type: "text", text: String(out) }] };
43
+ }
44
+ catch (e) {
45
+ return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
46
+ }
47
+ }
48
+ /** Start the stdio MCP server and block (the transport keeps the process alive). */
49
+ export async function startMcpServer(version, ctx) {
50
+ const server = new Server({ name: "hara", version }, { capabilities: { tools: {} } });
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpToolList() }));
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK's result union has a task-augmented
53
+ // member TS can't narrow to; our {content,isError} shape is a valid CallToolResult (validated at runtime).
54
+ server.setRequestHandler(CallToolRequestSchema, async (req) => (await mcpCallTool(req.params.name, (req.params.arguments ?? {}), ctx)));
55
+ await server.connect(new StdioServerTransport());
56
+ }
@@ -3,9 +3,21 @@
3
3
  // project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
4
4
  import { homedir } from "node:os";
5
5
  import { join, dirname } from "node:path";
6
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
7
7
  import { findProjectRoot } from "../context/agents-md.js";
8
- const DIGEST_CAP = 4000; // chars of MEMORY/USER injected at session start (logs reached via search)
8
+ // Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
9
+ // project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
10
+ // boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
11
+ // per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
12
+ const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
13
+ /** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
14
+ function capAtLine(text, cap) {
15
+ if (text.length <= cap)
16
+ return text;
17
+ const cut = text.slice(0, cap);
18
+ const nl = cut.lastIndexOf("\n");
19
+ return (nl > cap * 0.5 ? cut.slice(0, nl) : cut).trimEnd() + "\n…[truncated — memory_search for the rest]";
20
+ }
9
21
  export function memoryDir(scope, cwd) {
10
22
  if (scope === "global")
11
23
  return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
@@ -49,7 +61,9 @@ export function forgetMemory(scope, target, match, cwd) {
49
61
  writeFileSync(f, kept.join("\n"), "utf8");
50
62
  return lines.length - kept.length;
51
63
  }
52
- /** Capped MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. */
64
+ /** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
65
+ * capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
66
+ * never starves USER prefs) and no entry is cut mid-line. Daily logs are reached via memory_search. */
53
67
  export function memoryDigest(cwd) {
54
68
  const sources = [
55
69
  ["project", "memory", "project MEMORY"],
@@ -64,14 +78,38 @@ export function memoryDigest(cwd) {
64
78
  try {
65
79
  const t = readFileSync(f, "utf8").trim();
66
80
  if (t)
67
- parts.push(`## ${label}\n${t}`);
81
+ parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
82
+ }
83
+ catch {
84
+ /* skip unreadable */
85
+ }
86
+ }
87
+ return parts.join("\n\n");
88
+ }
89
+ /** Concatenate the daily logs (`log/YYYY-MM-DD.md`) from the last `days` for one scope — the short-term
90
+ * tier `hara memory distill` consolidates into evergreen MEMORY. Empty if there's no log dir. */
91
+ export function readRecentLogs(scope, cwd, days) {
92
+ const dir = join(memoryDir(scope, cwd), "log");
93
+ if (!existsSync(dir))
94
+ return "";
95
+ const cutoff = Date.now() - days * 86_400_000;
96
+ const out = [];
97
+ for (const f of readdirSync(dir).sort()) {
98
+ if (!f.endsWith(".md"))
99
+ continue;
100
+ const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(f);
101
+ if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
102
+ continue;
103
+ try {
104
+ const t = readFileSync(join(dir, f), "utf8").trim();
105
+ if (t)
106
+ out.push(`### ${f}\n${t}`);
68
107
  }
69
108
  catch {
70
109
  /* skip unreadable */
71
110
  }
72
111
  }
73
- const out = parts.join("\n\n");
74
- return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
112
+ return out.join("\n\n");
75
113
  }
76
114
  /** Create memory dirs + seed files (global + project). Returns files written. */
77
115
  export function scaffoldMemory(cwd) {