@nanhara/hara 0.62.0 → 0.70.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 +94 -0
- package/README.md +16 -4
- package/dist/agent/loop.js +12 -7
- package/dist/concurrency.js +22 -0
- package/dist/config.js +1 -0
- package/dist/context/mentions.js +35 -31
- package/dist/export.js +43 -0
- package/dist/index.js +109 -2
- package/dist/org/roles.js +8 -2
- package/dist/org-fleet/enroll.js +143 -0
- package/dist/recall.js +20 -4
- package/dist/search/semindex.js +18 -0
- package/dist/search/zvec-store.js +77 -0
- package/package.json +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,100 @@ 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.70.0 — unreleased (B-end: devices pull their governed org-role bundle)
|
|
9
|
+
|
|
10
|
+
- An enrolled device now **syncs its digital-employee roles from hara-control** — `GET {gateway}/v1/roles`
|
|
11
|
+
(Bearer device token) returns the governed bundle the control plane resolved for this device's
|
|
12
|
+
person/team, and hara materializes it into `~/.hara/org-roles/*.md`. This closes the B3 gap where the
|
|
13
|
+
server could resolve+govern roles but the CLI never consumed them (it only read local `.hara/roles/`).
|
|
14
|
+
- **Precedence layer** (in `loadRoles`): `plugins < org(B-end push) < global < .claude/agents < project`
|
|
15
|
+
— the org baseline sits above third-party plugins, but a dev's own global/project roles still win, so
|
|
16
|
+
pushed roles are a managed default, not a lock. (Org policy can tighten this later.)
|
|
17
|
+
- **Authoritative replace**: the org bundle owns `~/.hara/org-roles/` — each sync wipes and rewrites it,
|
|
18
|
+
so a server-side role revoke/rename actually removes the local file. A `_policy.json` sidecar carries
|
|
19
|
+
the org governance floor (model/tool/approval) for later enforcement.
|
|
20
|
+
- Wired on `hara enroll` (reports the count) and best-effort in the background on startup when
|
|
21
|
+
`provider=hara-gateway` (alongside the heartbeat). Never throws / never blocks — returns 0 on any
|
|
22
|
+
failure or when not enrolled. snake_case wire fields (`allow_tools`/`deny_tools`) map to the CLI's
|
|
23
|
+
`allowTools`/`denyTools` frontmatter. New `syncOrgRoles()` in `src/org-fleet/enroll.ts` + test.
|
|
24
|
+
|
|
25
|
+
## 0.69.1 — unreleased (@file expands inline, not appended at the bottom)
|
|
26
|
+
|
|
27
|
+
- `@path` mentions now expand **in place** — the referenced file/dir content lands exactly where it's
|
|
28
|
+
written, so "compare `@a.ts` with `@b.ts`" reads in context instead of keeping the bare `@a.ts`
|
|
29
|
+
tokens and dumping both files in a block at the very end. A repeated mention keeps the bare `@path`
|
|
30
|
+
the second time (no double-inlining); a non-readable ref is left as typed. Labels ("Referenced file
|
|
31
|
+
`x`:") and the image/dir handling are unchanged — only the position moved.
|
|
32
|
+
|
|
33
|
+
## 0.69.0 — unreleased (real local vector store via zvec)
|
|
34
|
+
|
|
35
|
+
- Local semantic search now uses **zvec** (`@zvec/zvec`, an in-process native vector DB) for ANN
|
|
36
|
+
retrieval, replacing the brute-force-cosine scan as the query path. zvec was previously referenced
|
|
37
|
+
only in comments as the "scale-up path"; it's now actually wired (it ships a darwin-arm64/linux
|
|
38
|
+
prebuilt, added as an **optionalDependency**).
|
|
39
|
+
- Design keeps the JSON store as the durable embedding cache + SSOT for hit text, and recomputes the
|
|
40
|
+
cosine **score from the JSON vectors** after zvec returns candidate ids — so ranking/threshold
|
|
41
|
+
semantics are byte-identical to before (no change to `hybrid.ts`), zvec just does fast candidate
|
|
42
|
+
retrieval. Build writes the zvec index alongside the JSON cache (`~/.hara/index/<name>.zvec`).
|
|
43
|
+
- **Graceful fallback preserved**: if `@zvec/zvec` is absent or its native binding fails to load (a
|
|
44
|
+
platform with no prebuilt), `queryIndex` falls back to the JSON brute-force — so installs without
|
|
45
|
+
the binding keep working. Lexical remains the zero-dep floor when no embedder is configured at all.
|
|
46
|
+
- New `src/search/zvec-store.ts` (lazy load · read-only open for queries · best-effort build) + test.
|
|
47
|
+
|
|
48
|
+
## 0.68.0 — unreleased (run hara in Docker)
|
|
49
|
+
|
|
50
|
+
- **Dockerfile** — run hara in a container against any mounted repo, no Node install needed, and as an
|
|
51
|
+
isolated/ephemeral environment (handy for CI). Multi-stage build → a slim runtime that still ships a
|
|
52
|
+
shell + `git` + `ripgrep` (the bash/search tools need them; a distroless image would break them).
|
|
53
|
+
`docker run --rm -v "$PWD:/workspace" -e HARA_API_KEY=… ghcr.io/hara-cli/hara -p "…"`.
|
|
54
|
+
- **Release pipeline** now also builds + pushes a multi-arch (amd64/arm64) image to
|
|
55
|
+
`ghcr.io/hara-cli/hara` on a version tag, alongside the existing standalone binaries — so the Docker
|
|
56
|
+
and binary distributions stay in lockstep. (Single-binary distribution shipped in 0.60.0; this
|
|
57
|
+
rounds out "install hara any way you like": npm · binary · Docker.)
|
|
58
|
+
|
|
59
|
+
## 0.67.0 — unreleased (bounded parallel concurrency)
|
|
60
|
+
|
|
61
|
+
- hara already runs work in parallel — fan-out **`agent`** sub-agents, concurrent read-kind tools in a turn,
|
|
62
|
+
and `hara plan --parallel` waves — but with **no cap**: the model spawning 20 `agent` calls in one turn
|
|
63
|
+
started 20 LLM loops at once (provider rate-limits / resource thrash). Now a **bounded pool** (`mapLimit`)
|
|
64
|
+
caps in-flight parallelism to **8** by default (tunable via `HARA_MAX_CONCURRENCY`), matching cc-haha's
|
|
65
|
+
safeguard (it caps at 10). Excess work queues and runs as slots free; ordering + behavior otherwise
|
|
66
|
+
unchanged. Applied to the loop's read/agent batch and the parallel-plan wave.
|
|
67
|
+
|
|
68
|
+
## 0.66.0 — unreleased (B-end: device enrollment + `hara-gateway` provider)
|
|
69
|
+
|
|
70
|
+
- First slice of the **B-end** (fleets / control plane): `hara enroll <gateway-url> --code <code>` trades a
|
|
71
|
+
one-time code for a scoped, revocable **device token** (stored `0600` in `~/.hara/org.json`) and switches
|
|
72
|
+
hara to the new **`hara-gateway`** provider — an OpenAI-compatible client pointed at your org's gateway.
|
|
73
|
+
**The real provider key never touches the device** (it stays at the gateway). A heartbeat fires on start
|
|
74
|
+
for fleet visibility; `--status` / `--clear` manage it. The device↔gateway protocol (enroll / heartbeat /
|
|
75
|
+
OpenAI-compatible proxy) is documented in `docs/b-end.md` and **verified end-to-end against a stub control
|
|
76
|
+
plane**. The control-plane server (`hara-control`) + the LiteLLM data-plane are the next, separate increment.
|
|
77
|
+
|
|
78
|
+
## 0.65.0 — unreleased (frontmatter-aware asset recall)
|
|
79
|
+
|
|
80
|
+
- Asset/skill recall (`searchAssets`, behind `hara recall` / `/recall` / skill dedup) now **ranks by the
|
|
81
|
+
asset's declared dimensions** — a query word in the `title` or the frontmatter `tags`/`lang` counts more
|
|
82
|
+
than one buried in the body. The asset format already declared these (the scaffold seeds `tags`/`lang`);
|
|
83
|
+
retrieval now actually uses them. The base relevance score (distinct query words present) is unchanged,
|
|
84
|
+
so the dedup-before-save threshold is unaffected — this only improves *ordering*. (Studied codex + cc-haha:
|
|
85
|
+
both stay lexical + manual-file curation with no semantic search or auto-capture; hara's hybrid lexical+
|
|
86
|
+
opt-in-semantic recall over a unified skills/code-assets/memory corpus is already ahead.)
|
|
87
|
+
|
|
88
|
+
## 0.64.0 — unreleased (session export)
|
|
89
|
+
|
|
90
|
+
- **`hara export [session] [--out file]`** renders a saved session to a Markdown transcript — the header
|
|
91
|
+
(title/model/cwd/date), each turn (you / hara), tool calls inline, and tool results in collapsible
|
|
92
|
+
`<details>` blocks (capped). Default is the latest session in the current directory. For sharing a
|
|
93
|
+
decision, pasting into a PR, or archiving. Pure renderer (`src/export.ts`), unit-tested.
|
|
94
|
+
|
|
95
|
+
## 0.63.0 — unreleased (first-run setup wizard)
|
|
96
|
+
|
|
97
|
+
- **`hara setup`** — an interactive wizard (provider → optional base URL → API key → model) that writes
|
|
98
|
+
`~/.hara/config.json` (0600), so a new user doesn't have to know the individual `hara config set` keys.
|
|
99
|
+
It's also **auto-offered** when you start `hara` unconfigured ("Not authenticated — run setup now?")
|
|
100
|
+
instead of just erroring. TTY-only (scripts get a clear pointer to `hara config set`).
|
|
101
|
+
|
|
8
102
|
## 0.62.0 — unreleased (shell completions)
|
|
9
103
|
|
|
10
104
|
- **`hara completions bash|zsh|fish`** prints a completion script (eval it in your shell rc) that
|
package/README.md
CHANGED
|
@@ -31,6 +31,16 @@ curl -fsSL https://raw.githubusercontent.com/hara-cli/hara/main/install.sh | sh
|
|
|
31
31
|
|
|
32
32
|
Tab completion (optional): `eval "$(hara completions zsh)"` in your `~/.zshrc` (or `bash`/`fish`).
|
|
33
33
|
|
|
34
|
+
Or in **Docker** — run hara against any repo without installing Node, and as an isolated/ephemeral
|
|
35
|
+
environment (handy for CI):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
docker run --rm -v "$PWD:/workspace" -e HARA_API_KEY=sk-... ghcr.io/hara-cli/hara -p "summarize this repo"
|
|
39
|
+
# interactive TUI:
|
|
40
|
+
docker run --rm -it -v "$PWD:/workspace" -e HARA_API_KEY=sk-... ghcr.io/hara-cli/hara
|
|
41
|
+
# or build it yourself: docker build -t hara . && docker run --rm -v "$PWD:/workspace" -e HARA_API_KEY=sk-... hara
|
|
42
|
+
```
|
|
43
|
+
|
|
34
44
|
Or from source:
|
|
35
45
|
|
|
36
46
|
```bash
|
|
@@ -59,7 +69,9 @@ hara -p "summarize @README.md and list any TODOs"
|
|
|
59
69
|
|
|
60
70
|
## Setup
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
The fastest path is **`hara setup`** — an interactive wizard for provider + key + model (it also runs
|
|
73
|
+
automatically the first time you start `hara` unconfigured). Or configure it yourself — hara is
|
|
74
|
+
**multi-provider**:
|
|
63
75
|
|
|
64
76
|
**Anthropic (default)**
|
|
65
77
|
```bash
|
|
@@ -185,7 +197,7 @@ only changed files re-embed (a full repo rebuild that takes ~a minute re-runs in
|
|
|
185
197
|
`hara config set computerUse read|click|full` and allowlist apps with `hara config set computerApps "App, …"`. Guarded
|
|
186
198
|
by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-per-session grant. Screenshots are read via your
|
|
187
199
|
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.
|
|
200
|
+
**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
201
|
**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
202
|
**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
203
|
**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 +217,7 @@ the diff and either approves or sends it back with fixes — looping implement
|
|
|
205
217
|
(or `--rounds N`). Add **`--commit`** and it commits the approved result with an AI-written message (guarded
|
|
206
218
|
to a clean start tree; a review that doesn't pass leaves the work uncommitted). The
|
|
207
219
|
**`agent`** tool spawns **parallel read-only sub-agents** for fan-out — analyze / review / search
|
|
208
|
-
several things at once (each can take a `role`).
|
|
220
|
+
several things at once (each can take a `role`), bounded to 8 concurrent (`HARA_MAX_CONCURRENCY`).
|
|
209
221
|
|
|
210
222
|
Beyond routing, **`hara plan "<task>"`** makes the org *plan*: it decomposes the task into atoms,
|
|
211
223
|
sequences them as a DAG, and executes each step (optionally routed to a role) behind a per-step
|
|
@@ -231,7 +243,7 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
|
|
|
231
243
|
|
|
232
244
|
### Roadmap
|
|
233
245
|
|
|
234
|
-
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **single-binary distribution** · `/compact` context management.
|
|
246
|
+
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **single-binary distribution** · **Docker image** · `/compact` context management.
|
|
235
247
|
**Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
|
|
236
248
|
|
|
237
249
|
## Security
|
package/dist/agent/loop.js
CHANGED
|
@@ -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(
|
|
196
|
+
batch.push(i); // safe → accumulate to run concurrently
|
|
189
197
|
}
|
|
190
198
|
else {
|
|
191
|
-
|
|
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
|
|
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/context/mentions.js
CHANGED
|
@@ -8,45 +8,49 @@ import { mediaTypeFor } from "../images.js";
|
|
|
8
8
|
const MAX_FILE = 50_000;
|
|
9
9
|
// @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
|
|
10
10
|
const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
|
|
11
|
-
/**
|
|
11
|
+
/** Expand `@path` references **in place** — the file/dir content lands where it's referenced, not
|
|
12
|
+
* dumped at the bottom (so "compare @a.ts with @b.ts" reads in context). A repeated mention keeps
|
|
13
|
+
* the bare `@path` the second time (no double-inlining), and a non-readable ref is left untouched. */
|
|
12
14
|
export function expandMentions(input, cwd) {
|
|
13
15
|
const seen = new Set();
|
|
14
|
-
const blocks = [];
|
|
15
|
-
let m;
|
|
16
16
|
MENTION_RE.lastIndex = 0;
|
|
17
|
-
|
|
18
|
-
const
|
|
17
|
+
return input.replace(MENTION_RE, (whole, ref) => {
|
|
18
|
+
const prefix = whole.slice(0, whole.length - ref.length - 1); // BOL "" or the captured leading whitespace
|
|
19
19
|
if (seen.has(ref))
|
|
20
|
-
|
|
20
|
+
return whole; // already inlined above → leave this occurrence as the bare @ref
|
|
21
|
+
const block = expandRef(ref, cwd);
|
|
22
|
+
if (block === null)
|
|
23
|
+
return whole; // not a readable file/dir → leave the token as typed
|
|
21
24
|
seen.add(ref);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const files = walkFiles(abs, 300);
|
|
41
|
-
blocks.push(`Referenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\``);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
25
|
+
return `${prefix}${block}`;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Render one mention as an inline block, or null if it isn't a readable file/dir. */
|
|
29
|
+
function expandRef(ref, cwd) {
|
|
30
|
+
const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(abs))
|
|
33
|
+
return null;
|
|
34
|
+
const st = statSync(abs);
|
|
35
|
+
if (st.isFile()) {
|
|
36
|
+
// don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
|
|
37
|
+
if (mediaTypeFor(abs))
|
|
38
|
+
return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
|
|
39
|
+
let txt = readFileSync(abs, "utf8");
|
|
40
|
+
if (txt.length > MAX_FILE)
|
|
41
|
+
txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
|
|
42
|
+
return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
|
|
44
43
|
}
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
if (st.isDirectory()) {
|
|
45
|
+
// `@dir` loads a listing of the directory's files (the agent can then read specific ones)
|
|
46
|
+
const files = walkFiles(abs, 300);
|
|
47
|
+
return `\nReferenced directory \`${ref}\` (${files.length} files):\n\`\`\`\n${files.join("\n") || "(empty)"}\n\`\`\`\n`;
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
+
catch {
|
|
51
|
+
/* ignore unreadable mention */
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
50
54
|
}
|
|
51
55
|
// Short-lived per-cwd cache so Tab completion stays snappy without re-scanning every press.
|
|
52
56
|
const cache = new Map();
|
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, syncOrgRoles } 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
|
|
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
|
|
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,58 @@ 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
|
+
const nRoles = await syncOrgRoles(); // pull this device's governed digital-employee bundle (B3)
|
|
767
|
+
if (nRoles > 0)
|
|
768
|
+
out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
program
|
|
775
|
+
.command("export [session]")
|
|
776
|
+
.description("export a session to a Markdown transcript (default: the latest in this directory)")
|
|
777
|
+
.option("--out <file>", "write to a file instead of stdout")
|
|
778
|
+
.action((sessionArg, opts) => {
|
|
779
|
+
const data = sessionArg ? (() => { const id = resolveSessionId(sessionArg); return id ? loadSession(id) : null; })() : latestForCwd(process.cwd());
|
|
780
|
+
if (!data)
|
|
781
|
+
return void out(c.red(sessionArg ? `No session matching '${sessionArg}'.\n` : "No session for this directory — pass an id (see `hara sessions`).\n"));
|
|
782
|
+
const md = renderSessionMarkdown(data);
|
|
783
|
+
if (opts.out) {
|
|
784
|
+
writeFileSync(opts.out, md, "utf8");
|
|
785
|
+
out(c.green(`✓ wrote ${opts.out}`) + c.dim(` (${md.length} chars)\n`));
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
out(md);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
699
791
|
program
|
|
700
792
|
.command("completions <shell>")
|
|
701
793
|
.description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
|
|
@@ -1133,10 +1225,25 @@ program.action(async (opts) => {
|
|
|
1133
1225
|
cfg.model = opts.model;
|
|
1134
1226
|
const provider0 = await buildProvider(cfg);
|
|
1135
1227
|
if (!provider0) {
|
|
1228
|
+
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1229
|
+
if (stdin.isTTY && !opts.print) {
|
|
1230
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
1231
|
+
const ans = (await rl.question(c.yellow(`Not authenticated for '${cfg.provider}'. Run setup now? `) + c.dim("[Y/n] "))).trim().toLowerCase();
|
|
1232
|
+
rl.close();
|
|
1233
|
+
if (ans === "" || ans === "y" || ans === "yes") {
|
|
1234
|
+
await runSetup();
|
|
1235
|
+
out(c.dim(`\nThen run ${c.bold("hara")} to start.\n`));
|
|
1236
|
+
process.exit(0);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1136
1239
|
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
1137
1240
|
process.exit(1);
|
|
1138
1241
|
}
|
|
1139
1242
|
let provider = provider0;
|
|
1243
|
+
if (cfg.provider === "hara-gateway") {
|
|
1244
|
+
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
1245
|
+
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
1246
|
+
}
|
|
1140
1247
|
const cwd = cfg.cwd;
|
|
1141
1248
|
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
1142
1249
|
let currentTurn = null; // set during a running turn so Esc can abort it
|
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
|
|
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)) {
|
|
@@ -0,0 +1,143 @@
|
|
|
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
|
+
// GET {gateway}/v1/roles Bearer <device_token> -> {version, org_policy, roles:[…]} (B3 digital-employee push-down)
|
|
11
|
+
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
12
|
+
import { homedir, hostname, platform } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
15
|
+
import { orgRolesDir } from "../org/roles.js";
|
|
16
|
+
const orgPath = () => join(homedir(), ".hara", "org.json");
|
|
17
|
+
const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
|
|
18
|
+
/** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
|
|
19
|
+
export function gatewayBaseURL(e) {
|
|
20
|
+
return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
21
|
+
}
|
|
22
|
+
export function loadEnrollment() {
|
|
23
|
+
const p = orgPath();
|
|
24
|
+
if (!existsSync(p))
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const e = JSON.parse(readFileSync(p, "utf8"));
|
|
28
|
+
return e && typeof e === "object" && e.gatewayUrl && e.deviceToken ? e : null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function saveEnrollment(e) {
|
|
35
|
+
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
36
|
+
writeFileSync(orgPath(), JSON.stringify(e, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); // holds a device token → 0600
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(orgPath(), 0o600);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* best-effort */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function clearEnrollment() {
|
|
45
|
+
if (!existsSync(orgPath()))
|
|
46
|
+
return false;
|
|
47
|
+
rmSync(orgPath());
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
|
|
51
|
+
export function parseEnrollResponse(gatewayUrl, j, now) {
|
|
52
|
+
const deviceToken = (j.device_token ?? j.deviceToken);
|
|
53
|
+
if (!deviceToken)
|
|
54
|
+
throw new Error("enroll response missing device_token");
|
|
55
|
+
return {
|
|
56
|
+
gatewayUrl: gatewayUrl.replace(/\/$/, ""),
|
|
57
|
+
deviceToken,
|
|
58
|
+
deviceId: String(j.device_id ?? j.deviceId ?? ""),
|
|
59
|
+
model: String(j.model ?? ""),
|
|
60
|
+
baseURL: (j.base_url ?? j.baseURL),
|
|
61
|
+
enrolledAt: now,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Exchange a one-time code for a device token at the gateway, persist it, and return the Enrollment. */
|
|
65
|
+
export async function enrollDevice(gatewayUrl, code, signal) {
|
|
66
|
+
const base = gatewayUrl.replace(/\/$/, "");
|
|
67
|
+
const res = await fetch(`${base}/v1/enroll`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
signal,
|
|
70
|
+
headers: { "content-type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ code, device: deviceInfo() }),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error(`HTTP ${res.status}${res.status === 401 || res.status === 403 ? " — bad or expired code" : ""}: ${(await res.text()).slice(0, 200)}`);
|
|
75
|
+
const e = parseEnrollResponse(base, (await res.json()), new Date().toISOString());
|
|
76
|
+
saveEnrollment(e);
|
|
77
|
+
return e;
|
|
78
|
+
}
|
|
79
|
+
/** Best-effort heartbeat so the control plane shows this device online. Never throws. */
|
|
80
|
+
export async function heartbeat(signal) {
|
|
81
|
+
const e = loadEnrollment();
|
|
82
|
+
if (!e)
|
|
83
|
+
return false;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${e.gatewayUrl}/v1/heartbeat`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
signal,
|
|
88
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${e.deviceToken}` },
|
|
89
|
+
body: JSON.stringify({ device_id: e.deviceId, ...deviceInfo() }),
|
|
90
|
+
});
|
|
91
|
+
return res.ok;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
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/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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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/dist/search/semindex.js
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// zvec-backed ANN index for local semantic search. zvec (@zvec/zvec) is an OPTIONAL native dependency:
|
|
2
|
+
// if it's absent or fails to load (no prebuilt for the platform), every function here returns
|
|
3
|
+
// null/false and the caller falls back to the JSON brute-force store in semindex.ts — so installs
|
|
4
|
+
// without the native binding keep working (lexical/JSON floor preserved).
|
|
5
|
+
//
|
|
6
|
+
// Design: zvec stores only vector→id (a pure ANN index). The JSON store stays the durable embedding
|
|
7
|
+
// cache + SSOT for hit text/score. So zvec is used for fast candidate retrieval, but the SCORE is
|
|
8
|
+
// recomputed from the JSON vectors (in semindex.queryIndex) — identical score semantics, zero risk
|
|
9
|
+
// to hybrid.ts's thresholds.
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { rmSync, existsSync } from "node:fs";
|
|
13
|
+
import { findProjectRoot } from "../context/agents-md.js";
|
|
14
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
15
|
+
let cached = null;
|
|
16
|
+
let attempted = false;
|
|
17
|
+
async function zvec() {
|
|
18
|
+
if (attempted)
|
|
19
|
+
return cached;
|
|
20
|
+
attempted = true;
|
|
21
|
+
try {
|
|
22
|
+
cached = await import("@zvec/zvec");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
cached = null; // optional dep / no native prebuilt → callers fall back to brute-force
|
|
26
|
+
}
|
|
27
|
+
return cached;
|
|
28
|
+
}
|
|
29
|
+
export async function zvecAvailable() {
|
|
30
|
+
return (await zvec()) !== null;
|
|
31
|
+
}
|
|
32
|
+
function zvecDir(name, cwd) {
|
|
33
|
+
if (name === "repo")
|
|
34
|
+
return join(findProjectRoot(cwd), ".hara", "index", "repo.zvec");
|
|
35
|
+
return join(homedir(), ".hara", "index", `${name}.zvec`);
|
|
36
|
+
}
|
|
37
|
+
const VEC = "v";
|
|
38
|
+
/** (Re)build a fresh zvec collection from already-embedded items. Best-effort — false on any failure. */
|
|
39
|
+
export async function zvecBuild(name, items, cwd) {
|
|
40
|
+
const z = await zvec();
|
|
41
|
+
if (!z || items.length === 0)
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
const dim = items[0].vec.length;
|
|
45
|
+
const path = zvecDir(name, cwd);
|
|
46
|
+
rmSync(path, { recursive: true, force: true }); // rebuild from the JSON cache (vectors already computed)
|
|
47
|
+
const schema = new z.ZVecCollectionSchema({
|
|
48
|
+
name,
|
|
49
|
+
vectors: [{ name: VEC, dataType: z.ZVecDataType.VECTOR_FP32, dimension: dim, indexParams: { indexType: z.ZVecIndexType.FLAT, metricType: z.ZVecMetricType.COSINE } }],
|
|
50
|
+
});
|
|
51
|
+
const col = z.ZVecCreateAndOpen(path, schema);
|
|
52
|
+
col.insertSync(items.map((it) => ({ id: it.id, vectors: { [VEC]: it.vec } })));
|
|
53
|
+
col.closeSync?.(); // closeSync flushes + releases the LOCK so a later (read-only) open succeeds
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** ANN candidate ids (best first) from the zvec index. null if unavailable/error → caller brute-forces. */
|
|
61
|
+
export async function zvecQueryIds(name, qv, cwd, k) {
|
|
62
|
+
const z = await zvec();
|
|
63
|
+
if (!z)
|
|
64
|
+
return null;
|
|
65
|
+
if (!existsSync(zvecDir(name, cwd)))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const col = z.ZVecOpen(zvecDir(name, cwd), { readOnly: true }); // read-only → no write-lock contention
|
|
69
|
+
// over-fetch, then semindex re-ranks the candidates by exact cosine from the JSON store
|
|
70
|
+
const docs = await col.query({ fieldName: VEC, vector: qv, topk: Math.max(k * 4, 24) });
|
|
71
|
+
col.closeSync?.();
|
|
72
|
+
return docs.map((d) => d.id);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanhara/hara",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.70.0",
|
|
4
4
|
"description": "hara — a coding agent CLI that runs like an engineering org.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hara": "dist/index.js"
|
|
@@ -63,5 +63,8 @@
|
|
|
63
63
|
"ink-testing-library": "^4.0.0",
|
|
64
64
|
"tsx": "^4.22.4",
|
|
65
65
|
"typescript": "^6.0.3"
|
|
66
|
+
},
|
|
67
|
+
"optionalDependencies": {
|
|
68
|
+
"@zvec/zvec": "^0.5.0"
|
|
66
69
|
}
|
|
67
70
|
}
|