@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.
- package/CHANGELOG.md +62 -0
- package/README.md +24 -11
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +33 -10
- package/dist/agent/touched.js +4 -0
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +141 -12
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/fs-read.js +318 -3
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +197 -11
- package/dist/gateway/discord.js +2 -4
- package/dist/gateway/feishu.js +4 -6
- package/dist/gateway/flows-pending.js +38 -31
- package/dist/gateway/matrix.js +3 -5
- package/dist/gateway/mattermost.js +2 -4
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +121 -73
- package/dist/gateway/signal.js +4 -6
- package/dist/gateway/slack.js +4 -6
- package/dist/gateway/telegram.js +3 -5
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +7 -8
- package/dist/gateway/weixin.js +22 -12
- package/dist/hooks.js +12 -6
- package/dist/index.js +142 -61
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +4 -2
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +2 -1
- package/dist/skills/skills.js +16 -7
- package/dist/tools/builtin.js +53 -27
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +15 -5
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +37 -17
- package/dist/tools/search.js +100 -40
- package/dist/tools/send.js +11 -5
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/search/semindex.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
8
|
+
import { basename, join, isAbsolute, resolve } from "node:path";
|
|
8
9
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
9
|
-
import { listProjectFiles, walkFiles
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
135
|
+
let legacy = false;
|
|
136
|
+
if (state.indexSnapshot) {
|
|
70
137
|
try {
|
|
71
|
-
const old = JSON.parse(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
prevByFile.
|
|
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
|
-
|
|
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
|
|
172
|
+
const currentHash = contentHash(fchunks);
|
|
99
173
|
const prev = prevByFile.get(file);
|
|
100
|
-
if (sameModel
|
|
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 (!
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
145
|
-
continue;
|
|
146
|
-
let buf;
|
|
229
|
+
let text;
|
|
147
230
|
try {
|
|
148
|
-
|
|
231
|
+
text = readModelContextFileSync(abs, 200_000);
|
|
149
232
|
}
|
|
150
233
|
catch {
|
|
151
234
|
continue;
|
|
152
235
|
}
|
|
153
|
-
|
|
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
|
-
|
|
167
|
-
continue;
|
|
168
|
-
let buf;
|
|
247
|
+
let text;
|
|
169
248
|
try {
|
|
170
|
-
|
|
249
|
+
text = readModelContextFileSync(abs, 200_000);
|
|
171
250
|
}
|
|
172
251
|
catch {
|
|
173
252
|
continue;
|
|
174
253
|
}
|
|
175
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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(
|
|
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(
|
|
204
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
7
|
-
// cache + SSOT for hit text/score. So zvec is used for fast
|
|
8
|
-
// recomputed from the JSON vectors (in semindex.queryIndex) —
|
|
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
|
|
16
|
-
let attempted = false;
|
|
17
|
+
let loadPromise = null;
|
|
17
18
|
async function zvec() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
}
|