@nanhara/hara 0.62.0 → 0.67.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 CHANGED
@@ -5,6 +5,49 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.67.0 — unreleased (bounded parallel concurrency)
9
+
10
+ - hara already runs work in parallel — fan-out **`agent`** sub-agents, concurrent read-kind tools in a turn,
11
+ and `hara plan --parallel` waves — but with **no cap**: the model spawning 20 `agent` calls in one turn
12
+ started 20 LLM loops at once (provider rate-limits / resource thrash). Now a **bounded pool** (`mapLimit`)
13
+ caps in-flight parallelism to **8** by default (tunable via `HARA_MAX_CONCURRENCY`), matching cc-haha's
14
+ safeguard (it caps at 10). Excess work queues and runs as slots free; ordering + behavior otherwise
15
+ unchanged. Applied to the loop's read/agent batch and the parallel-plan wave.
16
+
17
+ ## 0.66.0 — unreleased (B-end: device enrollment + `hara-gateway` provider)
18
+
19
+ - First slice of the **B-end** (fleets / control plane): `hara enroll <gateway-url> --code <code>` trades a
20
+ one-time code for a scoped, revocable **device token** (stored `0600` in `~/.hara/org.json`) and switches
21
+ hara to the new **`hara-gateway`** provider — an OpenAI-compatible client pointed at your org's gateway.
22
+ **The real provider key never touches the device** (it stays at the gateway). A heartbeat fires on start
23
+ for fleet visibility; `--status` / `--clear` manage it. The device↔gateway protocol (enroll / heartbeat /
24
+ OpenAI-compatible proxy) is documented in `docs/b-end.md` and **verified end-to-end against a stub control
25
+ plane**. The control-plane server (`hara-control`) + the LiteLLM data-plane are the next, separate increment.
26
+
27
+ ## 0.65.0 — unreleased (frontmatter-aware asset recall)
28
+
29
+ - Asset/skill recall (`searchAssets`, behind `hara recall` / `/recall` / skill dedup) now **ranks by the
30
+ asset's declared dimensions** — a query word in the `title` or the frontmatter `tags`/`lang` counts more
31
+ than one buried in the body. The asset format already declared these (the scaffold seeds `tags`/`lang`);
32
+ retrieval now actually uses them. The base relevance score (distinct query words present) is unchanged,
33
+ so the dedup-before-save threshold is unaffected — this only improves *ordering*. (Studied codex + cc-haha:
34
+ both stay lexical + manual-file curation with no semantic search or auto-capture; hara's hybrid lexical+
35
+ opt-in-semantic recall over a unified skills/code-assets/memory corpus is already ahead.)
36
+
37
+ ## 0.64.0 — unreleased (session export)
38
+
39
+ - **`hara export [session] [--out file]`** renders a saved session to a Markdown transcript — the header
40
+ (title/model/cwd/date), each turn (you / hara), tool calls inline, and tool results in collapsible
41
+ `<details>` blocks (capped). Default is the latest session in the current directory. For sharing a
42
+ decision, pasting into a PR, or archiving. Pure renderer (`src/export.ts`), unit-tested.
43
+
44
+ ## 0.63.0 — unreleased (first-run setup wizard)
45
+
46
+ - **`hara setup`** — an interactive wizard (provider → optional base URL → API key → model) that writes
47
+ `~/.hara/config.json` (0600), so a new user doesn't have to know the individual `hara config set` keys.
48
+ It's also **auto-offered** when you start `hara` unconfigured ("Not authenticated — run setup now?")
49
+ instead of just erroring. TTY-only (scripts get a clear pointer to `hara config set`).
50
+
8
51
  ## 0.62.0 — unreleased (shell completions)
9
52
 
10
53
  - **`hara completions bash|zsh|fish`** prints a completion script (eval it in your shell rc) that
package/README.md CHANGED
@@ -59,7 +59,9 @@ hara -p "summarize @README.md and list any TODOs"
59
59
 
60
60
  ## Setup
61
61
 
62
- hara is **multi-provider**pick a provider + key.
62
+ The fastest path is **`hara setup`** an interactive wizard for provider + key + model (it also runs
63
+ automatically the first time you start `hara` unconfigured). Or configure it yourself — hara is
64
+ **multi-provider**:
63
65
 
64
66
  **Anthropic (default)**
65
67
  ```bash
@@ -185,7 +187,7 @@ only changed files re-embed (a full repo rebuild that takes ~a minute re-runs in
185
187
  `hara config set computerUse read|click|full` and allowlist apps with `hara config set computerApps "App, …"`. Guarded
186
188
  by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-per-session grant. Screenshots are read via your
187
189
  vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
188
- **Sessions**: conversations are saved automatically — `-c` / `--resume <id>` to continue, `hara sessions` to list.
190
+ **Sessions**: conversations are saved automatically — `-c` / `--resume <id>` to continue, `hara sessions` to list, `hara export [id] [--out file]` to render one as a Markdown transcript.
189
191
  **MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
190
192
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
191
193
  **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`.
@@ -205,7 +207,7 @@ the diff and either approves or sends it back with fixes — looping implement
205
207
  (or `--rounds N`). Add **`--commit`** and it commits the approved result with an AI-written message (guarded
206
208
  to a clean start tree; a review that doesn't pass leaves the work uncommitted). The
207
209
  **`agent`** tool spawns **parallel read-only sub-agents** for fan-out — analyze / review / search
208
- several things at once (each can take a `role`).
210
+ several things at once (each can take a `role`), bounded to 8 concurrent (`HARA_MAX_CONCURRENCY`).
209
211
 
210
212
  Beyond routing, **`hara plan "<task>"`** makes the org *plan*: it decomposes the task into atoms,
211
213
  sequences them as a DAG, and executes each step (optionally routed to a role) behind a per-step
@@ -5,6 +5,7 @@ import { activity } from "../activity.js";
5
5
  import { makeRenderer } from "../md.js";
6
6
  import { skillsDigest } from "../skills/skills.js";
7
7
  import { runHooks } from "../hooks.js";
8
+ import { mapLimit, maxParallel } from "../concurrency.js";
8
9
  /** Whether a tool call needs user confirmation under the given approval mode. */
9
10
  export function needsConfirm(kind, mode) {
10
11
  if (kind === "read")
@@ -181,21 +182,25 @@ export async function runAgent(history, opts) {
181
182
  activity.dec();
182
183
  }
183
184
  };
184
- let batch = [];
185
+ let batch = []; // indices of pending read-kind tools (run concurrently, capped)
186
+ const flush = async () => {
187
+ if (!batch.length)
188
+ return;
189
+ const idx = batch;
190
+ batch = [];
191
+ await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
192
+ };
185
193
  for (let i = 0; i < plans.length; i++) {
186
194
  const p = plans[i];
187
195
  if (p.denied === undefined && p.tool?.kind === "read") {
188
- batch.push(runOne(i, p)); // safe → accumulate to run concurrently
196
+ batch.push(i); // safe → accumulate to run concurrently
189
197
  }
190
198
  else {
191
- if (batch.length) {
192
- await Promise.all(batch); // flush pending reads before an edit/exec
193
- batch = [];
194
- }
199
+ await flush(); // flush pending reads before an edit/exec
195
200
  await runOne(i, p);
196
201
  }
197
202
  }
198
- await Promise.all(batch);
203
+ await flush();
199
204
  history.push({ role: "tool", results });
200
205
  }
201
206
  }
@@ -0,0 +1,22 @@
1
+ // Bounded-concurrency map: run `fn` over `items` with at most `limit` in flight at once, preserving input
2
+ // order in the results. Used to cap parallel sub-agents / read-tools / plan atoms so a wide fan-out (the
3
+ // model spawning 20 `agent` calls in one turn) doesn't hammer the provider's rate limits or thrash the box.
4
+ // cc-haha caps tool concurrency at 10; hara defaults to 8, tunable via HARA_MAX_CONCURRENCY.
5
+ export function maxParallel() {
6
+ const n = Number(process.env.HARA_MAX_CONCURRENCY);
7
+ return Number.isInteger(n) && n >= 1 ? n : 8;
8
+ }
9
+ export async function mapLimit(items, limit, fn) {
10
+ const results = new Array(items.length);
11
+ let next = 0;
12
+ const worker = async () => {
13
+ for (;;) {
14
+ const i = next++;
15
+ if (i >= items.length)
16
+ return;
17
+ results[i] = await fn(items[i], i);
18
+ }
19
+ };
20
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
21
+ return results;
22
+ }
package/dist/config.js CHANGED
@@ -10,6 +10,7 @@ const PROVIDER_DEFAULTS = {
10
10
  },
11
11
  "qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
12
12
  openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
13
+ "hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
13
14
  };
14
15
  export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify", "vimMode"];
15
16
  export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
package/dist/export.js ADDED
@@ -0,0 +1,43 @@
1
+ const CAP = 4000; // per tool-result body, so a giant log doesn't bloat the transcript
2
+ /** A session → Markdown: a header (title/model/cwd/date) then each turn (you / hara / tool results). */
3
+ export function renderSessionMarkdown(data) {
4
+ const { meta, history } = data;
5
+ const out = [
6
+ `# ${meta.title || meta.id}`,
7
+ "",
8
+ `- **session** \`${meta.id}\``,
9
+ `- **model** ${meta.provider}:${meta.model}`,
10
+ `- **cwd** ${meta.cwd}`,
11
+ `- **created** ${meta.createdAt}`,
12
+ "",
13
+ "---",
14
+ "",
15
+ ];
16
+ for (const m of history) {
17
+ if (m.role === "user") {
18
+ const text = (m.content ?? "").trim();
19
+ if (text)
20
+ out.push("## 🧑 You", "", text, "");
21
+ }
22
+ else if (m.role === "assistant") {
23
+ const parts = [];
24
+ if (m.text?.trim())
25
+ parts.push(m.text.trim());
26
+ for (const tu of m.toolUses ?? []) {
27
+ const input = JSON.stringify(tu.input ?? {});
28
+ parts.push(`> 🔧 \`${tu.name}\`${input && input !== "{}" ? ` \`${input.length > 200 ? input.slice(0, 200) + "…" : input}\`` : ""}`);
29
+ }
30
+ if (parts.length)
31
+ out.push("## 🤖 hara", "", parts.join("\n\n"), "");
32
+ }
33
+ else {
34
+ for (const r of m.results ?? []) {
35
+ const body = String(r.content ?? "").trim();
36
+ if (!body)
37
+ continue;
38
+ out.push(`<details><summary>↳ ${r.name}${r.isError ? " (error)" : ""}</summary>`, "", "```", body.length > CAP ? body.slice(0, CAP) + `\n…[${body.length - CAP} more chars]` : body, "```", "", "</details>", "");
39
+ }
40
+ }
41
+ }
42
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
43
+ }
package/dist/index.js CHANGED
@@ -18,6 +18,9 @@ import { runAgent } from "./agent/loop.js";
18
18
  import { notifyDone } from "./notify.js";
19
19
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
20
20
  import { completionScript } from "./completions.js";
21
+ import { renderSessionMarkdown } from "./export.js";
22
+ import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL } from "./org-fleet/enroll.js";
23
+ import { mapLimit, maxParallel } from "./concurrency.js";
21
24
  import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
22
25
  import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
23
26
  import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
@@ -78,6 +81,12 @@ async function buildProvider(cfg) {
78
81
  return null;
79
82
  return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
80
83
  }
84
+ if (cfg.provider === "hara-gateway") {
85
+ const e = loadEnrollment();
86
+ if (!e)
87
+ return null; // not enrolled → `hara enroll`
88
+ return createOpenAIProvider({ apiKey: e.deviceToken, baseURL: gatewayBaseURL(e), model: cfg.model || e.model, label: "hara-gateway" });
89
+ }
81
90
  if (!cfg.apiKey)
82
91
  return null;
83
92
  if (cfg.provider === "anthropic") {
@@ -88,7 +97,38 @@ async function buildProvider(cfg) {
88
97
  function authHint(cfg) {
89
98
  if (cfg.provider === "qwen-oauth")
90
99
  return `Run ${c.bold("hara login qwen")} to authenticate.`;
91
- return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara config set apiKey <key>")}.`;
100
+ return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
101
+ }
102
+ const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
103
+ /** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model → ~/.hara/config.json. */
104
+ async function runSetup() {
105
+ if (!stdin.isTTY) {
106
+ out(c.yellow("`hara setup` is interactive — run it in a terminal, or use `hara config set <key> <value>` in scripts.\n"));
107
+ return;
108
+ }
109
+ const rl = createInterface({ input: stdin, output: stdout });
110
+ try {
111
+ out(c.bold("hara setup") + c.dim(" — configure a provider, key, and model (Ctrl-C to cancel)\n\n"));
112
+ const provider = ((await rl.question(`Provider ${c.dim("anthropic / qwen / openai")} [anthropic]: `)).trim() || "anthropic").toLowerCase();
113
+ let baseURL = "";
114
+ if (provider === "qwen" || provider === "openai") {
115
+ baseURL = (await rl.question(`Base URL ${c.dim("(blank = default; set for an OpenAI-compatible endpoint, e.g. GLM/Kimi/DashScope)")}: `)).trim();
116
+ }
117
+ const envKey = providerEnvKey(provider);
118
+ const apiKey = (await rl.question(`API key ${c.dim(`(blank = use the ${envKey} env var)`)}: `)).trim();
119
+ const model = (await rl.question(`Model [${SETUP_DEFAULT_MODEL[provider] ?? "?"}]: `)).trim() || SETUP_DEFAULT_MODEL[provider] || "";
120
+ writeConfigValue("provider", provider);
121
+ if (baseURL)
122
+ writeConfigValue("baseURL", baseURL);
123
+ if (apiKey)
124
+ writeConfigValue("apiKey", apiKey);
125
+ if (model)
126
+ writeConfigValue("model", model);
127
+ out(c.green(`\n✓ saved to ${configPath()}\n`) + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
128
+ }
129
+ finally {
130
+ rl.close();
131
+ }
92
132
  }
93
133
  async function runInit(provider, cwd, sandbox = "off") {
94
134
  const history = [{ role: "user", content: INIT_PROMPT }];
@@ -319,7 +359,7 @@ async function executePlan(plan, roles, o) {
319
359
  if (!todo.length)
320
360
  continue; // whole wave already complete (resume)
321
361
  out(c.cyan(`\n▶ wave [${todo.map((a) => a.id).join(", ")}] — ${todo.length} in parallel\n`));
322
- const results = await Promise.all(todo.map((atom) => executeAtom(atom, plan, done, roles, o)));
362
+ const results = await mapLimit(todo, maxParallel(), (atom) => executeAtom(atom, plan, done, roles, o)); // bounded
323
363
  todo.forEach((atom, i) => {
324
364
  if (results[i]) {
325
365
  done.push(atom);
@@ -696,6 +736,55 @@ program
696
736
  .command("doctor")
697
737
  .description("check your hara setup (provider / auth / model / node / assets / roles)")
698
738
  .action(() => out(runDoctor(loadConfig()) + "\n"));
739
+ program
740
+ .command("setup")
741
+ .description("interactive first-run setup — pick a provider, API key, and model")
742
+ .action(runSetup);
743
+ program
744
+ .command("enroll [gateway-url]")
745
+ .description("B-end: join a fleet — trade a one-time code for a device token (routes hara through your org's gateway; no provider key on this device)")
746
+ .option("--code <code>", "enrollment code from your hara-control admin")
747
+ .option("--status", "show the current enrollment")
748
+ .option("--clear", "remove the enrollment (revert to your own provider config)")
749
+ .action(async (gatewayUrl, opts) => {
750
+ if (opts.status) {
751
+ const e = loadEnrollment();
752
+ return void out(e ? c.green("enrolled") + c.dim(` · ${e.gatewayUrl} · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · since ${e.enrolledAt}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
753
+ }
754
+ if (opts.clear)
755
+ return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
756
+ if (!gatewayUrl)
757
+ return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
758
+ if (!opts.code)
759
+ return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
760
+ try {
761
+ const e = await enrollDevice(gatewayUrl, opts.code);
762
+ writeConfigValue("provider", "hara-gateway");
763
+ if (e.model)
764
+ writeConfigValue("model", e.model);
765
+ 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"));
766
+ }
767
+ catch (err) {
768
+ out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
769
+ }
770
+ });
771
+ program
772
+ .command("export [session]")
773
+ .description("export a session to a Markdown transcript (default: the latest in this directory)")
774
+ .option("--out <file>", "write to a file instead of stdout")
775
+ .action((sessionArg, opts) => {
776
+ const data = sessionArg ? (() => { const id = resolveSessionId(sessionArg); return id ? loadSession(id) : null; })() : latestForCwd(process.cwd());
777
+ if (!data)
778
+ return void out(c.red(sessionArg ? `No session matching '${sessionArg}'.\n` : "No session for this directory — pass an id (see `hara sessions`).\n"));
779
+ const md = renderSessionMarkdown(data);
780
+ if (opts.out) {
781
+ writeFileSync(opts.out, md, "utf8");
782
+ out(c.green(`✓ wrote ${opts.out}`) + c.dim(` (${md.length} chars)\n`));
783
+ }
784
+ else {
785
+ out(md);
786
+ }
787
+ });
699
788
  program
700
789
  .command("completions <shell>")
701
790
  .description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
@@ -1133,10 +1222,23 @@ program.action(async (opts) => {
1133
1222
  cfg.model = opts.model;
1134
1223
  const provider0 = await buildProvider(cfg);
1135
1224
  if (!provider0) {
1225
+ // First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
1226
+ if (stdin.isTTY && !opts.print) {
1227
+ const rl = createInterface({ input: stdin, output: stdout });
1228
+ const ans = (await rl.question(c.yellow(`Not authenticated for '${cfg.provider}'. Run setup now? `) + c.dim("[Y/n] "))).trim().toLowerCase();
1229
+ rl.close();
1230
+ if (ans === "" || ans === "y" || ans === "yes") {
1231
+ await runSetup();
1232
+ out(c.dim(`\nThen run ${c.bold("hara")} to start.\n`));
1233
+ process.exit(0);
1234
+ }
1235
+ }
1136
1236
  out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1137
1237
  process.exit(1);
1138
1238
  }
1139
1239
  let provider = provider0;
1240
+ if (cfg.provider === "hara-gateway")
1241
+ void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
1140
1242
  const cwd = cfg.cwd;
1141
1243
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
1142
1244
  let currentTurn = null; // set during a running turn so Esc can abort it
@@ -0,0 +1,94 @@
1
+ // B-end device enrollment (the OSS client side of the fleet/control-plane story). A hara device joins a
2
+ // fleet by exchanging a one-time enrollment code for a scoped, revocable DEVICE TOKEN — it never holds the
3
+ // real provider key (that stays at the gateway). hara then points its OpenAI-compatible calls at the
4
+ // gateway, which validates the token, maps it to an upstream key, and proxies. Heartbeats give the control
5
+ // plane fleet visibility. Token + endpoint live in ~/.hara/org.json (0600).
6
+ //
7
+ // Protocol (what `hara-control` implements on the other end):
8
+ // POST {gateway}/v1/enroll {code, device:{name,os,hara_version}} -> {device_token, device_id, model, base_url?}
9
+ // POST {gateway}/v1/heartbeat Bearer <device_token> {device_id, name, os, hara_version} -> 200/204
10
+ // POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
11
+ import { homedir, hostname, platform } from "node:os";
12
+ import { join } from "node:path";
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
14
+ const orgPath = () => join(homedir(), ".hara", "org.json");
15
+ const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
16
+ /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
17
+ export function gatewayBaseURL(e) {
18
+ return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
19
+ }
20
+ export function loadEnrollment() {
21
+ const p = orgPath();
22
+ if (!existsSync(p))
23
+ return null;
24
+ try {
25
+ const e = JSON.parse(readFileSync(p, "utf8"));
26
+ return e && typeof e === "object" && e.gatewayUrl && e.deviceToken ? e : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ function saveEnrollment(e) {
33
+ mkdirSync(join(homedir(), ".hara"), { recursive: true });
34
+ writeFileSync(orgPath(), JSON.stringify(e, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); // holds a device token → 0600
35
+ try {
36
+ chmodSync(orgPath(), 0o600);
37
+ }
38
+ catch {
39
+ /* best-effort */
40
+ }
41
+ }
42
+ export function clearEnrollment() {
43
+ if (!existsSync(orgPath()))
44
+ return false;
45
+ rmSync(orgPath());
46
+ return true;
47
+ }
48
+ /** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
49
+ export function parseEnrollResponse(gatewayUrl, j, now) {
50
+ const deviceToken = (j.device_token ?? j.deviceToken);
51
+ if (!deviceToken)
52
+ throw new Error("enroll response missing device_token");
53
+ return {
54
+ gatewayUrl: gatewayUrl.replace(/\/$/, ""),
55
+ deviceToken,
56
+ deviceId: String(j.device_id ?? j.deviceId ?? ""),
57
+ model: String(j.model ?? ""),
58
+ baseURL: (j.base_url ?? j.baseURL),
59
+ enrolledAt: now,
60
+ };
61
+ }
62
+ /** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
63
+ export async function enrollDevice(gatewayUrl, code, signal) {
64
+ const base = gatewayUrl.replace(/\/$/, "");
65
+ const res = await fetch(`${base}/v1/enroll`, {
66
+ method: "POST",
67
+ signal,
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ code, device: deviceInfo() }),
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}: ${(await res.text()).slice(0, 200)}`);
73
+ const e = parseEnrollResponse(base, (await res.json()), new Date().toISOString());
74
+ saveEnrollment(e);
75
+ return e;
76
+ }
77
+ /** Best-effort heartbeat so the control plane shows this device online. Never throws. */
78
+ export async function heartbeat(signal) {
79
+ const e = loadEnrollment();
80
+ if (!e)
81
+ return false;
82
+ try {
83
+ const res = await fetch(`${e.gatewayUrl}/v1/heartbeat`, {
84
+ method: "POST",
85
+ signal,
86
+ headers: { "content-type": "application/json", authorization: `Bearer ${e.deviceToken}` },
87
+ body: JSON.stringify({ device_id: e.deviceId, ...deviceInfo() }),
88
+ });
89
+ return res.ok;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
package/dist/recall.js CHANGED
@@ -24,6 +24,20 @@ export function titleOf(text, path) {
24
24
  const h = /^#\s+(.+)$/m.exec(text);
25
25
  return h ? h[1].trim() : (path.split("/").pop() ?? path);
26
26
  }
27
+ /** A ranking boost from the asset's declared dimensions: a query word in the title or the frontmatter
28
+ * tags/lang matters more than one buried in the body. Used to order results, NOT the base relevance
29
+ * score (which the dedup threshold relies on). */
30
+ export function metaBoost(text, title, words) {
31
+ const titleL = title.toLowerCase();
32
+ let b = words.filter((w) => titleL.includes(w)).length * 3;
33
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text);
34
+ if (fm) {
35
+ const tags = (/(?:^|\n)tags:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
36
+ const lang = (/(?:^|\n)lang:\s*(.+)/i.exec(fm[1])?.[1] ?? "").toLowerCase();
37
+ b += words.filter((w) => `${tags} ${lang}`.includes(w)).length * 2;
38
+ }
39
+ return b;
40
+ }
27
41
  /**
28
42
  * Lexical search: rank .md files by how many query words appear in path+content.
29
43
  * Default searches the code-asset library (relative paths). Pass `roots` to search other dirs
@@ -48,14 +62,16 @@ export function searchAssets(query, limit = 5, roots) {
48
62
  continue;
49
63
  }
50
64
  const hay = (rel + "\n" + text).toLowerCase();
51
- const score = words.filter((w) => hay.includes(w)).length;
65
+ const score = words.filter((w) => hay.includes(w)).length; // distinct query words present (dedup threshold uses this)
52
66
  if (!score)
53
67
  continue;
54
- hits.push({ path: abs ? join(dir, rel) : rel, title: titleOf(text, rel), snippet: text.slice(0, 800), score });
68
+ const title = titleOf(text, rel);
69
+ hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text.slice(0, 800), score, boost: metaBoost(text, title, words) });
55
70
  }
56
71
  }
57
- hits.sort((a, b) => b.score - a.score || a.path.length - b.path.length);
58
- return hits.slice(0, limit);
72
+ // rank by relevance, then by the declared-dimension boost (title/tags/lang), then prefer the shorter path
73
+ hits.sort((a, b) => b.score - a.score || b.boost - a.boost || a.path.length - b.path.length);
74
+ return hits.slice(0, limit).map(({ boost, ...r }) => r);
59
75
  }
60
76
  /** Create the assets dir with an example snippet + README. Returns files written. */
61
77
  export function scaffoldAssets() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.62.0",
3
+ "version": "0.67.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"