@nanhara/hara 0.122.0 → 0.122.2

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -11
  3. package/SECURITY.md +48 -9
  4. package/dist/agent/loop.js +33 -10
  5. package/dist/agent/touched.js +4 -0
  6. package/dist/checkpoints.js +103 -17
  7. package/dist/cli.js +16 -0
  8. package/dist/config.js +141 -12
  9. package/dist/context/agents-md.js +44 -9
  10. package/dist/context/mentions.js +10 -4
  11. package/dist/context/subdir-hints.js +40 -7
  12. package/dist/cron/runner.js +372 -37
  13. package/dist/cron/store.js +11 -3
  14. package/dist/exec/jobs.js +88 -20
  15. package/dist/fs-read.js +318 -3
  16. package/dist/fs-walk.js +8 -2
  17. package/dist/fs-write.js +197 -11
  18. package/dist/gateway/discord.js +2 -4
  19. package/dist/gateway/feishu.js +4 -6
  20. package/dist/gateway/flows-pending.js +38 -31
  21. package/dist/gateway/matrix.js +3 -5
  22. package/dist/gateway/mattermost.js +2 -4
  23. package/dist/gateway/outbound-files.js +379 -0
  24. package/dist/gateway/serve.js +121 -73
  25. package/dist/gateway/signal.js +4 -6
  26. package/dist/gateway/slack.js +4 -6
  27. package/dist/gateway/telegram.js +3 -5
  28. package/dist/gateway/tmux-routes.js +11 -3
  29. package/dist/gateway/wecom.js +7 -8
  30. package/dist/gateway/weixin.js +22 -12
  31. package/dist/hooks.js +12 -6
  32. package/dist/index.js +142 -61
  33. package/dist/mcp/client.js +164 -12
  34. package/dist/memory/store.js +68 -22
  35. package/dist/org/planner.js +36 -10
  36. package/dist/org/review-chain.js +360 -24
  37. package/dist/org/roles.js +4 -2
  38. package/dist/profile/profile.js +152 -27
  39. package/dist/recall.js +4 -2
  40. package/dist/runtime.js +37 -0
  41. package/dist/sandbox.js +142 -33
  42. package/dist/search/semindex.js +182 -53
  43. package/dist/search/zvec-store.js +121 -42
  44. package/dist/security/permissions.js +326 -19
  45. package/dist/security/private-state.js +299 -0
  46. package/dist/security/project-trust.js +6 -0
  47. package/dist/security/sensitive-files.js +723 -0
  48. package/dist/security/subprocess-env.js +210 -0
  49. package/dist/serve/server.js +2 -1
  50. package/dist/skills/skills.js +16 -7
  51. package/dist/tools/builtin.js +53 -27
  52. package/dist/tools/cron.js +6 -0
  53. package/dist/tools/edit.js +15 -5
  54. package/dist/tools/external_agent.js +110 -16
  55. package/dist/tools/memory.js +38 -8
  56. package/dist/tools/patch.js +37 -17
  57. package/dist/tools/search.js +100 -40
  58. package/dist/tools/send.js +11 -5
  59. package/package.json +11 -10
  60. package/runtime-bootstrap.cjs +72 -0
@@ -2,20 +2,72 @@
2
2
  // code-asset / repo / knowledge-base scale (hundreds–low-thousands of chunks); the optional zvec adapter is
3
3
  // the scale-up path later. Markdown/code stays the SSOT; this index is a derived, rebuildable, gitignored
4
4
  // artifact. The embedder is injected (see embed.ts) so the store + chunking are testable without a model.
5
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
5
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
6
7
  import { homedir } from "node:os";
7
- import { join, dirname } from "node:path";
8
+ import { basename, join, isAbsolute, resolve } from "node:path";
8
9
  import { findProjectRoot } from "../context/agents-md.js";
9
- import { listProjectFiles, walkFiles, isProbablyBinary, fileSize } from "../fs-walk.js";
10
- import { zvecBuild, zvecQueryIds } from "./zvec-store.js";
10
+ import { listProjectFiles, walkFiles } from "../fs-walk.js";
11
+ import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
12
+ import { isSensitiveFilePath } from "../security/sensitive-files.js";
13
+ import { readModelContextFileSync } from "../fs-read.js";
14
+ import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
15
+ import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
11
16
  // Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
12
17
  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;
18
+ // Version 2 is the first cache format built exclusively through the protected-file identity boundary and
19
+ // keyed by source content rather than mtime alone. Versionless/older caches may contain historical aliases
20
+ // of secret files and are never queried or reused.
21
+ const INDEX_FORMAT = 2;
13
22
  /** Index location — repo index lives in the project (gitignore it); the rest are global. Derived/rebuildable. */
14
23
  export function indexPath(name, cwd) {
15
24
  if (name === "repo")
16
25
  return join(findProjectRoot(cwd), ".hara", "index", "repo.json");
26
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name))
27
+ throw new Error(`invalid semantic index name '${name}'`);
17
28
  return join(homedir(), ".hara", "index", `${name}.json`);
18
29
  }
30
+ function indexBase(name, cwd) {
31
+ return name === "repo" ? findProjectRoot(cwd) : homedir();
32
+ }
33
+ async function ensurePrivateIndexState(name, cwd) {
34
+ const requested = indexPath(name, cwd); // validates non-repo names before touching the filesystem
35
+ // Project `.hara` also contains explicitly user-authored memory/roles and keeps its existing sharing
36
+ // mode; only the private `index` child is repaired. The global ~/.hara control plane is private in full.
37
+ const dir = ensurePrivateStateSubdirectory(indexBase(name, cwd), [".hara", "index"], name === "repo" ? 1 : 0);
38
+ const path = join(dir.path, basename(requested));
39
+ const ignore = join(dir.path, ".gitignore");
40
+ const indexSnapshot = await readPrivateStateFileSnapshot(path);
41
+ const ignoreSnapshot = await readPrivateStateFileSnapshot(ignore);
42
+ return {
43
+ dir,
44
+ path,
45
+ ignore,
46
+ indexSnapshot,
47
+ ignoreSnapshot,
48
+ indexBoundary: bindHaraPrivateStateWritePath(path, dir.path, "write semantic index"),
49
+ ignoreBoundary: bindHaraPrivateStateWritePath(ignore, dir.path, "write semantic index ignore rule"),
50
+ };
51
+ }
52
+ function existingPrivateIndexPath(name, cwd) {
53
+ try {
54
+ const requested = indexPath(name, cwd);
55
+ const base = realpathSync.native(indexBase(name, cwd));
56
+ const hara = join(base, ".hara");
57
+ const dir = join(hara, "index");
58
+ for (const component of [hara, dir]) {
59
+ const info = lstatSync(component);
60
+ if (!info.isDirectory() || info.isSymbolicLink() || realpathSync.native(component) !== component)
61
+ return null;
62
+ }
63
+ const path = join(dir, basename(requested));
64
+ const info = lstatSync(path);
65
+ return info.isFile() && !info.isSymbolicLink() && info.nlink === 1 ? path : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
19
71
  function statMtime(p) {
20
72
  try {
21
73
  return statSync(p).mtimeMs;
@@ -58,30 +110,52 @@ function cosine(a, b) {
58
110
  }
59
111
  return na && nb ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
60
112
  }
61
- /** Build/refresh the index. **Incremental**: files whose mtime is unchanged since the last build keep their
62
- * existing vectors (no re-embed); only new/changed files are embedded, and deleted files drop out. A changed
113
+ function contentHash(chunks) {
114
+ const hash = createHash("sha256");
115
+ for (const chunk of chunks) {
116
+ const encoded = JSON.stringify([chunk.file, chunk.source, chunk.text]);
117
+ hash.update(String(Buffer.byteLength(encoded, "utf8"))).update(":").update(encoded);
118
+ }
119
+ return hash.digest("hex");
120
+ }
121
+ async function rotateLegacyIndex(name, cwd, state) {
122
+ if (state.indexSnapshot)
123
+ removePrivateStateFile(state.path, state.indexSnapshot, state.dir);
124
+ await zvecRemove(name, cwd);
125
+ }
126
+ /** Build/refresh the index. **Incremental**: files whose content hash is unchanged keep their existing
127
+ * vectors (no re-embed); only new/changed files are embedded, and deleted files drop out. A changed
63
128
  * embedding model forces a full rebuild (old vectors aren't comparable). Returns counts. */
64
129
  export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
65
- const p = indexPath(name, cwd);
130
+ const state = await ensurePrivateIndexState(name, cwd);
131
+ const p = state.path;
66
132
  // Load the previous index → reuse vectors for unchanged files.
67
133
  const prevByFile = new Map();
68
134
  let prevModel = "";
69
- if (existsSync(p)) {
135
+ let legacy = false;
136
+ if (state.indexSnapshot) {
70
137
  try {
71
- const old = JSON.parse(readFileSync(p, "utf8"));
72
- prevModel = old.model;
73
- for (const it of old.items ?? []) {
74
- const arr = prevByFile.get(it.file);
75
- if (arr)
76
- arr.push(it);
77
- else
78
- prevByFile.set(it.file, [it]);
138
+ const old = JSON.parse(state.indexSnapshot.text);
139
+ if (old.format !== INDEX_FORMAT) {
140
+ legacy = true;
141
+ }
142
+ else {
143
+ prevModel = old.model;
144
+ for (const it of old.items ?? []) {
145
+ const arr = prevByFile.get(it.file);
146
+ if (arr)
147
+ arr.push(it);
148
+ else
149
+ prevByFile.set(it.file, [it]);
150
+ }
79
151
  }
80
152
  }
81
153
  catch {
82
- /* corrupt index → full rebuild */
154
+ legacy = true; // corrupt/unknown cache → full rebuild and ANN rotation
83
155
  }
84
156
  }
157
+ if (legacy)
158
+ await zvecRemove(name, cwd);
85
159
  const sameModel = prevModel === model;
86
160
  const byFile = new Map();
87
161
  for (const c of chunks) {
@@ -95,14 +169,16 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
95
169
  const toEmbed = [];
96
170
  let reused = 0;
97
171
  for (const [file, fchunks] of byFile) {
98
- const mtime = fchunks[0].mtime ?? 0;
172
+ const currentHash = contentHash(fchunks);
99
173
  const prev = prevByFile.get(file);
100
- if (sameModel && prev?.length && mtime > 0 && prev.every((it) => it.mtime === mtime)) {
174
+ if (sameModel
175
+ && prev?.length === fchunks.length
176
+ && prev.every((it) => it.contentHash === currentHash)) {
101
177
  items.push(...prev); // file unchanged → keep its vectors
102
178
  reused += prev.length;
103
179
  }
104
180
  else {
105
- toEmbed.push(...fchunks);
181
+ toEmbed.push(...fchunks.map((chunk) => ({ ...chunk, contentHash: currentHash })));
106
182
  }
107
183
  }
108
184
  const B = 64;
@@ -111,18 +187,27 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
111
187
  const vecs = await embed(batch.map((c) => c.text));
112
188
  batch.forEach((c, j) => vecs[j] && items.push({ ...c, vec: vecs[j] }));
113
189
  }
114
- const dir = dirname(p);
115
- mkdirSync(dir, { recursive: true });
116
190
  // The index is derived + rebuildable (and may embed file contents) — never let it be committed.
117
- if (!existsSync(join(dir, ".gitignore")))
118
- writeFileSync(join(dir, ".gitignore"), "*\n", "utf8");
119
- writeFileSync(p, JSON.stringify({ model, items }), "utf8");
191
+ if (!state.ignoreSnapshot || state.ignoreSnapshot.text !== "*\n") {
192
+ await atomicWriteText(state.ignoreBoundary.target, "*\n", {
193
+ expected: state.ignoreSnapshot?.text ?? null,
194
+ expectedIdentity: state.ignoreSnapshot ?? undefined,
195
+ mode: 0o600,
196
+ boundary: state.ignoreBoundary,
197
+ });
198
+ }
199
+ await atomicWriteText(p, JSON.stringify({ format: INDEX_FORMAT, model, items }), {
200
+ expected: state.indexSnapshot?.text ?? null,
201
+ expectedIdentity: state.indexSnapshot ?? undefined,
202
+ mode: 0o600,
203
+ boundary: state.indexBoundary,
204
+ });
120
205
  // Build a zvec ANN index alongside the JSON cache (best-effort; queryIndex prefers it for retrieval).
121
206
  await zvecBuild(name, items.map((it) => ({ id: it.id, vec: it.vec })), cwd);
122
207
  return { total: items.length, embedded: toEmbed.length, reused };
123
208
  }
124
209
  export function indexExists(name, cwd) {
125
- return existsSync(indexPath(name, cwd));
210
+ return existingPrivateIndexPath(name, cwd) !== null;
126
211
  }
127
212
  // Never embed (and POST to an embedding provider, then persist in the index) a secret-bearing file —
128
213
  // the asset/skill/memory dirs aren't .gitignore-filtered, so a stray credentials.json/secrets.yaml/.env
@@ -141,18 +226,14 @@ export function collectDirChunks(dir, source) {
141
226
  if (!CODE_RE.test(rel) || looksSecret(rel))
142
227
  continue;
143
228
  const abs = join(dir, rel);
144
- if (fileSize(abs) > 200_000)
145
- continue;
146
- let buf;
229
+ let text;
147
230
  try {
148
- buf = readFileSync(abs);
231
+ text = readModelContextFileSync(abs, 200_000);
149
232
  }
150
233
  catch {
151
234
  continue;
152
235
  }
153
- if (isProbablyBinary(buf))
154
- continue;
155
- chunks.push(...chunkText(buf.toString("utf8"), abs, source, statMtime(abs)));
236
+ chunks.push(...chunkText(text, abs, source, statMtime(abs)));
156
237
  }
157
238
  return chunks;
158
239
  }
@@ -163,55 +244,103 @@ export function collectRepoChunks(root) {
163
244
  if (!CODE_RE.test(rel) || looksSecret(rel))
164
245
  continue;
165
246
  const abs = join(root, rel);
166
- if (fileSize(abs) > 200_000)
167
- continue;
168
- let buf;
247
+ let text;
169
248
  try {
170
- buf = readFileSync(abs);
249
+ text = readModelContextFileSync(abs, 200_000);
171
250
  }
172
251
  catch {
173
252
  continue;
174
253
  }
175
- if (isProbablyBinary(buf))
176
- continue;
177
- chunks.push(...chunkText(buf.toString("utf8"), rel, "repo", statMtime(abs)));
254
+ chunks.push(...chunkText(text, rel, "repo", statMtime(abs)));
178
255
  }
179
256
  return chunks;
180
257
  }
181
258
  /** Cosine-rank the index against the query embedding. Returns top-k hits (empty if no index). */
182
259
  export async function queryIndex(name, query, embed, cwd, k = 6) {
183
- const p = indexPath(name, cwd);
184
- if (!existsSync(p))
260
+ if (!existingPrivateIndexPath(name, cwd))
261
+ return [];
262
+ let state;
263
+ try {
264
+ state = await ensurePrivateIndexState(name, cwd);
265
+ }
266
+ catch {
267
+ return [];
268
+ }
269
+ const p = state.path;
270
+ if (!state.indexSnapshot)
185
271
  return [];
186
272
  let idx;
187
273
  try {
188
- idx = JSON.parse(readFileSync(p, "utf8"));
274
+ idx = JSON.parse(state.indexSnapshot.text);
189
275
  }
190
276
  catch {
191
277
  return [];
192
278
  }
279
+ if (idx.format !== INDEX_FORMAT) {
280
+ await rotateLegacyIndex(name, cwd, state);
281
+ return [];
282
+ }
193
283
  if (!idx.items?.length)
194
284
  return [];
285
+ // Old indexes may predate the sensitive-file boundary. Filter at query time as well as build time so a
286
+ // stale JSON/zvec cache can never resurrect protected content into model context.
287
+ const root = findProjectRoot(cwd);
288
+ const safeItems = idx.items.filter((item) => {
289
+ const path = isAbsolute(item.file) ? item.file : resolve(root, item.file);
290
+ return !looksSecret(item.file) && !isSensitiveFilePath(path);
291
+ });
292
+ if (!safeItems.length)
293
+ return [];
195
294
  const [qv] = await embed([query]);
196
295
  if (!qv)
197
296
  return [];
297
+ // A format marker proves which writer created the cache, not that its source still has the same bytes.
298
+ // Validate only ranked candidate files (cached per path) before returning their historical text. Missing,
299
+ // replaced, hard-linked, protected, oversized, or changed sources fail closed without making every query
300
+ // reread the entire corpus.
301
+ const freshness = new Map();
302
+ const isFresh = (item) => {
303
+ const path = isAbsolute(item.file) ? item.file : resolve(root, item.file);
304
+ const cached = freshness.get(path);
305
+ if (cached !== undefined)
306
+ return cached;
307
+ let fresh = false;
308
+ try {
309
+ const text = readModelContextFileSync(path, 200_000);
310
+ fresh = contentHash(chunkText(text, item.file, item.source)) === item.contentHash;
311
+ }
312
+ catch {
313
+ fresh = false;
314
+ }
315
+ freshness.set(path, fresh);
316
+ return fresh;
317
+ };
318
+ const rankFresh = (candidates) => {
319
+ const ranked = candidates
320
+ .map((it) => ({ item: it, score: cosine(qv, it.vec) }))
321
+ .sort((a, b) => b.score - a.score);
322
+ const hits = [];
323
+ for (const { item, score } of ranked) {
324
+ if (!isFresh(item))
325
+ continue;
326
+ hits.push({ file: item.file, source: item.source, text: item.text, score });
327
+ if (hits.length >= k)
328
+ break;
329
+ }
330
+ return hits;
331
+ };
198
332
  // Prefer the zvec ANN index for candidate retrieval; re-rank candidates by EXACT cosine from the JSON
199
333
  // store (identical score semantics to the brute-force path). Fall back to full brute-force if zvec is
200
334
  // unavailable / has no index / errors.
201
335
  const ids = await zvecQueryIds(name, qv, cwd, k);
202
336
  if (ids?.length) {
203
- const byId = new Map(idx.items.map((it) => [it.id, it]));
204
- const hits = ids
337
+ const byId = new Map(safeItems.map((it) => [it.id, it]));
338
+ const candidates = ids
205
339
  .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);
340
+ .filter((it) => Boolean(it));
341
+ const hits = rankFresh(candidates);
210
342
  if (hits.length)
211
343
  return hits;
212
344
  }
213
- return idx.items
214
- .map((it) => ({ file: it.file, source: it.source, text: it.text, score: cosine(qv, it.vec) }))
215
- .sort((a, b) => b.score - a.score)
216
- .slice(0, k);
345
+ return rankFresh(safeItems);
217
346
  }
@@ -3,28 +3,23 @@
3
3
  // null/false and the caller falls back to the JSON brute-force store in semindex.ts — so installs
4
4
  // without the native binding keep working (lexical/JSON floor preserved).
5
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
6
+ // Design: zvec stores vector→internal-id plus the original semantic id (a retrieval-only ANN index).
7
+ // The JSON store stays the durable embedding cache + SSOT for hit text/score. So zvec is used for fast
8
+ // candidate retrieval, but the SCORE is recomputed from the JSON vectors (in semindex.queryIndex) —
9
+ // identical score semantics, zero risk
9
10
  // to hybrid.ts's thresholds.
10
11
  import { homedir } from "node:os";
11
12
  import { join } from "node:path";
12
13
  import { rmSync, existsSync } from "node:fs";
14
+ import { createHash } from "node:crypto";
13
15
  import { findProjectRoot } from "../context/agents-md.js";
14
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
15
- let cached = null;
16
- let attempted = false;
17
+ let loadPromise = null;
17
18
  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;
19
+ // Share the in-flight import as well as its result. Returning a still-null cache to a concurrent first
20
+ // caller would make availability depend on timing and unnecessarily drop that operation to JSON search.
21
+ loadPromise ??= import("@zvec/zvec").catch(() => null);
22
+ return loadPromise;
28
23
  }
29
24
  export async function zvecAvailable() {
30
25
  return (await zvec()) !== null;
@@ -34,44 +29,128 @@ function zvecDir(name, cwd) {
34
29
  return join(findProjectRoot(cwd), ".hara", "index", "repo.zvec");
35
30
  return join(homedir(), ".hara", "index", `${name}.zvec`);
36
31
  }
32
+ /** Remove a derived ANN cache without loading the optional native binding. Used when the JSON format is
33
+ * rotated so an old candidate store can never outlive the policy/version that produced it. */
34
+ export async function zvecRemove(name, cwd) {
35
+ const path = zvecDir(name, cwd);
36
+ await withPathLock(path, () => {
37
+ rmSync(path, { recursive: true, force: true });
38
+ });
39
+ }
37
40
  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;
41
+ const SOURCE_ID = "source_id";
42
+ // zvec document ids are limited to a small ASCII character set and 64 bytes, while semantic-index ids are
43
+ // intentionally path-like (and may be absolute or Unicode). Keep the original id in a scalar field and use
44
+ // a fixed-width digest only as zvec's internal primary key.
45
+ function zvecId(id) {
46
+ return createHash("sha256").update(id, "utf8").digest("hex");
47
+ }
48
+ // A collection owns native RocksDB resources. closeSync() is the documented @zvec/zvec 0.5 API; making
49
+ // cleanup explicit and unconditional prevents a failed insert/query from retaining the process-local LOCK.
50
+ function closeCollection(col) {
51
+ if (!col)
52
+ return true;
43
53
  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
+ if (typeof col.closeSync !== "function")
55
+ return false;
56
+ col.closeSync();
54
57
  return true;
55
58
  }
56
59
  catch {
57
60
  return false;
58
61
  }
59
62
  }
63
+ // zvec permits concurrent readers but only one writer. More importantly, rebuilding removes and recreates
64
+ // the collection directory, so every operation on one path must be ordered in this process (readers too).
65
+ const pathTails = new Map();
66
+ async function withPathLock(path, operation) {
67
+ const previous = pathTails.get(path) ?? Promise.resolve();
68
+ let release;
69
+ const gate = new Promise((resolve) => {
70
+ release = resolve;
71
+ });
72
+ const tail = previous.then(() => gate);
73
+ pathTails.set(path, tail);
74
+ await previous;
75
+ try {
76
+ return await operation();
77
+ }
78
+ finally {
79
+ release();
80
+ if (pathTails.get(path) === tail)
81
+ pathTails.delete(path);
82
+ }
83
+ }
84
+ /** (Re)build a fresh zvec collection from already-embedded items. Best-effort — false on any failure. */
85
+ export async function zvecBuild(name, items, cwd) {
86
+ const z = await zvec();
87
+ if (!z || items.length === 0)
88
+ return false;
89
+ const path = zvecDir(name, cwd);
90
+ return withPathLock(path, () => {
91
+ let col = null;
92
+ let success = false;
93
+ try {
94
+ const dim = items[0].vec.length;
95
+ rmSync(path, { recursive: true, force: true }); // rebuild from the JSON cache (vectors already computed)
96
+ const schema = new z.ZVecCollectionSchema({
97
+ name,
98
+ fields: [{ name: SOURCE_ID, dataType: z.ZVecDataType.STRING }],
99
+ vectors: [{ name: VEC, dataType: z.ZVecDataType.VECTOR_FP32, dimension: dim, indexParams: { indexType: z.ZVecIndexType.FLAT, metricType: z.ZVecMetricType.COSINE } }],
100
+ });
101
+ col = z.ZVecCreateAndOpen(path, schema);
102
+ col.insertSync(items.map((it) => ({ id: zvecId(it.id), fields: { [SOURCE_ID]: it.id }, vectors: { [VEC]: it.vec } })));
103
+ success = true;
104
+ }
105
+ catch {
106
+ success = false;
107
+ }
108
+ finally {
109
+ if (!closeCollection(col))
110
+ success = false;
111
+ }
112
+ // Never leave a partially inserted ANN index visible: semindex treats any non-empty candidate set as
113
+ // authoritative for candidate selection, so an incomplete native store would be worse than JSON fallback.
114
+ if (!success) {
115
+ try {
116
+ rmSync(path, { recursive: true, force: true });
117
+ }
118
+ catch {
119
+ /* best-effort optional index cleanup */
120
+ }
121
+ }
122
+ return success;
123
+ });
124
+ }
60
125
  /** ANN candidate ids (best first) from the zvec index. null if unavailable/error → caller brute-forces. */
61
126
  export async function zvecQueryIds(name, qv, cwd, k) {
62
127
  const z = await zvec();
63
128
  if (!z)
64
129
  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
- }
130
+ const path = zvecDir(name, cwd);
131
+ return withPathLock(path, async () => {
132
+ if (!existsSync(path))
133
+ return null;
134
+ let col = null;
135
+ let docs = null;
136
+ let closed = false;
137
+ try {
138
+ col = z.ZVecOpen(path, { readOnly: true });
139
+ // over-fetch, then semindex re-ranks the candidates by exact cosine from the JSON store
140
+ // Omitting outputFields returns every scalar field (only source_id in current indexes) and also lets
141
+ // pre-source_id collections answer with their original native id during a rolling upgrade.
142
+ docs = await col.query({ fieldName: VEC, vector: qv, topk: Math.max(k * 4, 24) });
143
+ }
144
+ catch {
145
+ docs = null;
146
+ }
147
+ finally {
148
+ closed = closeCollection(col);
149
+ }
150
+ if (!docs || !closed)
151
+ return null;
152
+ // Old collections did not have source_id and used their JSON id directly; retain that best-effort read
153
+ // compatibility even though normal index builds replace them with the current schema.
154
+ return docs.map((doc) => (typeof doc.fields?.[SOURCE_ID] === "string" ? doc.fields[SOURCE_ID] : doc.id));
155
+ });
77
156
  }