@nanhara/hara 0.67.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 CHANGED
@@ -5,6 +5,57 @@ 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
+
8
59
  ## 0.67.0 — unreleased (bounded parallel concurrency)
9
60
 
10
61
  - hara already runs work in parallel — fan-out **`agent`** sub-agents, concurrent read-kind tools in a turn,
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
@@ -233,7 +243,7 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
233
243
 
234
244
  ### Roadmap
235
245
 
236
- **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.
237
247
  **Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
238
248
 
239
249
  ## Security
@@ -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
- /** Append the contents of any @mentioned files to the input as fenced blocks. */
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
- while ((m = MENTION_RE.exec(input)) !== null) {
18
- const ref = m[1];
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
- continue;
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
- const abs = isAbsolute(ref) ? ref : resolve(cwd, ref);
23
- try {
24
- if (existsSync(abs)) {
25
- const st = statSync(abs);
26
- if (st.isFile()) {
27
- if (mediaTypeFor(abs)) {
28
- // don't inline binary image bytes as text — paste it with Ctrl+V (or drag the file in) to attach visually
29
- blocks.push(`Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`);
30
- }
31
- else {
32
- let txt = readFileSync(abs, "utf8");
33
- if (txt.length > MAX_FILE)
34
- txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
35
- blocks.push(`Referenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\``);
36
- }
37
- }
38
- else if (st.isDirectory()) {
39
- // `@dir` loads a listing of the directory's files (the agent can then read specific ones)
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
- catch {
46
- /* ignore unreadable mention */
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
- return blocks.length ? `${input}\n\n${blocks.join("\n\n")}` : input;
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/index.js CHANGED
@@ -19,7 +19,7 @@ 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
23
  import { mapLimit, maxParallel } from "./concurrency.js";
24
24
  import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
25
25
  import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
@@ -763,6 +763,9 @@ program
763
763
  if (e.model)
764
764
  writeConfigValue("model", e.model);
765
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`));
766
769
  }
767
770
  catch (err) {
768
771
  out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
@@ -1237,8 +1240,10 @@ program.action(async (opts) => {
1237
1240
  process.exit(1);
1238
1241
  }
1239
1242
  let provider = provider0;
1240
- if (cfg.provider === "hara-gateway")
1243
+ if (cfg.provider === "hara-gateway") {
1241
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
+ }
1242
1247
  const cwd = cfg.cwd;
1243
1248
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
1244
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, 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
+ }
@@ -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.67.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
  }