@nanhara/hara 0.67.0 → 0.89.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
@@ -3,7 +3,7 @@ import { Command } from "commander";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { emitKeypressEvents } from "node:readline";
5
5
  import { runTui } from "./tui/run.js";
6
- import { readClipboardImage } from "./images.js";
6
+ import { readClipboardImage, mediaTypeFor } from "./images.js";
7
7
  import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
8
8
  import { setTheme } from "./tui/theme.js";
9
9
  import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
@@ -19,7 +19,13 @@ import { notifyDone } from "./notify.js";
19
19
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
20
20
  import { completionScript } from "./completions.js";
21
21
  import { renderSessionMarkdown } from "./export.js";
22
- import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL } from "./org-fleet/enroll.js";
22
+ import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL, syncOrgRoles } from "./org-fleet/enroll.js";
23
+ import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
24
+ import { routingProvider } from "./agent/route.js";
25
+ import { shouldAutoCompact } from "./agent/compact.js";
26
+ import { formatContextReport } from "./agent/context-report.js";
27
+ import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
28
+ import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
23
29
  import { mapLimit, maxParallel } from "./concurrency.js";
24
30
  import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
25
31
  import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
@@ -58,6 +64,7 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
58
64
  import "./tools/skill.js"; // register the skill loader tool
59
65
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
60
66
  import "./tools/todo.js"; // register todo_write (inline task checklist)
67
+ import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
61
68
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
62
69
  const here = dirname(fileURLToPath(import.meta.url));
63
70
  // Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
@@ -94,6 +101,15 @@ async function buildProvider(cfg) {
94
101
  }
95
102
  return createOpenAIProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL, label: cfg.provider });
96
103
  }
104
+ /** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
105
+ * turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
106
+ * routeModel is unset or equals the primary model. routeBaseURL/routeApiKey default to the primary's. */
107
+ async function withRouting(primary, cfg) {
108
+ if (!primary || !cfg.routeModel || cfg.routeModel === cfg.model)
109
+ return primary;
110
+ const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
111
+ return alt ? routingProvider(primary, alt) : primary;
112
+ }
97
113
  function authHint(cfg) {
98
114
  if (cfg.provider === "qwen-oauth")
99
115
  return `Run ${c.bold("hara login qwen")} to authenticate.`;
@@ -486,15 +502,59 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
486
502
  "conventions / user preferences from the logs that are NOT already captured, and persist each with " +
487
503
  "memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
488
504
  "Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
489
- const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
490
- "continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
491
- "Be specific. Output only the summary.";
505
+ const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
506
+ "loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
507
+ "then output ONLY the summary under these exact headings:\n" +
508
+ "1. Goal — the user's overall intent, in their own framing.\n" +
509
+ "2. Key decisions — choices made and why (so they aren't relitigated).\n" +
510
+ "3. Files & code — files created/changed and the important snippets, with why each matters.\n" +
511
+ "4. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
512
+ "5. Current state — what works now / what is verified.\n" +
513
+ "6. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
514
+ "Be specific and concrete. Drop the <analysis>; output only the headed summary.";
492
515
  const workingSetFromSummary = (s) => s
493
516
  .split("\n")
494
517
  .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
495
518
  .filter((l) => l.length > 3)
496
519
  .slice(0, 12)
497
520
  .map((l) => l.slice(0, 140));
521
+ /** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
522
+ * /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
523
+ async function compactConversation(provider, history, meta, stats) {
524
+ if (history.length < 2)
525
+ return null;
526
+ const r = await provider.turn({
527
+ system: COMPACT_SYSTEM,
528
+ history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
529
+ tools: [],
530
+ onText: () => { },
531
+ });
532
+ if (r.stop === "error")
533
+ return null;
534
+ const summary = r.text.trim();
535
+ if (!summary)
536
+ return null;
537
+ meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects into the next turns
538
+ history.length = 0;
539
+ history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
540
+ stats.input += r.usage?.input ?? 0;
541
+ stats.output += r.usage?.output ?? 0;
542
+ stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
543
+ saveSession(meta, history);
544
+ return summary;
545
+ }
546
+ /** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
547
+ * doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
548
+ * a one-line status. Returns true if it compacted. */
549
+ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
550
+ const pct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
551
+ if (!shouldAutoCompact(pct, history.length, cfg.autoCompact))
552
+ return false;
553
+ notify(`✻ Auto-compacting conversation (context ${pct}% full)…`);
554
+ const summary = await compactConversation(provider, history, meta, stats);
555
+ notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
556
+ return !!summary;
557
+ }
498
558
  /** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
499
559
  async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
500
560
  const roles = loadRoles(cwd);
@@ -763,11 +823,50 @@ program
763
823
  if (e.model)
764
824
  writeConfigValue("model", e.model);
765
825
  out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
826
+ const nRoles = await syncOrgRoles(); // pull this device's governed digital-employee bundle (B3)
827
+ if (nRoles > 0)
828
+ out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
766
829
  }
767
830
  catch (err) {
768
831
  out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
769
832
  }
770
833
  });
834
+ program
835
+ .command("permissions")
836
+ .description("show or scaffold command permission rules (bash allow/ask/deny + read-only autorun)")
837
+ .option("--init", "write a starter permissions.json")
838
+ .option("--project", "with --init, write it in this project (.hara/permissions.json) instead of globally")
839
+ .action((opts) => {
840
+ if (opts.init) {
841
+ const p = scaffoldPermissions(process.cwd(), opts.project ? "project" : "global");
842
+ return void out(p ? c.green(`✓ wrote ${p}\n`) : c.dim("(permissions file already exists — edit it directly)\n"));
843
+ }
844
+ const r = loadPermissionRules(process.cwd());
845
+ const pp = projectPermissionsPath(process.cwd());
846
+ out(c.bold("Command permissions") +
847
+ c.dim(" (bash) — deny blocks even in full-auto; allow / read-only auto-runs even in suggest\n") +
848
+ ` ${c.dim("global: ")} ${globalPermissionsPath()}\n` +
849
+ ` ${c.dim("project:")} ${pp ?? "(none)"}\n` +
850
+ ` ${c.dim("read-only autorun:")} ${r.readonlyAutorun ? c.green("on") : "off"}\n` +
851
+ ` ${c.green("allow")}: ${r.allow.length ? r.allow.join(", ") : c.dim("(none)")}\n` +
852
+ ` ${c.red("deny")} : ${r.deny.length ? r.deny.join(", ") : c.dim("(none)")}\n` +
853
+ c.dim(" edit the JSON to customize, or `hara permissions --init` for a starter.\n"));
854
+ });
855
+ program
856
+ .command("gateway")
857
+ .description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
858
+ .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk", "telegram")
859
+ .option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
860
+ .option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
861
+ .action(async (opts) => {
862
+ const mod = await import("./gateway/serve.js");
863
+ if (opts.platform === "weixin" && opts.login) {
864
+ await mod.weixinLogin();
865
+ return;
866
+ }
867
+ const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
868
+ await mod.runGateway({ cwd, platform: opts.platform });
869
+ });
771
870
  program
772
871
  .command("export [session]")
773
872
  .description("export a session to a Markdown transcript (default: the latest in this directory)")
@@ -1220,7 +1319,9 @@ program.action(async (opts) => {
1220
1319
  const cfg = loadConfig({ profile: opts.profile });
1221
1320
  if (opts.model)
1222
1321
  cfg.model = opts.model;
1223
- const provider0 = await buildProvider(cfg);
1322
+ const provider0 = await withRouting(await buildProvider(cfg), cfg);
1323
+ const fallbackProvider = provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model ? await buildProvider({ ...cfg, model: cfg.fallbackModel, baseURL: cfg.fallbackBaseURL ?? cfg.baseURL, apiKey: cfg.fallbackApiKey ?? cfg.apiKey }) : null;
1324
+ const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
1224
1325
  if (!provider0) {
1225
1326
  // First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
1226
1327
  if (stdin.isTTY && !opts.print) {
@@ -1237,8 +1338,10 @@ program.action(async (opts) => {
1237
1338
  process.exit(1);
1238
1339
  }
1239
1340
  let provider = provider0;
1240
- if (cfg.provider === "hara-gateway")
1341
+ if (cfg.provider === "hara-gateway") {
1241
1342
  void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
1343
+ void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
1344
+ }
1242
1345
  const cwd = cfg.cwd;
1243
1346
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
1244
1347
  let currentTurn = null; // set during a running turn so Esc can abort it
@@ -1256,16 +1359,83 @@ program.action(async (opts) => {
1256
1359
  // one-shot
1257
1360
  if (opts.print) {
1258
1361
  const projectContext = loadAgentsMd(cwd) || undefined;
1259
- const history = [{ role: "user", content: expandMentions(String(opts.print), cwd) }];
1362
+ // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
1363
+ // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
1364
+ // describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
1365
+ const describeImage = async (path, hint) => {
1366
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
1367
+ const vp = cfg.visionModel
1368
+ ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
1369
+ : cap === "vision"
1370
+ ? provider
1371
+ : null;
1372
+ if (!vp)
1373
+ return "";
1374
+ try {
1375
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
1376
+ }
1377
+ catch {
1378
+ return "";
1379
+ }
1380
+ };
1381
+ // Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
1382
+ // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
1383
+ // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
1384
+ let meta = null;
1385
+ const history = [];
1386
+ if (opts.resume || opts.continue) {
1387
+ const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
1388
+ const prior = rid ? loadSession(rid) : null;
1389
+ if (prior?.history)
1390
+ history.push(...prior.history);
1391
+ meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
1392
+ }
1393
+ // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
1394
+ // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
1395
+ // visionModel sidecar and folded into the message (text-only models can't take image blocks).
1396
+ const userText = expandMentions(String(opts.print), cwd);
1397
+ const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
1398
+ .split("\n")
1399
+ .map((s) => s.trim())
1400
+ .filter((p) => p && existsSync(p))
1401
+ .map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
1402
+ if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
1403
+ history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
1404
+ }
1405
+ else if (inboundImgs.length && cfg.visionModel) {
1406
+ let desc = "";
1407
+ try {
1408
+ const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
1409
+ if (vp)
1410
+ desc = await describeImages(vp, inboundImgs);
1411
+ }
1412
+ catch {
1413
+ /* describe is best-effort — fall back to the marker-only text */
1414
+ }
1415
+ const n = inboundImgs.length;
1416
+ history.push({
1417
+ role: "user",
1418
+ content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
1419
+ });
1420
+ }
1421
+ else {
1422
+ history.push({ role: "user", content: userText });
1423
+ }
1260
1424
  await runAgent(history, {
1261
1425
  provider,
1262
- ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role) },
1426
+ ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
1263
1427
  approval: "full-auto",
1264
1428
  confirm: async () => true,
1265
1429
  projectContext,
1266
1430
  memory: memoryDigest(cwd),
1267
1431
  stats,
1268
1432
  });
1433
+ if (meta) {
1434
+ // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
1435
+ // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
1436
+ await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
1437
+ saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
1438
+ }
1269
1439
  if (stats.input || stats.output)
1270
1440
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1271
1441
  await closeMcp();
@@ -1533,31 +1703,52 @@ program.action(async (opts) => {
1533
1703
  out(c.green(`↩ reverted: ${r.files.join(", ")}\n`));
1534
1704
  },
1535
1705
  },
1706
+ {
1707
+ name: "context",
1708
+ desc: "show what's filling the context window (token breakdown by category)",
1709
+ run: () => void out(formatContextReport(history, cfg.model) + "\n"),
1710
+ },
1711
+ {
1712
+ name: "rewind",
1713
+ desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
1714
+ run: (a) => {
1715
+ const arg = (a ?? "").trim();
1716
+ if (!arg) {
1717
+ const turns = userTurnPreviews(history);
1718
+ return void out(turns.length ? "Recent turns (newest first) — `/rewind <n>` forks from before it (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") + "\n" : c.dim("(nothing to rewind)\n"));
1719
+ }
1720
+ const nh = rewindTo(history, Number(arg));
1721
+ if (!nh)
1722
+ return void out(c.dim(`(no such turn: ${arg})\n`));
1723
+ history.length = 0;
1724
+ history.push(...nh);
1725
+ saveSession(meta, history);
1726
+ out(c.green(`(rewound — dropped the last ${arg} turn(s); ${history.length} messages kept. Files are unchanged. Type your next message.)\n`));
1727
+ },
1728
+ },
1729
+ {
1730
+ name: "checkpoint",
1731
+ desc: "file-state checkpoints: /checkpoint (list) · /checkpoint restore <n> (revert files to a checkpoint)",
1732
+ run: (a) => {
1733
+ const parts = (a ?? "").trim().split(/\s+/);
1734
+ const cps = listCheckpoints(cwd);
1735
+ if (parts[0] !== "restore") {
1736
+ return void out(cps.length ? "File checkpoints (newest first) — `/checkpoint restore <n>` reverts files to it:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") + "\n" : c.dim("(no checkpoints yet — taken before each turn when fileCheckpoints is on)\n"));
1737
+ }
1738
+ const cp = cps[Number(parts[1]) - 1];
1739
+ if (!cp)
1740
+ return void out(c.dim(`(no checkpoint ${parts[1] ?? ""})\n`));
1741
+ const k = restoreCheckpoint(cwd, cp.sha);
1742
+ out(k == null ? c.red("(restore failed)\n") : c.green(`(restored ${k} file(s) to ${cp.sha} — '${cp.label}'; prior state snapshotted too)\n`));
1743
+ },
1744
+ },
1536
1745
  {
1537
1746
  name: "compact",
1538
1747
  desc: "summarize the conversation so far to free up context",
1539
1748
  run: async () => {
1540
- if (history.length < 2)
1541
- return void out(c.dim("(nothing to compact)\n"));
1542
1749
  out(c.dim("Compacting…\n"));
1543
- const r = await provider.turn({
1544
- system: COMPACT_SYSTEM,
1545
- history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
1546
- tools: [],
1547
- onText: () => { },
1548
- });
1549
- if (r.stop === "error")
1550
- return void out(c.red(`(compact failed: ${r.errorMsg})\n`));
1551
- const summary = r.text.trim();
1552
- if (!summary)
1553
- return void out(c.dim("(compact produced nothing)\n"));
1554
- meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects next turns
1555
- history.length = 0;
1556
- history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
1557
- stats.input += r.usage?.input ?? 0;
1558
- stats.output += r.usage?.output ?? 0;
1559
- saveSession(meta, history);
1560
- out(c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`));
1750
+ const summary = await compactConversation(provider, history, meta, stats);
1751
+ out(summary ? c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`) : c.dim("(nothing to compact / compact failed)\n"));
1561
1752
  },
1562
1753
  },
1563
1754
  {
@@ -1771,6 +1962,33 @@ program.action(async (opts) => {
1771
1962
  saveSession(meta, history);
1772
1963
  return void h.sink.notice(`(renamed → ${meta.title})`);
1773
1964
  }
1965
+ if (nm === "context")
1966
+ return void h.sink.notice(formatContextReport(history, cfg.model));
1967
+ if (nm === "rewind") {
1968
+ if (!arg) {
1969
+ const turns = userTurnPreviews(history);
1970
+ return void h.sink.notice(turns.length ? "Recent turns (newest first) — /rewind <n> (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") : "(nothing to rewind)");
1971
+ }
1972
+ const nh = rewindTo(history, Number(arg));
1973
+ if (!nh)
1974
+ return void h.sink.notice(`(no such turn: ${arg})`);
1975
+ history.length = 0;
1976
+ history.push(...nh);
1977
+ saveSession(meta, history);
1978
+ return void h.sink.notice(`(rewound — kept ${history.length} messages; files unchanged. Type your next message.)`);
1979
+ }
1980
+ if (nm === "checkpoint") {
1981
+ const parts = arg.split(/\s+/);
1982
+ const cps = listCheckpoints(cwd);
1983
+ if (parts[0] !== "restore") {
1984
+ return void h.sink.notice(cps.length ? "File checkpoints (newest first) — /checkpoint restore <n>:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") : "(no checkpoints yet)");
1985
+ }
1986
+ const cp = cps[Number(parts[1]) - 1];
1987
+ if (!cp)
1988
+ return void h.sink.notice(`(no checkpoint ${parts[1] ?? ""})`);
1989
+ const k = restoreCheckpoint(cwd, cp.sha);
1990
+ return void h.sink.notice(k == null ? "(restore failed)" : `(restored ${k} file(s) to ${cp.sha} — '${cp.label}')`);
1991
+ }
1774
1992
  if (nm === "compact") {
1775
1993
  if (history.length < 2)
1776
1994
  return void h.sink.notice("(nothing to compact)");
@@ -1794,25 +2012,8 @@ program.action(async (opts) => {
1794
2012
  /* flush is best-effort */
1795
2013
  }
1796
2014
  }
1797
- const cr = await provider.turn({
1798
- system: COMPACT_SYSTEM,
1799
- history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
1800
- tools: [],
1801
- onText: () => { },
1802
- });
1803
- if (cr.stop === "error")
1804
- return void h.sink.notice(`(compact failed: ${cr.errorMsg})`);
1805
- const summary = cr.text.trim();
1806
- if (!summary)
1807
- return void h.sink.notice("(compact produced nothing)");
1808
- meta.workingSet = workingSetFromSummary(summary);
1809
- history.length = 0;
1810
- history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
1811
- stats.input += cr.usage?.input ?? 0;
1812
- stats.output += cr.usage?.output ?? 0;
1813
- h.sink.usage(cr.usage?.input ?? 0, cr.usage?.output ?? 0);
1814
- saveSession(meta, history);
1815
- return void h.sink.notice(`(compacted — kept ${meta.workingSet.length} working-memory notes)`);
2015
+ const summary = await compactConversation(provider, history, meta, stats);
2016
+ return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
1816
2017
  }
1817
2018
  if (nm === "sessions") {
1818
2019
  const ms = listSessions();
@@ -1977,6 +2178,8 @@ program.action(async (opts) => {
1977
2178
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
1978
2179
  recalledContext = "";
1979
2180
  history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
2181
+ if (cfg.fileCheckpoints)
2182
+ checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
1980
2183
  const beforeIn = stats.input;
1981
2184
  const beforeOut = stats.output;
1982
2185
  await runAgent(history, {
@@ -1990,6 +2193,7 @@ program.action(async (opts) => {
1990
2193
  stats,
1991
2194
  signal: h.signal,
1992
2195
  pendingInput,
2196
+ fallback: fbOpt,
1993
2197
  });
1994
2198
  if (!meta.title) {
1995
2199
  meta.title = await nameSession(provider, history);
@@ -1998,6 +2202,7 @@ program.action(async (opts) => {
1998
2202
  h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
1999
2203
  notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
2000
2204
  saveSession(meta, history);
2205
+ await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
2001
2206
  },
2002
2207
  });
2003
2208
  await closeMcp();
@@ -2042,10 +2247,12 @@ program.action(async (opts) => {
2042
2247
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
2043
2248
  recalledContext = "";
2044
2249
  history.push({ role: "user", content: userContent });
2250
+ if (cfg.fileCheckpoints)
2251
+ checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
2045
2252
  currentTurn = new AbortController();
2046
2253
  const t0 = Date.now();
2047
2254
  try {
2048
- await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
2255
+ await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
2049
2256
  }
2050
2257
  catch (e) {
2051
2258
  out(c.red(`\n[error] ${e.message}\n`));
@@ -2068,9 +2275,11 @@ program.action(async (opts) => {
2068
2275
  out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
2069
2276
  }
2070
2277
  saveSession(meta, history);
2071
- const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
2072
- if (ctxPct >= 80)
2073
- out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /reset to clear\n`));
2278
+ if (!(await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`))))) {
2279
+ const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
2280
+ if (ctxPct >= 80)
2281
+ out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
2282
+ }
2074
2283
  }
2075
2284
  bar.uninstall();
2076
2285
  rl.close();
package/dist/org/roles.js CHANGED
@@ -12,6 +12,12 @@ export function rolesDir(cwd) {
12
12
  export function globalRolesDir() {
13
13
  return join(homedir(), ".hara", "roles");
14
14
  }
15
+ /** Org-pushed roles (B-end): the digital-employee bundle synced from hara-control's `/v1/roles` into
16
+ * `~/.hara/org-roles/*.md` (see org-fleet/enroll.ts syncOrgRoles). A managed baseline — above
17
+ * third-party plugins, but a dev's own global/project roles still win. */
18
+ export function orgRolesDir() {
19
+ return join(homedir(), ".hara", "org-roles");
20
+ }
15
21
  /** Claude-Code subagents (`.claude/agents/*.md`) — consumed for ecosystem interop (project scope). */
16
22
  export function claudeAgentsDir(cwd) {
17
23
  return join(findProjectRoot(cwd), ".claude", "agents");
@@ -62,8 +68,8 @@ export function subagentToolFilter(role, isReadonly) {
62
68
  }
63
69
  export function loadRoles(cwd) {
64
70
  const byId = new Map();
65
- // lowest→highest precedence: plugins < global < .claude/agents < .hara/roles (project wins, same as memory/config)
66
- for (const dir of [...pluginRoleDirs(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
71
+ // lowest→highest precedence: plugins < org(B-end push) < global < .claude/agents < .hara/roles (project wins)
72
+ for (const dir of [...pluginRoleDirs(), orgRolesDir(), globalRolesDir(), claudeAgentsDir(cwd), rolesDir(cwd)]) {
67
73
  if (!existsSync(dir))
68
74
  continue;
69
75
  for (const f of readdirSync(dir)) {
@@ -7,10 +7,12 @@
7
7
  // Protocol (what `hara-control` implements on the other end):
8
8
  // POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
9
9
  // POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
10
+ // GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
10
11
  // POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
11
12
  import { homedir, hostname, platform } from "node:os";
12
13
  import { join } from "node:path";
13
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
15
+ import { orgRolesDir } from "../org/roles.js";
14
16
  const orgPath = () => join(homedir(), ".hara", "org.json");
15
17
  const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
16
18
  /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
@@ -92,3 +94,50 @@ export async function heartbeat(signal) {
92
94
  return false;
93
95
  }
94
96
  }
97
+ /** Render one bundle role into the markdown frontmatter the CLI role loader expects
98
+ * (src/org/roles.ts parseFrontmatter): name/description/owns/rejects/model/allowTools/denyTools, body=system. */
99
+ function renderRoleMd(r) {
100
+ const fm = ["---", `name: ${r.name}`];
101
+ if (r.description)
102
+ fm.push(`description: ${r.description}`);
103
+ if (r.owns?.length)
104
+ fm.push(`owns: [${r.owns.join(", ")}]`);
105
+ if (r.rejects?.length)
106
+ fm.push(`rejects: [${r.rejects.join(", ")}]`);
107
+ if (r.model)
108
+ fm.push(`model: ${r.model}`);
109
+ if (r.allow_tools?.length)
110
+ fm.push(`allowTools: [${r.allow_tools.join(", ")}]`); // snake_case wire → camelCase fm
111
+ if (r.deny_tools?.length)
112
+ fm.push(`denyTools: [${r.deny_tools.join(", ")}]`);
113
+ fm.push("---", "", (r.system || "").trim(), "");
114
+ return fm.join("\n");
115
+ }
116
+ /** Pull this device's governed role bundle from the control plane and materialize it into
117
+ * `~/.hara/org-roles/*.md` — a managed precedence layer below the dev's own global/project roles
118
+ * (see src/org/roles.ts loadRoles). The org bundle is AUTHORITATIVE: the dir is wiped and rewritten on
119
+ * every sync, so a server-side revoke/rename actually removes the local role. Best-effort: never throws;
120
+ * returns the count of roles written (0 on any failure / not enrolled / empty bundle). */
121
+ export async function syncOrgRoles(signal) {
122
+ const e = loadEnrollment();
123
+ if (!e)
124
+ return 0;
125
+ try {
126
+ const res = await fetch(`${e.gatewayUrl}/v1/roles`, { signal, headers: { authorization: `Bearer ${e.deviceToken}` } });
127
+ if (!res.ok)
128
+ return 0;
129
+ const bundle = (await res.json());
130
+ const roles = Array.isArray(bundle.roles) ? bundle.roles.filter((r) => r && r.name && r.system) : [];
131
+ const dir = orgRolesDir();
132
+ rmSync(dir, { recursive: true, force: true }); // authoritative replace
133
+ mkdirSync(dir, { recursive: true });
134
+ for (const r of roles)
135
+ writeFileSync(join(dir, `${r.name}.md`), renderRoleMd(r), "utf8");
136
+ // org policy sidecar (model/tool/approval floors the CLI enforces; skipped by the .md-only role loader)
137
+ writeFileSync(join(dir, "_policy.json"), JSON.stringify({ version: bundle.version ?? 0, org_policy: bundle.org_policy ?? {} }, null, 2) + "\n", "utf8");
138
+ return roles.length;
139
+ }
140
+ catch {
141
+ return 0;
142
+ }
143
+ }
package/dist/sandbox.js CHANGED
@@ -33,24 +33,27 @@ let warnedUnsandboxed = false; // emit the "macOS-only" notice at most once per
33
33
  * Streams output via `opts.onData` while capturing it for the resolved value.
34
34
  * Resolves on exit 0; rejects (with `.stdout`/`.stderr`/`.code`) on nonzero exit or timeout.
35
35
  */
36
- export function runShell(command, cwd, mode, opts) {
37
- let cmd;
38
- let args;
36
+ /** Build the (sandboxed, when supported) argv for a shell command — shared by runShell + background jobs
37
+ * so the seatbelt write-confinement is identical for both. */
38
+ export function shellCommand(command, cwd, mode) {
39
39
  if (mode !== "off" && platform() === "darwin") {
40
40
  const dir = mkdtempSync(join(tmpdir(), "hara-sb-"));
41
41
  const profileFile = join(dir, "policy.sb");
42
42
  writeFileSync(profileFile, seatbeltProfile(cwd, mode));
43
- cmd = "sandbox-exec";
44
- args = ["-f", profileFile, "/bin/bash", "-lc", command];
43
+ return { cmd: "sandbox-exec", args: ["-f", profileFile, "/bin/bash", "-lc", command] };
45
44
  }
46
- else {
47
- if (mode !== "off" && !warnedUnsandboxed) {
48
- warnedUnsandboxed = true;
49
- process.stderr.write(`hara: --sandbox ${mode} is macOS-only the shell runs UNSANDBOXED on ${platform()}.\n`);
50
- }
51
- cmd = "/bin/sh";
52
- args = ["-c", command];
45
+ maybeWarnUnsandboxed(mode);
46
+ return { cmd: "/bin/sh", args: ["-c", command] };
47
+ }
48
+ /** One-time-per-process notice that --sandbox is a no-op off macOS (covers every entry point). */
49
+ export function maybeWarnUnsandboxed(mode) {
50
+ if (mode !== "off" && !warnedUnsandboxed) {
51
+ warnedUnsandboxed = true;
52
+ process.stderr.write(`hara: --sandbox ${mode} is macOS-only — the shell runs UNSANDBOXED on ${platform()}.\n`);
53
53
  }
54
+ }
55
+ export function runShell(command, cwd, mode, opts) {
56
+ const { cmd, args } = shellCommand(command, cwd, mode);
54
57
  return new Promise((resolve, reject) => {
55
58
  const child = spawn(cmd, args, { cwd });
56
59
  let stdout = "";
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { join, dirname } from "node:path";
8
8
  import { findProjectRoot } from "../context/agents-md.js";
9
9
  import { listProjectFiles, walkFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
10
+ import { zvecBuild, zvecQueryIds } from "./zvec-store.js";
10
11
  // Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
11
12
  const CODE_RE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cc|cpp|hpp|cs|swift|scala|sh|bash|sql|md|mdx|json|ya?ml|toml|html|css|scss|less|vue|svelte|astro|tf|proto|graphql|gql|gradle|txt)$/i;
12
13
  /** Index location — repo index lives in the project (gitignore it); the rest are global. Derived/rebuildable. */
@@ -116,6 +117,8 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
116
117
  if (!existsSync(join(dir, ".gitignore")))
117
118
  writeFileSync(join(dir, ".gitignore"), "*\n", "utf8");
118
119
  writeFileSync(p, JSON.stringify({ model, items }), "utf8");
120
+ // Build a zvec ANN index alongside the JSON cache (best-effort; queryIndex prefers it for retrieval).
121
+ await zvecBuild(name, items.map((it) => ({ id: it.id, vec: it.vec })), cwd);
119
122
  return { total: items.length, embedded: toEmbed.length, reused };
120
123
  }
121
124
  export function indexExists(name, cwd) {
@@ -192,6 +195,21 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
192
195
  const [qv] = await embed([query]);
193
196
  if (!qv)
194
197
  return [];
198
+ // Prefer the zvec ANN index for candidate retrieval; re-rank candidates by EXACT cosine from the JSON
199
+ // store (identical score semantics to the brute-force path). Fall back to full brute-force if zvec is
200
+ // unavailable / has no index / errors.
201
+ const ids = await zvecQueryIds(name, qv, cwd, k);
202
+ if (ids?.length) {
203
+ const byId = new Map(idx.items.map((it) => [it.id, it]));
204
+ const hits = ids
205
+ .map((id) => byId.get(id))
206
+ .filter((it) => Boolean(it))
207
+ .map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
208
+ .sort((a, b) => b.score - a.score)
209
+ .slice(0, k);
210
+ if (hits.length)
211
+ return hits;
212
+ }
195
213
  return idx.items
196
214
  .map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
197
215
  .sort((a, b) => b.score - a.score)